Home / Features / Security
Chapter one

The security model

Four boundaries do the work: what you know, what your machine holds, what a ticket proves, and what a node is allowed to conclude on its own. Nothing else is load-bearing.

Passwords

Argon2id, 64 MB, t=3, p=1, per-user salt. Parameters stored beside the hash so cost can be raised later.

Devices

An Ed25519 keypair per machine, protected by the OS key store, proving itself by signature on every sign-in.

Tokens

15-minute ES256 access tokens, 30-day opaque refresh tokens that rotate on every use.

Tickets

90 seconds, single use, bound to one node and one WireGuard key. Redeemed online or not at all.

Device binding

A fingerprint you send me is not a fact

The obvious way to limit an account to one machine is to ask the client for a device fingerprint and trust it. That is worth nothing: whoever wants two devices sends the same string from both, and whoever wants to share an account posts the string to a friend.

So the fingerprint here is a label, shown in the console so you can tell your laptop from your phone. The binding is a key.

  • The client generates an Ed25519 keypair on first sign-in and stores the private half in the platform key store — DPAPI or a CNG/TPM container on Windows
  • The public half is registered against the account, with a name and platform
  • Every later sign-in is challenge–response: the master issues a nonce, the client signs it, the master verifies against the stored key
  • Signing covers the decoded nonce bytes, and a fixed vector is asserted from both the .NET and the Dart side so the two languages cannot drift apart

Running a second machine now means either extracting a key the OS is protecting, or asking an administrator to raise the device limit. That is a boundary rather than a speed bump.

Client Master who am I? (email) random 32-byte nonce sign(nonce, device key) password + signature access + refresh token
The private key never crosses the wire. The nonce is worthless a minute later.
Connect tickets

Four properties, all of them tested

A ticket is the thing a node accepts. It is worth stating exactly what it proves, because each property closes a specific attack.

Single use

Redemption is an atomic compare-and-set on the ticket row. Two simultaneous redemptions: one wins, the other is refused. A ticket captured on the wire and replayed a second later is already spent.

Node-bound

A ticket issued for node A is refused when node B presents it. A hostile node cannot harvest tickets and use them elsewhere.

Key-bound

The master records the client's WireGuard public key when it issues the ticket, and the node must present that same key at redemption. An intercepted ticket cannot be redeemed for an attacker's key.

Short-lived

90 seconds — long enough for a slow network, short enough that a stolen ticket is nearly useless. Expiry is judged by the master; the node's own check is advisory and tolerant of clock skew.

Signed as well as random

The opaque secret is what matters, but the ticket is wrapped in a signed envelope carrying the node id and expiry so a node can drop obvious garbage without a round trip. That is denial-of-service protection, not authorization — the online redemption stays mandatory.

One refusal, one code

Expired, wrong node, already redeemed and key mismatch all answer identically. Telling them apart would turn the node into an oracle that reports which half of a guess was right — and there is no operational question the distinction answers that the master's own logs do not.

token A used once token B current … token A presented again the whole family is revoked, and audited
A spent refresh token coming back is evidence of theft, not a retry.
Tokens

Short lives, and rotation with teeth

Access tokens last fifteen minutes and are signed ES256 by a key that rotates, published through JWKS so validation never needs a shared secret. The algorithm is pinned, so a token offering alg:none or a symmetric algorithm against a published key is refused rather than considered.

Refresh tokens are opaque 256-bit values, stored only as hashes, bound to the device, and rotated on every use. If a spent one is presented again — the signature of a stolen token being used alongside the real client — the entire token family for that device is revoked and the event is written to the audit log.

  • Clock skew tolerance is 30 seconds, not the framework's default five minutes, which would quietly extend every token by a third
  • Signing keys are encrypted before they reach the database, with a key that never lives beside it
  • Key refresh preserves identity, so a rotation cannot break validation mid-flight
Blast radius

What each compromise actually costs

A stolen password

Not enough. The device signature is a second, independent proof, and enrolling a new device is refused past the account's limit.

A stolen access token

Fifteen minutes, and it cannot mint a ticket for a device it is not bound to.

A stolen refresh token

One use, then the reuse is detected and the family dies. The real client's next refresh is what triggers it.

An intercepted ticket

Ninety seconds, one node, one WireGuard key — the attacker's own key will not redeem it.

A compromised node

It sees the traffic of the clients on it, which is inherent to any VPN. It holds no credentials, has no database access, cannot mint a session, and cannot redeem a ticket bound to another node. Its certificate is revoked centrally and stops working at the TLS layer.

A compromised master

Total, and stated plainly rather than hedged. Mitigated by keeping the console off the open internet, by administrator two-factor, and by an append-only audit log that records every administrative action with actor, target and source address.

Read the connection path next

The security model is easiest to believe once you have followed a single connection from sign-in to tunnel.