Skip to content

Replace pre-shared auth tokens with ed25519 public-key auth - #20

Merged
andrewchen5678 merged 7 commits into
mainfrom
pubkey-auth
Aug 28, 2026
Merged

andrewchen5678 merged 7 commits into
mainfrom
pubkey-auth

Conversation

@andrewchen5678

Copy link
Copy Markdown
Contributor

Brings ezvpn-windows in line with the Apple and Android apps, which moved to
ed25519 public-key auth in ezvpn core v0.0.40+. The pre-shared auth token is
gone: a profile now names a key from a shared, named key list, and the config
JSON carries auth_key (the client's ed25519-sec:… secret) instead of
auth_token. The public half goes on the server's authorized_keys file.

What's here

  • Keys come from the FFI, never from .NET. Core/Interop/AuthKey wraps
    ezvpn_generate_client_key / ezvpn_client_public_key; the key format stays
    owned by the Rust flexaccess-keys crate.
  • A shared, named key list (AuthKeyStore), referenced by profiles via
    TunnelProfile.AuthKeyId. Saving a profile copies that key's secret into the
    profile's own credential, so deleting a key never breaks a saved profile —
    it keeps connecting, and the editor lets you point it at another key.
  • A new keys screen (AuthKeysDialog): generate, import, rename in place,
    copy, delete. Everything is inline because WinUI allows one ContentDialog
    at a time; "Manage keys…" hides and re-shows the editor so a half-filled
    form survives the round trip.
  • Secrets stay in Credential Manager, one credential per record —
    ezvpn-profile-key:<profileId>, ezvpn-relay:<profileId>, ezvpn-key:<keyId>.
    Per-key records because a credential blob caps out at 2560 bytes. Copying a
    secret is kept out of clipboard history and off the Cloud Clipboard.
  • No back-compat paths. AuthKeyId is required with no default, so a
    keyless profile can neither be constructed nor deserialized.
  • Native DLL pinned to ezvpn v0.0.43; verify-ezvpn-commit default ref moved
    to the matching commit. README and AGENTS.md updated.

Verification

Verified on the Hyper-V VM with pwsh ci/windows/remote.ps1 (build, test,
publish, MSI): 0 warnings, 0 errors, 47 tests passed, MSI built. The FFI
contract was also checked empirically on the VM — a bad key gives
invalid auth key: … must decode to 32 bytes, and omitting the field gives
invalid config JSON: missing field `auth_key` .

🤖 Generated with Claude Code

https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN

Andrew Chen and others added 7 commits August 27, 2026 16:14
Adopt ezvpn's public-key handshake, matching ezvpn-apple and ezvpn-android: the
client authenticates with an ed25519 keypair in the shared FlexAccess format, and
the server accepts it only if the public half is on its authorized_keys file. The
ezvpn_start config's auth_token becomes auth_key.

Keys are generated and derived exclusively through the FFI
(ezvpn_generate_client_key / ezvpn_client_public_key, wrapped by
Core/Interop/AuthKey), so the key format stays owned by flexaccess-keys and is
never reimplemented in .NET.

AuthKeyStore keeps the app's shared, named key list — the same model the other
apps use: profiles reference a key by id (TunnelProfile.AuthKeyId) and, on save,
copy that key's secret into the profile's own credential, which is what the
tunnel is handed. So a key deleted from the list doesn't break profiles already
saved with it. Public halves are re-derived on load rather than stored, and a
record whose secret no longer parses is dropped from the list without being
deleted from storage (a transient native failure must not destroy a key).

Storage diverges from the siblings deliberately: they keep the whole list as one
Keychain / encrypted-preferences document, but a Credential Manager blob caps out
at 2560 bytes (about nine keys), so each key is its own ezvpn-key:<keyId>
credential. Per-record writes also mean an add, rename or delete never rewrites
the other keys. TokenStore becomes SecretStore throughout, and its reads now
distinguish "not stored" from "couldn't be read" instead of returning null for
both.

The new Auth keys dialog generates, imports, renames, copies and deletes keys;
secrets never render, and copying one is behind a confirmation. Its actions are
all inline (rename in place, confirmation flyouts) because WinUI allows only one
ContentDialog at a time. The profile editor's password box becomes a key picker
that shows the selected key's public half, warns when a profile's key has been
removed from the list, and reaches the key manager by hiding and re-showing
itself so the half-filled form survives.

Pins ezvpn.dll to v0.0.43: v0.0.36 predates the key FFI symbols. Per the repo's
no-backward-compatibility rule there is no migration — profiles saved with a
pre-shared token keep loading but fail the handshake until re-saved with a key.

Verified on the CI VM (ci/windows/remote.ps1): build, 43 Core tests, publish and
MSI all green, plus a throwaway harness against the pinned DLL confirming
generate/derive round-trips and that ezvpn_start wants exactly auth_key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN
- Keep copied secret keys out of clipboard history (Win+V) and off the Cloud
  Clipboard via SetContentWithOptions, so the most sensitive thing the app holds
  isn't left in a history pane or synced to the user's other machines. Public
  keys keep the ordinary clipboard path — they aren't secrets. This is the
  closest Windows equivalent of the Apple app's expiring pasteboard copy.
- Explain an emptied auth-key picker in TunnelEditDialog.SetKeys: deleting the
  selected key in the key manager and returning to the editor cleared the picker
  with no notice, since only LoadFrom raised the warning.
- Skip a credential whose stored secret is missing or null rather than throwing.
  System.Text.Json doesn't enforce non-nullable annotations, so a hand-edited
  `"secret": null` blob landed as null and the unchecked .Length made one corrupt
  record fail the whole key list. A missing name stays non-fatal — the key works
  and can be renamed.

Verified on the CI VM: build, 43 Core tests, publish and MSI all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN
Strict no backward compatibility: nothing may exist to make an older build's
data work.

- Store a profile's auth key under ezvpn-profile-key:<profileId> instead of
  reusing ezvpn:<profileId>, the target that held the pre-shared token. That
  target holds a different kind of secret, and reading one back as an ed25519 key
  only produced a baffling "must decode to 32 bytes" at connect time. A profile
  from an older build now simply has no auth key, which is what it is.
- Collapse "profile names no key" and "profile's key was deleted" into one state
  in TunnelsManager.KeyNameFor and TunnelEditDialog.LoadFrom. The empty-AuthKeyId
  branch and the AuthKeyId.Length > 0 guard existed only to tell a pre-key-auth
  profile apart from a current one; either way the profile can't be re-saved
  until a key is picked, so there is one path and one message.
- Drop the config tests' assertions that the removed auth_token and relay_only
  fields aren't emitted. Nothing emits them and no format they belong to is
  supported, so they only kept dead field names alive.

AGENTS.md records the rule.

Verified on the CI VM: build, 43 Core tests, publish and MSI all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN
No users outside the sandbox, so nothing needs the keyless fallback: drop
TunnelProfile.AuthKeyId's "" default for `required`. A profile without a key can
no longer be built in code, and JSON omitting authKeyId fails to deserialize
instead of loading as a profile that could never connect. TunnelEditDialog
resolves the selection through RequiredKeyId, cashing in the invariant Validate
already enforces.

Deleting a key stays non-destructive and recoverable, which `required` must not
change: only that key's own credential is removed. The profile keeps its key id
and its own copy of the secret, so it goes on connecting; the editor shows no
selection with "Pick one before saving", and choosing another key repairs it. No
cascade to profiles, and no state the UI can't get out of.

Says so where it matters: the detail row now reads "Not in the key list — pick
one in Edit" rather than naming the problem without the fix, and the delete
confirmation states that profiles using the key keep connecting until re-saved.

New TunnelProfile tests cover the JSON round trip, that only the key id (never a
secret) is on disk, that a keyless profile is rejected, and that a profile whose
key was deleted survives and can be repointed at another key.

Verified on the CI VM: build, 47 Core tests, publish and MSI all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN
Two UI bugs found in review, both in the "add a key, then use it" flow.

AuthKeyStore hands out its own live List, so TunnelEditDialog.SetKeys was
re-assigning ItemsSource the very same instance it already held. A plain
List raises no collection-change notifications and setting a dependency
property to its current value is a no-op, so a key added through "Manage
keys…" never reached the picker — which is the whole fresh-install path.
Assign a snapshot instead, so the identity always changes.

The keys dialog rebuilt every row whenever a name box lost focus, even
when the name was unchanged. Clicking a row's copy or delete button moves
focus first, so the rebuild removed that button before the click landed
and the action was silently dropped. Only rebuild for an actual rename
(which re-sorts the list), and defer it past the current input event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN
@andrewchen5678
andrewchen5678 merged commit 5007f5a into main Aug 28, 2026
1 check passed
@andrewchen5678
andrewchen5678 deleted the pubkey-auth branch August 28, 2026 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant