anvilsign in

collin/anvil

RenderedSource

1# Passkeys
2
3Sign in with Touch ID, Windows Hello, a phone, or a security key instead of an
4account password. Passkeys are for *login only* — repository secrets
5([secrets.md](secrets.md)) stay keyed to your ssh keys, because CI needs to
6unlock them from a terminal where no authenticator is present.
7
8## Using them
9
10**Register** (account settings → Passkeys): name the device, press *Add
11passkey*, approve the prompt. Registering a second passkey on the same
12authenticator is refused by the browser rather than silently duplicated — anvil
13sends the existing credential ids as `excludeCredentials`.
14
15**Sign in**: the login page's *Sign in with a passkey* button. No username: a
16passkey is a discoverable credential, so the authenticator tells anvil which
17credential it used and that identifies the account.
18
19Password sign-in keeps working, and remains the way in if you lose every
20authenticator. Removing your last passkey is allowed for the same reason.
21
22## What anvil stores, and what it means if the database leaks
23
24Only public material: the credential id, the credential's public key, and the
25counters WebAuthn asks a relying party to track. The private key stays in the
26authenticator and is never transmitted, so — unlike a password hash — nothing in
27the `passkeys` table can be turned into a login, offline or otherwise. A leak
28costs users their registrations, not their accounts.
29
30Two properties come from the protocol rather than from anvil's code:
31
32- **Phishing resistance.** The authenticator binds every signature to anvil's
33 relying-party id. A look-alike site cannot get a usable signature, even with a
34 perfect replica of this UI.
35- **Replay resistance.** Every ceremony is a fresh random challenge, held in
36 memory, valid for five minutes, and accepted exactly once.
37
38## The relying-party id is your `base_url` host
39
40WebAuthn scopes a credential to one host, taken here from `http.base_url`:
41
42| `base_url` | RP id |
43|-----------------------------------|---------------------------|
44| `https://anvil.richardscollin.com` | `anvil.richardscollin.com` |
45| `https://anvil.localhost` | `anvil.localhost` |
46| `http://localhost:3000` | `localhost` |
47
48Consequences worth knowing before you move an instance:
49
50- **Change the host and existing passkeys stop working.** They are not deleted,
51 they simply belong to a different site now; users re-register (password login
52 is the way back in).
53- **Passkeys do not travel between instances.** One created against the local
54 Docker instance (`deploy/dev.sh`) is not usable on production, by design.
55- **WebAuthn requires a secure context**: HTTPS, or plain `localhost`. A LAN IP
56 over HTTP will not offer passkeys at all. `deploy/dev.sh` + portless gives
57 local development real HTTPS, which is why passkeys can be tested there.
58
59## Implementation
60
61`crates/anvil-core/src/passkeys.rs` holds the credential storage and the
62in-memory challenge registry; `crates/anvil-web/src/passkeys.rs` holds the two
63ceremonies, the JSON, and the browser glue.
64
65Verification is [`webauthn_rp`](https://crates.io/crates/webauthn_rp), chosen
66over the better-known `webauthn-rs` for one hard reason: `webauthn-rs` depends
67on OpenSSL, and anvil ships as a statically linked musl binary built by
68`deploy/build.sh` with no C toolchain in the picture. `webauthn_rp` is pure Rust
69and implements the spec's ceremony steps explicitly.
70
71Only passkeys are supported — discoverable credentials with user verification
72required. No attestation is requested (`none`), which is the norm for consumer
73authenticators and avoids collecting hardware identifiers we have no use for.
74
75The browser side hand-rolls the base64url ↔ ArrayBuffer conversions rather than
76using `PublicKeyCredential.parseCreationOptionsFromJSON()` / `toJSON()`: those
77are recent enough that relying on them would narrow support to new browsers for
78no gain.