Replace pre-shared auth tokens with ed25519 public-key auth - #20
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings
ezvpn-windowsin line with the Apple and Android apps, which moved toed25519 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'sed25519-sec:…secret) instead ofauth_token. The public half goes on the server'sauthorized_keysfile.What's here
Core/Interop/AuthKeywrapsezvpn_generate_client_key/ezvpn_client_public_key; the key format staysowned by the Rust
flexaccess-keyscrate.AuthKeyStore), referenced by profiles viaTunnelProfile.AuthKeyId. Saving a profile copies that key's secret into theprofile'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.
AuthKeysDialog): generate, import, rename in place,copy, delete. Everything is inline because WinUI allows one
ContentDialogat a time; "Manage keys…" hides and re-shows the editor so a half-filled
form survives the round trip.
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.
AuthKeyIdisrequiredwith no default, so akeyless profile can neither be constructed nor deserialized.
v0.0.43;verify-ezvpn-commitdefault ref movedto 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 givesinvalid config JSON: missing field `auth_key`.🤖 Generated with Claude Code
https://claude.ai/code/session_01CWMkFDzsTTQVQsUxpzF2wN