collin/anvil
RenderedSource
| 1 | # Passkeys |
| 2 | |
| 3 | Sign in with Touch ID, Windows Hello, a phone, or a security key instead of an |
| 4 | account password. Passkeys are for *login only* — repository secrets |
| 5 | ([secrets.md](secrets.md)) stay keyed to your ssh keys, because CI needs to |
| 6 | unlock them from a terminal where no authenticator is present. |
| 7 | |
| 8 | ## Using them |
| 9 | |
| 10 | **Register** (account settings → Passkeys): name the device, press *Add |
| 11 | passkey*, approve the prompt. Registering a second passkey on the same |
| 12 | authenticator is refused by the browser rather than silently duplicated — anvil |
| 13 | sends the existing credential ids as `excludeCredentials`. |
| 14 | |
| 15 | **Sign in**: the login page's *Sign in with a passkey* button. No username: a |
| 16 | passkey is a discoverable credential, so the authenticator tells anvil which |
| 17 | credential it used and that identifies the account. |
| 18 | |
| 19 | Password sign-in keeps working, and remains the way in if you lose every |
| 20 | authenticator. Removing your last passkey is allowed for the same reason. |
| 21 | |
| 22 | ## What anvil stores, and what it means if the database leaks |
| 23 | |
| 24 | Only public material: the credential id, the credential's public key, and the |
| 25 | counters WebAuthn asks a relying party to track. The private key stays in the |
| 26 | authenticator and is never transmitted, so — unlike a password hash — nothing in |
| 27 | the `passkeys` table can be turned into a login, offline or otherwise. A leak |
| 28 | costs users their registrations, not their accounts. |
| 29 | |
| 30 | Two 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 | |
| 40 | WebAuthn 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 | |
| 48 | Consequences 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 |
| 62 | in-memory challenge registry; `crates/anvil-web/src/passkeys.rs` holds the two |
| 63 | ceremonies, the JSON, and the browser glue. |
| 64 | |
| 65 | Verification is [`webauthn_rp`](https://crates.io/crates/webauthn_rp), chosen |
| 66 | over the better-known `webauthn-rs` for one hard reason: `webauthn-rs` depends |
| 67 | on 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 |
| 69 | and implements the spec's ceremony steps explicitly. |
| 70 | |
| 71 | Only passkeys are supported — discoverable credentials with user verification |
| 72 | required. No attestation is requested (`none`), which is the norm for consumer |
| 73 | authenticators and avoids collecting hardware identifiers we have no use for. |
| 74 | |
| 75 | The browser side hand-rolls the base64url ↔ ArrayBuffer conversions rather than |
| 76 | using `PublicKeyCredential.parseCreationOptionsFromJSON()` / `toJSON()`: those |
| 77 | are recent enough that relying on them would narrow support to new browsers for |
| 78 | no gain. |