Skip to content

Account web, passkeys and device-bound refresh tokens - #25

Closed
AlxcBTW wants to merge 1 commit into
mainfrom
feat/passkeys
Closed

AlxcBTW wants to merge 1 commit into
mainfrom
feat/passkeys

Conversation

@AlxcBTW

@AlxcBTW AlxcBTW commented Oct 24, 2026

Copy link
Copy Markdown
Member

Makes account takeover hard: refresh tokens are bound to a key on the device, and sign-in, account creation and account management move to web pages the cloud serves. Design: network ADR 0016.

Waits for anyplaynet/network#20. Cloud CI builds against network main, which does not have the new API types until that PR is merged. (Network main also already has the moderation API that this repository does not use yet: a missing field statement error in CI comes from that, not from this change.)

What it does

  • Tokens v2 (crates/ticket): kid, aud, iat, signature over a domain separator; v1 still read. Test vectors, extended fuzz target.
  • Device binding: a device sends its public key at sign-in; refresh needs an AnyPlay-Proof signed by it (60 seconds, once; nonces in the store, so every node refuses a replay). A copied refresh token alone fails and does not sign the device out. A replaced token that comes back signs the device out (the family ends). "Sign out everywhere" signs out every device and browser and sets a per-account not-before time, which is checked on every request and sent to the other nodes.
  • Account web (crates/cloud/src/web, needs public_url): sign in by e-mail code or passkey, create account (address, code, handle, age, terms), account page (e-mail, passkeys, devices, sign out everywhere, export, delete). Server-rendered, one small script for WebAuthn, strict CSP, Inter served from the binary, own session cookie (HttpOnly, SameSite=Strict, __Host-/Secure on https), CSRF token and origin check on every POST, a recent sign-in for sensitive actions. Dark first, light follows the system; launcher tokens.
  • Hand-off to the app: /web/authorize (loopback redirect only, PKCE S256, state) ends with a 60-second one-time code to 127.0.0.1; POST /v1/auth/browser/token trades code and verifier for tokens bound to the device key. POST /v1/auth/browser/link (proof required) gives a one-time link to the account page that asks before it signs in.
  • Passkeys: webauthn-rs 0.5.5 (MPL-2.0; cargo-deny passes). Discoverable sign-in, user verification required, counter check, state kept in the store and taken once. The e-mail code stays as recovery.
  • Age and terms: age_confirmed_at, terms_version, terms_accepted_at on the account (compliance gap G9); terms_version setting; a changed version is asked again at the next web sign-in.
  • Migration 0008_passkeys.sql (SQLite and Postgres). Settings: public_url, terms_version, legacy_device_days. Docs: configuration, ARCHITECTURE (threat model), compliance inventory.

Migration of existing devices

Devices signed in before this version get legacy_until = 30 days after the migration. Until then they refresh as before; the net service of the matching version binds them on its first refresh. After that they must sign in again. legacy_device_days = 0 requires a key from every new sign-in. Access tokens change format: replace the nodes of a cluster together.

Build note

webauthn-rs links OpenSSL. The crate enables openssl's vendored feature, so the container needs no libssl; building needs Perl and a C compiler (the Dockerfile's builder image has both, and so do the CI runners, I expect; if the Windows job cannot build it, that is the thing to look at).

Checked locally

cargo fmt (CI's list), cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace on SQLite and on Postgres with NATS, cargo deny check, rules.mjs. New tests: tests/binding.rs (stolen refresh token, replayed proof, family revocation, sign out everywhere, legacy devices), tests/web.rs (sign-in and create account in a cookie-jar browser, PKCE mismatch, used and expired codes, non-loopback redirects, CSRF and origin, terms, account page, link, passkeys with a software authenticator), crates/e2e/tests/browser_sign_in.rs (real net service: browser flow, refresh with a proof, account link). The fuzz target builds only on Linux with nightly; its logic was run locally with a shim.

Not tested: a real browser with a real authenticator (WebAuthn in the browser is the one thing the software authenticator cannot cover), and the Windows CI build of OpenSSL.

AlxcBTW added a commit that referenced this pull request Oct 24, 2026
Network #20 added Info.web and Device.key. The cloud answers no account
web yet (that comes with #25), and test and load devices send no key.
Implements network ADR 0016 (network PR 20). Access, relay and appeal tokens are v2 (kid, aud,
iat, domain-separated signature; v1 still read). Refresh tokens are bound to a device key and
need a proof; a replaced token signs the device out; sign out everywhere sets a not-before time
per account. The cloud serves the account web under /web (sign in, create account, account
page, ban statement and appeal), the one-time hand-off to the net service loopback address
with PKCE, the link to the account page, and passkeys with webauthn-rs. Migration 0008. Age and
terms version are recorded. Banned addresses are checked with the keyed hash of moderation.
@AlxcBTW

AlxcBTW commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

Superseded by the rebased branch feat/account-web (new PR), because force-pushing this branch is not allowed here.

@AlxcBTW AlxcBTW closed this Oct 24, 2026
@AlxcBTW
AlxcBTW deleted the feat/passkeys branch October 25, 2026 22:39
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