collin/anvil · bdd0fcba
Add passkey sign-in (WebAuthn)
Collin Richards · 2026-08-18 09:42 UTC · bdd0fcba025c5f40546448370fb805f5d0aa0d02 · parent 04186540 · browse files
modifiedREADME.md+8 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | ``` | |
| 27 | 27 | ||
| 28 | 28 | Configuration is optional; see [`anvil.example.toml`](anvil.example.toml). | |
| 29 | + | `PORT`/`HOST` and `ANVIL_BASE_URL` (or `PORTLESS_URL`) override the listen | |
| 30 | + | address and public URL, so a proxy can place anvil without a config file. | |
| 31 | + | ||
| 32 | + | To run it the way it runs in production — in Docker, with CI able to reach the | |
| 33 | + | host's Docker socket — use `./deploy/dev.sh`, which builds the image, starts a | |
| 34 | + | container, and (with [portless](https://www.npmjs.com/package/portless)) serves | |
| 35 | + | it over HTTPS at `https://anvil.localhost`. | |
| 29 | 36 | ||
| 30 | 37 | ## Docs | |
| 31 | 38 | ||
| 32 | 39 | - [CI artifacts](docs/ci-artifacts.md) | |
| 40 | + | - [Passkeys](docs/passkeys.md) — WebAuthn sign-in | |
| 33 | 41 | - [Repository secrets](docs/secrets.md) — encrypted to your ssh keys in the | |
| 34 | 42 | browser; anvil stores ciphertext it cannot open | |
| 35 | 43 | - [Threat model for untrusted users](docs/untrusted-mode.md) | |
modifiedTODO.md+3 −0
| ⋯ 29 unchanged lines | |||
| 30 | 30 | repo listings for visual browsing | |
| 31 | 31 | - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and | |
| 32 | 32 | `last_used_at` tracking | |
| 33 | + | - [ ] passkey follow-ups (docs/passkeys.md): conditional UI (autofill-style | |
| 34 | + | sign-in), and a warning before removing the last passkey on a | |
| 35 | + | password-less-by-preference account | |
| 33 | 36 | - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an | |
| 34 | 37 | ssh signature instead of the account password; per-step rather than per- | |
| 35 | 38 | pipeline scoping; `ssh-rsa` recipients (needs an RSA-OAEP branch in both the | |
| ⋯ 1 unchanged line | |||
modifiedcrates/anvil-cli/src/main.rs+5 −0
| ⋯ 185 unchanged lines | |||
| 186 | 186 | Box::new(anvil_core::periodic::SecretVaultSweepJob) | |
| 187 | 187 | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 188 | 188 | ), | |
| 189 | + | ( | |
| 190 | + | std::time::Duration::from_secs(300), | |
| 191 | + | Box::new(anvil_core::periodic::PasskeyCeremonySweepJob) | |
| 192 | + | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 193 | + | ), | |
| 189 | 194 | ]; | |
| 190 | 195 | anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await; | |
| 191 | 196 | ||
| ⋯ 140 unchanged lines | |||
modifiedcrates/anvil-core/Cargo.toml+1 −0
| ⋯ 18 unchanged lines | |||
| 19 | 19 | hmac.workspace = true | |
| 20 | 20 | sha2.workspace = true | |
| 21 | 21 | ssh-key.workspace = true | |
| 22 | + | webauthn_rp.workspace = true | |
| 22 | 23 | serde.workspace = true | |
| 23 | 24 | serde_json.workspace = true | |
| 24 | 25 | serde_yaml.workspace = true | |
| ⋯ 13 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+19 −1
| ⋯ 11 unchanged lines | |||
| 12 | 12 | CiRun, | |
| 13 | 13 | Issue, | |
| 14 | 14 | IssueComment, | |
| 15 | + | Passkey, | |
| 15 | 16 | RepoSecret, | |
| 16 | 17 | Repository, | |
| 17 | 18 | Session, | |
| ⋯ 29 unchanged lines | |||
| 47 | 48 | Attachment, | |
| 48 | 49 | ApiToken, | |
| 49 | 50 | AdminCache, | |
| 50 | - | RepoSecret | |
| 51 | + | RepoSecret, | |
| 52 | + | Passkey | |
| 51 | 53 | )) | |
| 52 | 54 | .connect(&url) | |
| 53 | 55 | .await?; | |
| ⋯ 26 unchanged lines | |||
| 80 | 82 | ADMIN_CACHE_DDL, | |
| 81 | 83 | REPO_SECRETS_DDL, | |
| 82 | 84 | r#"CREATE INDEX IF NOT EXISTS "index_repo_secrets_by_repo_id" ON "repo_secrets" ("repo_id")"#, | |
| 85 | + | PASSKEYS_DDL, | |
| 86 | + | r#"CREATE INDEX IF NOT EXISTS "index_passkeys_by_user_id" ON "passkeys" ("user_id")"#, | |
| 87 | + | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_passkeys_by_credential_id" ON "passkeys" ("credential_id")"#, | |
| 83 | 88 | ]; | |
| 84 | 89 | ||
| 85 | 90 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 52 unchanged lines | |||
| 138 | 143 | "created_at" BIGINT NOT NULL, | |
| 139 | 144 | "updated_at" BIGINT NOT NULL )"#; | |
| 140 | 145 | ||
| 146 | + | const PASSKEYS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "passkeys" ( | |
| 147 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 148 | + | "user_id" BIGINT NOT NULL, | |
| 149 | + | "name" TEXT NOT NULL, | |
| 150 | + | "credential_id" TEXT NOT NULL, | |
| 151 | + | "user_handle" TEXT NOT NULL, | |
| 152 | + | "static_state" TEXT NOT NULL, | |
| 153 | + | "dynamic_state" TEXT NOT NULL, | |
| 154 | + | "transports" BIGINT NOT NULL, | |
| 155 | + | "created_at" BIGINT NOT NULL, | |
| 156 | + | "last_used_at" BIGINT NOT NULL )"#; | |
| 157 | + | ||
| 141 | 158 | const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" ( | |
| 142 | 159 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 143 | 160 | "key" TEXT NOT NULL, | |
| ⋯ 66 unchanged lines | |||
| 210 | 227 | "attachments", | |
| 211 | 228 | "api_tokens", | |
| 212 | 229 | "repo_secrets", | |
| 230 | + | "passkeys", | |
| 213 | 231 | ]; | |
| 214 | 232 | ||
| 215 | 233 | /// Every schema object (table + indexes) for `table`, normalized. | |
| ⋯ 118 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+5 −0
| ⋯ 15 unchanged lines | |||
| 16 | 16 | pub mod issues; | |
| 17 | 17 | pub mod language; | |
| 18 | 18 | pub mod models; | |
| 19 | + | pub mod passkeys; | |
| 19 | 20 | pub mod periodic; | |
| 20 | 21 | pub mod preview_images; | |
| 21 | 22 | pub mod repos; | |
| ⋯ 16 unchanged lines | |||
| 38 | 39 | CiRun, | |
| 39 | 40 | Issue, | |
| 40 | 41 | IssueComment, | |
| 42 | + | Passkey, | |
| 41 | 43 | RepoSecret, | |
| 42 | 44 | Repository, | |
| 43 | 45 | Session, | |
| ⋯ 23 unchanged lines | |||
| 67 | 69 | /// Plaintext repo secrets for CI, held in memory only and lost on | |
| 68 | 70 | /// restart — see [`secrets::Vault`]. | |
| 69 | 71 | pub vault: secrets::Vault, | |
| 72 | + | /// WebAuthn challenges awaiting an answer — see [`passkeys::Ceremonies`]. | |
| 73 | + | pub ceremonies: passkeys::Ceremonies, | |
| 70 | 74 | /// Server-wide secret keying CSRF tokens. Persisted in the data dir so | |
| 71 | 75 | /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap. | |
| 72 | 76 | csrf_secret: std::sync::Arc<[u8; 32]>, | |
| ⋯ 14 unchanged lines | |||
| 87 | 91 | db, | |
| 88 | 92 | ci_tx: None, | |
| 89 | 93 | vault: secrets::Vault::default(), | |
| 94 | + | ceremonies: passkeys::Ceremonies::default(), | |
| 90 | 95 | csrf_secret, | |
| 91 | 96 | }) | |
| 92 | 97 | } | |
| ⋯ 52 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+34 −0
| ⋯ 214 unchanged lines | |||
| 215 | 215 | pub created_at: i64, | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | + | /// A registered passkey (WebAuthn credential) used to sign in. | |
| 219 | + | /// | |
| 220 | + | /// Only public material is here: the credential id, its public key, and the | |
| 221 | + | /// counters the spec asks a relying party to track. The private key lives in | |
| 222 | + | /// the authenticator and is never transmitted, so this table is not a | |
| 223 | + | /// credential store in the way a password hash is — losing it costs users | |
| 224 | + | /// their registrations, not their secrets. See [`crate::passkeys`]. | |
| 225 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 226 | + | pub struct Passkey { | |
| 227 | + | #[key] | |
| 228 | + | #[auto] | |
| 229 | + | pub id: i64, | |
| 230 | + | #[index] | |
| 231 | + | pub user_id: i64, | |
| 232 | + | /// User-supplied label, e.g. "MacBook Touch ID". | |
| 233 | + | pub name: String, | |
| 234 | + | /// Base64url credential id, as the authenticator reports it. | |
| 235 | + | #[unique] | |
| 236 | + | pub credential_id: String, | |
| 237 | + | /// Base64 WebAuthn user handle: opaque, per account, shared by that | |
| 238 | + | /// account's passkeys. | |
| 239 | + | pub user_handle: String, | |
| 240 | + | /// Base64 of the credential's immutable state (its public key). | |
| 241 | + | pub static_state: String, | |
| 242 | + | /// Base64 of the mutable state (signature counter, backup and | |
| 243 | + | /// user-verification flags), rewritten after every sign-in. | |
| 244 | + | pub dynamic_state: String, | |
| 245 | + | /// Encoded transport hints (USB, NFC, internal, …) for re-prompting. | |
| 246 | + | pub transports: i64, | |
| 247 | + | pub created_at: i64, | |
| 248 | + | /// Unix time of the last successful sign-in, or 0 if never used. | |
| 249 | + | pub last_used_at: i64, | |
| 250 | + | } | |
| 251 | + | ||
| 218 | 252 | /// A per-repository secret, stored only as a sealed envelope. | |
| 219 | 253 | /// | |
| 220 | 254 | /// The server cannot read `envelope`: it is encrypted to the owner's | |
| ⋯ 34 unchanged lines | |||
addedcrates/anvil-core/src/passkeys.rs+342 −0
| 1 | + | //! Passkeys: WebAuthn sign-in, as an alternative to the account password. | |
| 2 | + | //! | |
| 3 | + | //! anvil is the relying party. A passkey's private half never leaves the | |
| 4 | + | //! authenticator (Touch ID, Windows Hello, a security key, a phone); all we | |
| 5 | + | //! store is the credential id and its public key, and all a login proves is a | |
| 6 | + | //! signature over a challenge we issued. Nothing here can be replayed against | |
| 7 | + | //! another site: the authenticator binds every signature to our RP id. | |
| 8 | + | //! | |
| 9 | + | //! The ceremony protocol runs in two round trips — *begin* hands the browser a | |
| 10 | + | //! challenge, *finish* verifies what the authenticator signed — so the server | |
| 11 | + | //! has to remember the challenge in between. [`Ceremonies`] holds those, in | |
| 12 | + | //! memory, briefly. Verification itself lives in `anvil-web`, next to the JSON. | |
| 13 | + | //! | |
| 14 | + | //! Only passkeys (discoverable, user-verifying credentials) are supported, so | |
| 15 | + | //! signing in needs no username: the authenticator tells us which credential it | |
| 16 | + | //! used, and that identifies the account. | |
| 17 | + | ||
| 18 | + | use webauthn_rp::{ | |
| 19 | + | DiscoverableAuthenticationServerState, | |
| 20 | + | RegistrationServerState, | |
| 21 | + | request::{ | |
| 22 | + | AsciiDomain, | |
| 23 | + | RpId, | |
| 24 | + | register::{ | |
| 25 | + | USER_HANDLE_MAX_LEN, | |
| 26 | + | UserHandle64, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | use crate::{ | |
| 32 | + | error::{ | |
| 33 | + | Error, | |
| 34 | + | Result, | |
| 35 | + | }, | |
| 36 | + | models::Passkey, | |
| 37 | + | }; | |
| 38 | + | ||
| 39 | + | /// Length of the WebAuthn user handle, in bytes. The crate's maximum, and an | |
| 40 | + | /// opaque random value — deliberately *not* the account id, since the handle is | |
| 41 | + | /// visible to the authenticator and syncs to the user's password manager. | |
| 42 | + | pub const USER_HANDLE_LEN: usize = USER_HANDLE_MAX_LEN; | |
| 43 | + | ||
| 44 | + | /// How long a browser has to complete a ceremony before its challenge is | |
| 45 | + | /// forgotten. Matches the five-minute timeout sent to the authenticator. | |
| 46 | + | const CEREMONY_TTL_SECS: i64 = 300; | |
| 47 | + | ||
| 48 | + | /// Cap on outstanding ceremonies, so an unauthenticated endpoint that mints | |
| 49 | + | /// challenges cannot grow the map without bound. | |
| 50 | + | const MAX_CEREMONIES: usize = 512; | |
| 51 | + | ||
| 52 | + | /// The relying-party id for this deployment: the base URL's host. | |
| 53 | + | /// | |
| 54 | + | /// WebAuthn scopes a credential to exactly this string, so it must be stable — | |
| 55 | + | /// change the host and existing passkeys stop working (they are not lost, they | |
| 56 | + | /// simply belong to a different site now). | |
| 57 | + | pub fn rp_id(base_url: &str) -> Result<RpId> { | |
| 58 | + | let host = base_url | |
| 59 | + | .split_once("://") | |
| 60 | + | .map_or(base_url, |(_, rest)| rest) | |
| 61 | + | .split('/') | |
| 62 | + | .next() | |
| 63 | + | .unwrap_or_default() | |
| 64 | + | .split(':') | |
| 65 | + | .next() | |
| 66 | + | .unwrap_or_default() | |
| 67 | + | .to_ascii_lowercase(); | |
| 68 | + | if host.is_empty() { | |
| 69 | + | return Err(Error::Config(format!( | |
| 70 | + | "cannot derive a WebAuthn relying-party id from base_url `{base_url}`" | |
| 71 | + | ))); | |
| 72 | + | } | |
| 73 | + | AsciiDomain::try_from(host.clone()) | |
| 74 | + | .map(RpId::Domain) | |
| 75 | + | .map_err(|_| { | |
| 76 | + | Error::Config(format!( | |
| 77 | + | "base_url host `{host}` is not a domain WebAuthn accepts" | |
| 78 | + | )) | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The exact origin browsers must report, i.e. scheme + host + any explicit | |
| 83 | + | /// port. Compared verbatim during verification, which is what stops a | |
| 84 | + | /// look-alike site from replaying a ceremony. | |
| 85 | + | pub fn origin(base_url: &str) -> String { | |
| 86 | + | base_url.trim_end_matches('/').to_string() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// A ceremony in flight, keyed by an opaque id the browser echoes back. | |
| 90 | + | pub enum Ceremony { | |
| 91 | + | /// Registering a new passkey for an already signed-in user. | |
| 92 | + | Register { | |
| 93 | + | state: Box<RegistrationServerState<USER_HANDLE_LEN>>, | |
| 94 | + | user_id: i64, | |
| 95 | + | }, | |
| 96 | + | /// Signing in with an existing passkey. No user is known yet — the | |
| 97 | + | /// authenticator's response is what identifies the account. | |
| 98 | + | Authenticate { | |
| 99 | + | state: Box<DiscoverableAuthenticationServerState>, | |
| 100 | + | }, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Challenges issued but not yet completed. | |
| 104 | + | /// | |
| 105 | + | /// In memory only, and deliberately so: a challenge is single-use and expires | |
| 106 | + | /// in minutes, so persisting it would buy nothing but a table to clean up. A | |
| 107 | + | /// restart invalidates ceremonies in flight, which costs a user one retry. | |
| 108 | + | #[derive(Clone, Default)] | |
| 109 | + | pub struct Ceremonies { | |
| 110 | + | inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, Pending>>>, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | struct Pending { | |
| 114 | + | ceremony: Ceremony, | |
| 115 | + | expires_at: i64, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | impl Ceremonies { | |
| 119 | + | /// Store `ceremony` and return the id the browser must send back. | |
| 120 | + | pub fn insert(&self, ceremony: Ceremony) -> String { | |
| 121 | + | let id = random_id(); | |
| 122 | + | let mut map = self.inner.lock().expect("ceremony mutex"); | |
| 123 | + | let now = crate::now(); | |
| 124 | + | map.retain(|_, pending| pending.expires_at > now); | |
| 125 | + | // Under flood, drop the oldest rather than refuse new sign-ins. | |
| 126 | + | while map.len() >= MAX_CEREMONIES { | |
| 127 | + | let oldest = map | |
| 128 | + | .iter() | |
| 129 | + | .min_by_key(|(_, pending)| pending.expires_at) | |
| 130 | + | .map(|(key, _)| key.clone()); | |
| 131 | + | match oldest { | |
| 132 | + | Some(key) => { | |
| 133 | + | map.remove(&key); | |
| 134 | + | } | |
| 135 | + | None => break, | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | map.insert( | |
| 139 | + | id.clone(), | |
| 140 | + | Pending { | |
| 141 | + | ceremony, | |
| 142 | + | expires_at: now + CEREMONY_TTL_SECS, | |
| 143 | + | }, | |
| 144 | + | ); | |
| 145 | + | id | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Consume a ceremony. Single-use: a challenge answered twice is answered | |
| 149 | + | /// once, which is what makes replaying a captured assertion useless. | |
| 150 | + | pub fn take(&self, id: &str) -> Option<Ceremony> { | |
| 151 | + | let mut map = self.inner.lock().expect("ceremony mutex"); | |
| 152 | + | let pending = map.remove(id)?; | |
| 153 | + | (pending.expires_at > crate::now()).then_some(pending.ceremony) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Drop expired entries (called from the periodic sweep). | |
| 157 | + | pub fn sweep(&self) { | |
| 158 | + | let now = crate::now(); | |
| 159 | + | self.inner | |
| 160 | + | .lock() | |
| 161 | + | .expect("ceremony mutex") | |
| 162 | + | .retain(|_, pending| pending.expires_at > now); | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn random_id() -> String { | |
| 167 | + | use argon2::password_hash::rand_core::{ | |
| 168 | + | OsRng, | |
| 169 | + | RngCore, | |
| 170 | + | }; | |
| 171 | + | let mut bytes = [0u8; 32]; | |
| 172 | + | OsRng.fill_bytes(&mut bytes); | |
| 173 | + | bytes.iter().map(|b| format!("{b:02x}")).collect() | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// Generate a fresh WebAuthn user handle. | |
| 177 | + | pub fn new_user_handle() -> UserHandle64 { | |
| 178 | + | UserHandle64::new() | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // --- persistence ----------------------------------------------------------- | |
| 182 | + | ||
| 183 | + | /// List a user's passkeys, newest first. | |
| 184 | + | pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<Passkey>> { | |
| 185 | + | let mut conn = db.clone(); | |
| 186 | + | let mut keys = Passkey::filter(Passkey::fields().user_id().eq(user_id)) | |
| 187 | + | .exec(&mut conn) | |
| 188 | + | .await?; | |
| 189 | + | keys.sort_by_key(|k| std::cmp::Reverse(k.created_at)); | |
| 190 | + | Ok(keys) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Look a credential up by its id (base64url), as presented at sign-in. | |
| 194 | + | pub async fn find_by_credential_id( | |
| 195 | + | db: &toasty::Db, | |
| 196 | + | credential_id: &str, | |
| 197 | + | ) -> Result<Option<Passkey>> { | |
| 198 | + | let mut conn = db.clone(); | |
| 199 | + | Ok( | |
| 200 | + | Passkey::filter(Passkey::fields().credential_id().eq(credential_id)) | |
| 201 | + | .first() | |
| 202 | + | .exec(&mut conn) | |
| 203 | + | .await?, | |
| 204 | + | ) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Record a newly registered passkey. | |
| 208 | + | #[allow(clippy::too_many_arguments)] | |
| 209 | + | pub async fn add( | |
| 210 | + | db: &toasty::Db, | |
| 211 | + | user_id: i64, | |
| 212 | + | name: &str, | |
| 213 | + | credential_id: &str, | |
| 214 | + | user_handle: &str, | |
| 215 | + | static_state: &str, | |
| 216 | + | dynamic_state: &str, | |
| 217 | + | transports: i64, | |
| 218 | + | ) -> Result<Passkey> { | |
| 219 | + | if find_by_credential_id(db, credential_id).await?.is_some() { | |
| 220 | + | return Err(Error::AlreadyExists("passkey".into())); | |
| 221 | + | } | |
| 222 | + | let now = crate::now(); | |
| 223 | + | let mut conn = db.clone(); | |
| 224 | + | Ok(toasty::create!(Passkey { | |
| 225 | + | user_id: user_id, | |
| 226 | + | name: display_name(name), | |
| 227 | + | credential_id: credential_id, | |
| 228 | + | user_handle: user_handle, | |
| 229 | + | static_state: static_state, | |
| 230 | + | dynamic_state: dynamic_state, | |
| 231 | + | transports: transports, | |
| 232 | + | created_at: now, | |
| 233 | + | last_used_at: 0, | |
| 234 | + | }) | |
| 235 | + | .exec(&mut conn) | |
| 236 | + | .await?) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Persist the credential's post-authentication state (the signature counter | |
| 240 | + | /// and flags) and stamp its last use. | |
| 241 | + | pub async fn record_use(db: &toasty::Db, passkey: Passkey, dynamic_state: &str) -> Result<()> { | |
| 242 | + | let mut conn = db.clone(); | |
| 243 | + | let mut passkey = passkey; | |
| 244 | + | passkey | |
| 245 | + | .update() | |
| 246 | + | .dynamic_state(dynamic_state) | |
| 247 | + | .last_used_at(crate::now()) | |
| 248 | + | .exec(&mut conn) | |
| 249 | + | .await?; | |
| 250 | + | Ok(()) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Delete one of `user_id`'s passkeys. No-op if it is missing or someone | |
| 254 | + | /// else's. | |
| 255 | + | pub async fn delete(db: &toasty::Db, id: i64, user_id: i64) -> Result<()> { | |
| 256 | + | let mut conn = db.clone(); | |
| 257 | + | if let Some(passkey) = Passkey::filter(Passkey::fields().id().eq(id)) | |
| 258 | + | .first() | |
| 259 | + | .exec(&mut conn) | |
| 260 | + | .await? | |
| 261 | + | && passkey.user_id == user_id | |
| 262 | + | { | |
| 263 | + | let mut conn = db.clone(); | |
| 264 | + | passkey.delete().exec(&mut conn).await?; | |
| 265 | + | } | |
| 266 | + | Ok(()) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// A user's stable WebAuthn handle, shared by all of their passkeys: reusing it | |
| 270 | + | /// lets an authenticator recognize a second registration as the same account | |
| 271 | + | /// rather than a second one. | |
| 272 | + | pub async fn handle_for_user(db: &toasty::Db, user_id: i64) -> Result<Option<String>> { | |
| 273 | + | Ok(list(db, user_id) | |
| 274 | + | .await? | |
| 275 | + | .into_iter() | |
| 276 | + | .next() | |
| 277 | + | .map(|k| k.user_handle)) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Trim and bound a user-supplied label, falling back to something useful. | |
| 281 | + | fn display_name(name: &str) -> String { | |
| 282 | + | let name = name.trim(); | |
| 283 | + | if name.is_empty() { | |
| 284 | + | "passkey".to_string() | |
| 285 | + | } else { | |
| 286 | + | name.chars().take(64).collect() | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[cfg(test)] | |
| 291 | + | mod tests { | |
| 292 | + | use super::*; | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn derives_the_rp_id_from_the_base_url() { | |
| 296 | + | let id = |url: &str| rp_id(url).map(|id| id.as_ref().to_string()); | |
| 297 | + | assert_eq!(id("https://anvil.localhost").unwrap(), "anvil.localhost"); | |
| 298 | + | assert_eq!(id("http://localhost:3000").unwrap(), "localhost"); | |
| 299 | + | assert_eq!( | |
| 300 | + | id("https://anvil.richardscollin.com/").unwrap(), | |
| 301 | + | "anvil.richardscollin.com" | |
| 302 | + | ); | |
| 303 | + | // Case is normalized: browsers report the host lowercased. | |
| 304 | + | assert_eq!(id("https://Anvil.LOCALHOST").unwrap(), "anvil.localhost"); | |
| 305 | + | assert!(id("").is_err()); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[test] | |
| 309 | + | fn the_origin_keeps_scheme_and_port() { | |
| 310 | + | assert_eq!(origin("http://localhost:3000/"), "http://localhost:3000"); | |
| 311 | + | assert_eq!(origin("https://anvil.localhost"), "https://anvil.localhost"); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[test] | |
| 315 | + | fn ceremonies_are_single_use_and_expire() { | |
| 316 | + | let ceremonies = Ceremonies::default(); | |
| 317 | + | let id = ceremonies.insert(Ceremony::Authenticate { | |
| 318 | + | state: Box::new(fake_auth_state()), | |
| 319 | + | }); | |
| 320 | + | assert!(ceremonies.take(&id).is_some()); | |
| 321 | + | assert!( | |
| 322 | + | ceremonies.take(&id).is_none(), | |
| 323 | + | "a challenge must not be answerable twice" | |
| 324 | + | ); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// A real ceremony state, built the way the server builds one. | |
| 328 | + | fn fake_auth_state() -> DiscoverableAuthenticationServerState { | |
| 329 | + | let rp = rp_id("https://anvil.localhost").unwrap(); | |
| 330 | + | webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp) | |
| 331 | + | .start_ceremony() | |
| 332 | + | .expect("default passkey options are valid") | |
| 333 | + | .0 | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn labels_are_trimmed_and_defaulted() { | |
| 338 | + | assert_eq!(display_name(" MacBook "), "MacBook"); | |
| 339 | + | assert_eq!(display_name(""), "passkey"); | |
| 340 | + | assert_eq!(display_name(&"x".repeat(100)).len(), 64); | |
| 341 | + | } | |
| 342 | + | } |
modifiedcrates/anvil-core/src/periodic.rs+15 −0
| ⋯ 181 unchanged lines | |||
| 182 | 182 | } | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | /// Job that drops WebAuthn challenges nobody answered. | |
| 186 | + | pub struct PasskeyCeremonySweepJob; | |
| 187 | + | ||
| 188 | + | #[async_trait::async_trait] | |
| 189 | + | impl PeriodicJob for PasskeyCeremonySweepJob { | |
| 190 | + | async fn run(&self, app: &App) -> Result<()> { | |
| 191 | + | app.ceremonies.sweep(); | |
| 192 | + | Ok(()) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn name(&self) -> &str { | |
| 196 | + | "passkey_ceremony_sweep" | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 185 | 200 | /// Try to extract the first image URL from a repository's README file. | |
| 186 | 201 | /// Scans the repository for a README file, reads it, and returns the first image URL found. | |
| 187 | 202 | async fn extract_readme_image(repo_path: &Path) -> Option<String> { | |
| ⋯ 21 unchanged lines | |||
modifiedcrates/anvil-web/Cargo.toml+1 −0
| ⋯ 9 unchanged lines | |||
| 10 | 10 | [dependencies] | |
| 11 | 11 | anvil-core.workspace = true | |
| 12 | 12 | anvil-git.workspace = true | |
| 13 | + | webauthn_rp.workspace = true | |
| 13 | 14 | axum.workspace = true | |
| 14 | 15 | axum-extra.workspace = true | |
| 15 | 16 | tokio.workspace = true | |
| ⋯ 18 unchanged lines | |||
modifiedcrates/anvil-web/src/auth.rs+20 −10
| ⋯ 40 unchanged lines | |||
| 41 | 41 | ||
| 42 | 42 | use crate::ui::layout; | |
| 43 | 43 | ||
| 44 | - | const SESSION_COOKIE: &str = "anvil_session"; | |
| 44 | + | pub(crate) const SESSION_COOKIE: &str = "anvil_session"; | |
| 45 | 45 | ||
| 46 | 46 | /// Hidden form field (and header) name carrying the CSRF token. | |
| 47 | 47 | pub const CSRF_FIELD: &str = "csrf"; | |
| ⋯ 156 unchanged lines | |||
| 204 | 204 | }; | |
| 205 | 205 | ||
| 206 | 206 | match sessions::create(&app.db, user.id).await { | |
| 207 | - | Ok(session) => { | |
| 208 | - | let cookie = Cookie::build((SESSION_COOKIE, session.token)) | |
| 209 | - | .path("/") | |
| 210 | - | .http_only(true) | |
| 211 | - | .secure(app.config.secure_cookies()) | |
| 212 | - | .same_site(SameSite::Lax) | |
| 213 | - | .build(); | |
| 214 | - | (jar.add(cookie), Redirect::to("/")).into_response() | |
| 215 | - | } | |
| 207 | + | Ok(session) => ( | |
| 208 | + | jar.add(session_cookie(&app, session.token)), | |
| 209 | + | Redirect::to("/"), | |
| 210 | + | ) | |
| 211 | + | .into_response(), | |
| 216 | 212 | Err(e) => { | |
| 217 | 213 | tracing::error!("session create failed: {e}"); | |
| 218 | 214 | ( | |
| ⋯ 31 unchanged lines | |||
| 250 | 246 | p { label { "Password" br; input name="password" type="password"; } } | |
| 251 | 247 | button type="submit" { "Sign in" } | |
| 252 | 248 | } | |
| 249 | + | (crate::passkeys::shared_script()) | |
| 250 | + | (crate::passkeys::login_button()) | |
| 253 | 251 | }, | |
| 254 | 252 | ) | |
| 255 | 253 | } | |
| 256 | 254 | ||
| 255 | + | /// The session cookie for a freshly created session. `Secure` follows the | |
| 256 | + | /// deployment's scheme (see [`anvil_core::Config::secure_cookies`]), and | |
| 257 | + | /// `SameSite=Lax` is what lets the CSRF token be the only other defence needed. | |
| 258 | + | pub(crate) fn session_cookie(app: &App, token: String) -> Cookie<'static> { | |
| 259 | + | Cookie::build((SESSION_COOKIE, token)) | |
| 260 | + | .path("/") | |
| 261 | + | .http_only(true) | |
| 262 | + | .secure(app.config.secure_cookies()) | |
| 263 | + | .same_site(SameSite::Lax) | |
| 264 | + | .build() | |
| 265 | + | } | |
| 266 | + | ||
| 257 | 267 | /// Verify HTTP Basic credentials from the `Authorization` header against a user. | |
| 258 | 268 | /// Returns the authenticated user, or `None` if absent/invalid. | |
| 259 | 269 | pub async fn basic_auth_user(app: &App, authorization: Option<&str>) -> Option<User> { | |
| ⋯ 14 unchanged lines | |||
modifiedcrates/anvil-web/src/lib.rs+2 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | pub mod auth; | |
| 27 | 27 | pub mod git_http; | |
| 28 | 28 | pub mod pages; | |
| 29 | + | pub mod passkeys; | |
| 29 | 30 | pub mod secrets; | |
| 30 | 31 | pub mod todomd; | |
| 31 | 32 | pub mod ui; | |
| ⋯ 12 unchanged lines | |||
| 44 | 45 | router, | |
| 45 | 46 | app.config.http.attachment_max_mb.saturating_mul(1 << 20), | |
| 46 | 47 | ); // uploaded image attachments | |
| 48 | + | router = passkeys::routes(router); // WebAuthn sign-in | |
| 47 | 49 | router = secrets::routes(router); // sealed per-repo secrets + unlock API | |
| 48 | 50 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 49 | 51 | router | |
| ⋯ 23 unchanged lines | |||
addedcrates/anvil-web/src/passkeys.rs+719 −0
| 1 | + | //! Passkey sign-in: the two WebAuthn ceremonies, plus the browser glue. | |
| 2 | + | //! | |
| 3 | + | //! Registration (signed in) and authentication (signed out) each run as | |
| 4 | + | //! *begin* → *finish*. Begin mints a challenge, stashes the server half in | |
| 5 | + | //! [`anvil_core::passkeys::Ceremonies`], and returns the client half as JSON. | |
| 6 | + | //! Finish takes what `navigator.credentials` produced, verifies it against the | |
| 7 | + | //! stashed challenge, and either stores a credential or starts a session. | |
| 8 | + | //! | |
| 9 | + | //! Sign-in is usernameless: passkeys are discoverable credentials, so the | |
| 10 | + | //! authenticator hands back the credential id it used and we look the account | |
| 11 | + | //! up from that. | |
| 12 | + | ||
| 13 | + | use anvil_core::{ | |
| 14 | + | App, | |
| 15 | + | User, | |
| 16 | + | passkeys::{ | |
| 17 | + | self, | |
| 18 | + | Ceremony, | |
| 19 | + | }, | |
| 20 | + | sessions, | |
| 21 | + | users, | |
| 22 | + | }; | |
| 23 | + | use axum::{ | |
| 24 | + | Json, | |
| 25 | + | Router, | |
| 26 | + | extract::{ | |
| 27 | + | Path, | |
| 28 | + | State, | |
| 29 | + | }, | |
| 30 | + | http::{ | |
| 31 | + | HeaderMap, | |
| 32 | + | StatusCode, | |
| 33 | + | }, | |
| 34 | + | response::{ | |
| 35 | + | IntoResponse, | |
| 36 | + | Redirect, | |
| 37 | + | Response, | |
| 38 | + | }, | |
| 39 | + | routing::post, | |
| 40 | + | }; | |
| 41 | + | use base64::Engine; | |
| 42 | + | use maud::{ | |
| 43 | + | Markup, | |
| 44 | + | PreEscaped, | |
| 45 | + | html, | |
| 46 | + | }; | |
| 47 | + | use serde::{ | |
| 48 | + | Deserialize, | |
| 49 | + | Serialize, | |
| 50 | + | }; | |
| 51 | + | use webauthn_rp::{ | |
| 52 | + | AuthenticatedCredential, | |
| 53 | + | DiscoverableAuthentication64, | |
| 54 | + | DiscoverableCredentialRequestOptions, | |
| 55 | + | PublicKeyCredentialCreationOptions, | |
| 56 | + | Registration, | |
| 57 | + | bin::{ | |
| 58 | + | Decode, | |
| 59 | + | Encode, | |
| 60 | + | }, | |
| 61 | + | request::{ | |
| 62 | + | PublicKeyCredentialDescriptor, | |
| 63 | + | auth::AuthenticationVerificationOptions, | |
| 64 | + | register::{ | |
| 65 | + | Nickname, | |
| 66 | + | PublicKeyCredentialUserEntity, | |
| 67 | + | RegistrationVerificationOptions, | |
| 68 | + | UserHandle64, | |
| 69 | + | Username, | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | response::{ | |
| 73 | + | AuthTransports, | |
| 74 | + | CredentialId, | |
| 75 | + | register::{ | |
| 76 | + | CompressedPubKey, | |
| 77 | + | DynamicState, | |
| 78 | + | StaticState, | |
| 79 | + | }, | |
| 80 | + | }, | |
| 81 | + | }; | |
| 82 | + | ||
| 83 | + | use crate::{ | |
| 84 | + | auth::{ | |
| 85 | + | Csrf, | |
| 86 | + | CurrentUser, | |
| 87 | + | verify_csrf, | |
| 88 | + | }, | |
| 89 | + | ui::{ | |
| 90 | + | csrf_input, | |
| 91 | + | fmt_relative, | |
| 92 | + | }, | |
| 93 | + | }; | |
| 94 | + | ||
| 95 | + | /// The stored public key, in the shape `webauthn_rp` decodes into. | |
| 96 | + | type StoredKey = CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>; | |
| 97 | + | ||
| 98 | + | pub fn routes(router: Router<App>) -> Router<App> { | |
| 99 | + | router | |
| 100 | + | .route("/-/settings/passkeys/begin", post(register_begin)) | |
| 101 | + | .route("/-/settings/passkeys/finish", post(register_finish)) | |
| 102 | + | .route("/-/settings/passkeys/{id}/delete", post(delete_passkey)) | |
| 103 | + | .route("/-/login/passkey/begin", post(login_begin)) | |
| 104 | + | .route("/-/login/passkey/finish", post(login_finish)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // --- registration ---------------------------------------------------------- | |
| 108 | + | ||
| 109 | + | #[derive(Serialize)] | |
| 110 | + | struct BeginResponse { | |
| 111 | + | ceremony: String, | |
| 112 | + | options: serde_json::Value, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// `POST /-/settings/passkeys/begin` — issue a registration challenge. | |
| 116 | + | async fn register_begin( | |
| 117 | + | State(app): State<App>, | |
| 118 | + | CurrentUser(user): CurrentUser, | |
| 119 | + | csrf: Csrf, | |
| 120 | + | headers: HeaderMap, | |
| 121 | + | ) -> Response { | |
| 122 | + | let Some(user) = user else { | |
| 123 | + | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 124 | + | }; | |
| 125 | + | if let Err(resp) = check_csrf(&csrf, &headers) { | |
| 126 | + | return resp; | |
| 127 | + | } | |
| 128 | + | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 129 | + | Ok(rp) => rp, | |
| 130 | + | Err(e) => return server_error(e), | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | // Reuse this account's existing handle so the authenticator files a second | |
| 134 | + | // passkey under the same user rather than inventing a parallel identity. | |
| 135 | + | let existing = passkeys::list(&app.db, user.id).await.unwrap_or_default(); | |
| 136 | + | let handle = match existing.first() { | |
| 137 | + | Some(key) => match decode_handle(&key.user_handle) { | |
| 138 | + | Some(handle) => handle, | |
| 139 | + | None => return server_error("stored passkey handle is malformed"), | |
| 140 | + | }, | |
| 141 | + | None => passkeys::new_user_handle(), | |
| 142 | + | }; | |
| 143 | + | ||
| 144 | + | // Excluding what is already registered is what makes a second attempt on | |
| 145 | + | // the same authenticator say "already registered" instead of silently | |
| 146 | + | // creating a duplicate. | |
| 147 | + | let exclude = existing | |
| 148 | + | .iter() | |
| 149 | + | .filter_map(|key| { | |
| 150 | + | let id = b64url().decode(&key.credential_id).ok()?; | |
| 151 | + | Some(PublicKeyCredentialDescriptor { | |
| 152 | + | id: CredentialId::decode(id).ok()?, | |
| 153 | + | transports: decode_transports(key.transports), | |
| 154 | + | }) | |
| 155 | + | }) | |
| 156 | + | .collect(); | |
| 157 | + | ||
| 158 | + | let username = match Username::try_from(user.username.as_str()) { | |
| 159 | + | Ok(name) => name, | |
| 160 | + | Err(_) => return server_error("username is not usable as a WebAuthn name"), | |
| 161 | + | }; | |
| 162 | + | let display_name = Nickname::try_from(user.username.as_str()).ok(); | |
| 163 | + | let entity = PublicKeyCredentialUserEntity { | |
| 164 | + | name: username, | |
| 165 | + | id: &handle, | |
| 166 | + | display_name, | |
| 167 | + | }; | |
| 168 | + | ||
| 169 | + | let (server_state, client_state) = | |
| 170 | + | match PublicKeyCredentialCreationOptions::passkey(&rp, entity, exclude).start_ceremony() { | |
| 171 | + | Ok(pair) => pair, | |
| 172 | + | Err(e) => return server_error(format!("building registration options: {e}")), | |
| 173 | + | }; | |
| 174 | + | let options = match serde_json::to_value(&client_state) { | |
| 175 | + | Ok(value) => value, | |
| 176 | + | Err(e) => return server_error(e), | |
| 177 | + | }; | |
| 178 | + | let ceremony = app.ceremonies.insert(Ceremony::Register { | |
| 179 | + | state: Box::new(server_state), | |
| 180 | + | user_id: user.id, | |
| 181 | + | }); | |
| 182 | + | // The handle travels with the ceremony via the credential we are about to | |
| 183 | + | // store; keep it here so finish() writes the same bytes the browser saw. | |
| 184 | + | let handle_b64 = base64::engine::general_purpose::STANDARD.encode(handle.as_ref()); | |
| 185 | + | Json(serde_json::json!({ | |
| 186 | + | "ceremony": ceremony, | |
| 187 | + | "options": options, | |
| 188 | + | "handle": handle_b64, | |
| 189 | + | })) | |
| 190 | + | .into_response() | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[derive(Deserialize)] | |
| 194 | + | struct RegisterFinish { | |
| 195 | + | ceremony: String, | |
| 196 | + | #[serde(default)] | |
| 197 | + | name: String, | |
| 198 | + | handle: String, | |
| 199 | + | credential: serde_json::Value, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// `POST /-/settings/passkeys/finish` — verify and store the new credential. | |
| 203 | + | async fn register_finish( | |
| 204 | + | State(app): State<App>, | |
| 205 | + | CurrentUser(user): CurrentUser, | |
| 206 | + | csrf: Csrf, | |
| 207 | + | headers: HeaderMap, | |
| 208 | + | Json(body): Json<RegisterFinish>, | |
| 209 | + | ) -> Response { | |
| 210 | + | let Some(user) = user else { | |
| 211 | + | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 212 | + | }; | |
| 213 | + | if let Err(resp) = check_csrf(&csrf, &headers) { | |
| 214 | + | return resp; | |
| 215 | + | } | |
| 216 | + | let Some(Ceremony::Register { state, user_id }) = app.ceremonies.take(&body.ceremony) else { | |
| 217 | + | return bad_request("that registration expired — try again"); | |
| 218 | + | }; | |
| 219 | + | if user_id != user.id { | |
| 220 | + | return bad_request("that registration belongs to another session"); | |
| 221 | + | } | |
| 222 | + | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 223 | + | Ok(rp) => rp, | |
| 224 | + | Err(e) => return server_error(e), | |
| 225 | + | }; | |
| 226 | + | let registration: Registration = match serde_json::from_value(body.credential) { | |
| 227 | + | Ok(reg) => reg, | |
| 228 | + | Err(e) => return bad_request(format!("malformed credential: {e}")), | |
| 229 | + | }; | |
| 230 | + | ||
| 231 | + | let origin = passkeys::origin(&app.config.http.base_url); | |
| 232 | + | let options = RegistrationVerificationOptions::<&str, &str> { | |
| 233 | + | allowed_origins: &[origin.as_str()], | |
| 234 | + | ..Default::default() | |
| 235 | + | }; | |
| 236 | + | let credential = match state.verify(&rp, ®istration, &options) { | |
| 237 | + | Ok(credential) => credential, | |
| 238 | + | Err(e) => { | |
| 239 | + | tracing::warn!("passkey registration rejected: {e}"); | |
| 240 | + | return bad_request(format!("passkey rejected: {e}")); | |
| 241 | + | } | |
| 242 | + | }; | |
| 243 | + | ||
| 244 | + | let (id, transports, _handle, static_state, dynamic_state, _metadata) = credential.into_parts(); | |
| 245 | + | let credential_id = b64url().encode(id.as_ref()); | |
| 246 | + | let static_encoded = match static_state.encode() { | |
| 247 | + | Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), | |
| 248 | + | Err(_) => return server_error("encoding credential public key"), | |
| 249 | + | }; | |
| 250 | + | let dynamic_encoded = match dynamic_state.encode() { | |
| 251 | + | Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), | |
| 252 | + | Err(_) => return server_error("encoding credential state"), | |
| 253 | + | }; | |
| 254 | + | let transports = encode_transports(transports); | |
| 255 | + | ||
| 256 | + | match passkeys::add( | |
| 257 | + | &app.db, | |
| 258 | + | user.id, | |
| 259 | + | &body.name, | |
| 260 | + | &credential_id, | |
| 261 | + | &body.handle, | |
| 262 | + | &static_encoded, | |
| 263 | + | &dynamic_encoded, | |
| 264 | + | transports, | |
| 265 | + | ) | |
| 266 | + | .await | |
| 267 | + | { | |
| 268 | + | Ok(_) => { | |
| 269 | + | tracing::info!("passkey registered for {}", user.username); | |
| 270 | + | StatusCode::NO_CONTENT.into_response() | |
| 271 | + | } | |
| 272 | + | Err(anvil_core::Error::AlreadyExists(_)) => { | |
| 273 | + | bad_request("that passkey is already registered") | |
| 274 | + | } | |
| 275 | + | Err(e) => server_error(e), | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// `POST /-/settings/passkeys/{id}/delete` — remove one of your passkeys. | |
| 280 | + | async fn delete_passkey( | |
| 281 | + | State(app): State<App>, | |
| 282 | + | CurrentUser(user): CurrentUser, | |
| 283 | + | csrf: Csrf, | |
| 284 | + | Path(id): Path<i64>, | |
| 285 | + | axum::Form(form): axum::Form<crate::auth::CsrfForm>, | |
| 286 | + | ) -> Response { | |
| 287 | + | let Some(user) = user else { | |
| 288 | + | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 289 | + | }; | |
| 290 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 291 | + | return resp; | |
| 292 | + | } | |
| 293 | + | if let Err(e) = passkeys::delete(&app.db, id, user.id).await { | |
| 294 | + | return server_error(e); | |
| 295 | + | } | |
| 296 | + | Redirect::to("/-/settings").into_response() | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // --- sign-in --------------------------------------------------------------- | |
| 300 | + | ||
| 301 | + | /// `POST /-/login/passkey/begin` — issue an authentication challenge. | |
| 302 | + | /// | |
| 303 | + | /// Deliberately open to anyone: it reveals nothing (the challenge is random and | |
| 304 | + | /// no account is named), and requiring a session first would defeat the point. | |
| 305 | + | async fn login_begin(State(app): State<App>) -> Response { | |
| 306 | + | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 307 | + | Ok(rp) => rp, | |
| 308 | + | Err(e) => return server_error(e), | |
| 309 | + | }; | |
| 310 | + | let (server_state, client_state) = | |
| 311 | + | match DiscoverableCredentialRequestOptions::passkey(&rp).start_ceremony() { | |
| 312 | + | Ok(pair) => pair, | |
| 313 | + | Err(e) => return server_error(format!("building authentication options: {e}")), | |
| 314 | + | }; | |
| 315 | + | let options = match serde_json::to_value(&client_state) { | |
| 316 | + | Ok(value) => value, | |
| 317 | + | Err(e) => return server_error(e), | |
| 318 | + | }; | |
| 319 | + | let ceremony = app.ceremonies.insert(Ceremony::Authenticate { | |
| 320 | + | state: Box::new(server_state), | |
| 321 | + | }); | |
| 322 | + | Json(BeginResponse { ceremony, options }).into_response() | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[derive(Deserialize)] | |
| 326 | + | struct LoginFinish { | |
| 327 | + | ceremony: String, | |
| 328 | + | credential: serde_json::Value, | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// `POST /-/login/passkey/finish` — verify an assertion and start a session. | |
| 332 | + | async fn login_finish( | |
| 333 | + | State(app): State<App>, | |
| 334 | + | jar: axum_extra::extract::CookieJar, | |
| 335 | + | Json(body): Json<LoginFinish>, | |
| 336 | + | ) -> Response { | |
| 337 | + | let Some(Ceremony::Authenticate { state }) = app.ceremonies.take(&body.ceremony) else { | |
| 338 | + | return bad_request("that sign-in expired — try again"); | |
| 339 | + | }; | |
| 340 | + | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 341 | + | Ok(rp) => rp, | |
| 342 | + | Err(e) => return server_error(e), | |
| 343 | + | }; | |
| 344 | + | let authentication: DiscoverableAuthentication64 = match serde_json::from_value(body.credential) | |
| 345 | + | { | |
| 346 | + | Ok(auth) => auth, | |
| 347 | + | Err(e) => return bad_request(format!("malformed assertion: {e}")), | |
| 348 | + | }; | |
| 349 | + | ||
| 350 | + | let credential_id = b64url().encode(authentication.raw_id().as_ref()); | |
| 351 | + | let stored = match passkeys::find_by_credential_id(&app.db, &credential_id).await { | |
| 352 | + | Ok(Some(stored)) => stored, | |
| 353 | + | Ok(None) => return unauthorized(), | |
| 354 | + | Err(e) => return server_error(e), | |
| 355 | + | }; | |
| 356 | + | let Some(handle) = decode_handle(&stored.user_handle) else { | |
| 357 | + | return server_error("stored passkey handle is malformed"); | |
| 358 | + | }; | |
| 359 | + | let (Some(static_state), Some(dynamic_state)) = ( | |
| 360 | + | decode_static_state(&stored.static_state), | |
| 361 | + | decode_dynamic_state(&stored.dynamic_state), | |
| 362 | + | ) else { | |
| 363 | + | return server_error("stored passkey state is malformed"); | |
| 364 | + | }; | |
| 365 | + | ||
| 366 | + | let raw_id = authentication.raw_id().as_ref().to_vec(); | |
| 367 | + | let credential_ref = match CredentialId::decode(raw_id.as_slice()) { | |
| 368 | + | Ok(id) => id, | |
| 369 | + | Err(_) => return unauthorized(), | |
| 370 | + | }; | |
| 371 | + | let mut credential = | |
| 372 | + | match AuthenticatedCredential::new(credential_ref, &handle, static_state, dynamic_state) { | |
| 373 | + | Ok(credential) => credential, | |
| 374 | + | Err(e) => return server_error(format!("rebuilding credential: {e}")), | |
| 375 | + | }; | |
| 376 | + | ||
| 377 | + | let origin = passkeys::origin(&app.config.http.base_url); | |
| 378 | + | let options = AuthenticationVerificationOptions::<&str, &str> { | |
| 379 | + | allowed_origins: &[origin.as_str()], | |
| 380 | + | ..Default::default() | |
| 381 | + | }; | |
| 382 | + | match state.verify(&rp, &authentication, &mut credential, &options) { | |
| 383 | + | Ok(_updated) => {} | |
| 384 | + | Err(e) => { | |
| 385 | + | tracing::warn!("passkey sign-in rejected: {e}"); | |
| 386 | + | return unauthorized(); | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | let Ok(user) = users::find_by_id(&app.db, stored.user_id).await else { | |
| 391 | + | return server_error("looking up the passkey's account"); | |
| 392 | + | }; | |
| 393 | + | let Some(user) = user else { | |
| 394 | + | return unauthorized(); | |
| 395 | + | }; | |
| 396 | + | ||
| 397 | + | // Persist the counter/flags the authenticator just reported, so a cloned | |
| 398 | + | // credential replaying an older count is caught next time. | |
| 399 | + | let Ok(bytes) = credential.dynamic_state().encode(); | |
| 400 | + | let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); | |
| 401 | + | if let Err(e) = passkeys::record_use(&app.db, stored, &encoded).await { | |
| 402 | + | tracing::warn!("recording passkey use: {e}"); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | let session = match sessions::create(&app.db, user.id).await { | |
| 406 | + | Ok(session) => session, | |
| 407 | + | Err(e) => return server_error(e), | |
| 408 | + | }; | |
| 409 | + | tracing::info!("passkey sign-in for {}", user.username); | |
| 410 | + | let jar = jar.add(crate::auth::session_cookie(&app, session.token)); | |
| 411 | + | (jar, Json(serde_json::json!({ "redirect": "/" }))).into_response() | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // --- settings UI ----------------------------------------------------------- | |
| 415 | + | ||
| 416 | + | /// The passkeys section of account settings. | |
| 417 | + | pub fn settings_section(user: &User, keys: &[anvil_core::Passkey], csrf: &str) -> Markup { | |
| 418 | + | html! { | |
| 419 | + | h2 style="margin-top:28px" { "Passkeys" } | |
| 420 | + | p.muted style="font-size:13px" { | |
| 421 | + | "Sign in with Touch ID, Windows Hello, a phone, or a security key instead of " | |
| 422 | + | (user.username) "'s password. The key itself never leaves the device — anvil only " | |
| 423 | + | "stores its public half, and a passkey created here cannot be used on any other site." | |
| 424 | + | } | |
| 425 | + | @if keys.is_empty() { | |
| 426 | + | p.muted { "No passkeys yet." } | |
| 427 | + | } @else { | |
| 428 | + | div.box { | |
| 429 | + | @for key in keys { | |
| 430 | + | div.row { | |
| 431 | + | span { (key.name) } | |
| 432 | + | span.muted style="margin-left:auto;font-size:13px" { | |
| 433 | + | @if key.last_used_at == 0 { | |
| 434 | + | "never used" | |
| 435 | + | } @else { | |
| 436 | + | "last used " (fmt_relative(key.last_used_at)) | |
| 437 | + | } | |
| 438 | + | " · added " (fmt_relative(key.created_at)) | |
| 439 | + | } | |
| 440 | + | form method="post" style="margin-left:12px" | |
| 441 | + | action=(format!("/-/settings/passkeys/{}/delete", key.id)) { | |
| 442 | + | (csrf_input(csrf)) | |
| 443 | + | button.btn.btn-secondary type="submit" { "Remove" } | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | div #passkey-add.stack data-csrf=(csrf) style="margin-top:16px" { | |
| 450 | + | p { | |
| 451 | + | label { "Name this device" br; input #passkey-name type="text" placeholder="MacBook Touch ID" autocomplete="off"; } | |
| 452 | + | } | |
| 453 | + | p { | |
| 454 | + | button.btn #passkey-register type="button" { "Add passkey" } | |
| 455 | + | span #passkey-status.muted style="margin-left:10px;font-size:13px" {} | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | script { (PreEscaped(REGISTER_JS)) } | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | /// The "sign in with a passkey" control for the login page. | |
| 463 | + | pub fn login_button() -> Markup { | |
| 464 | + | html! { | |
| 465 | + | div #passkey-login style="margin-top:16px" { | |
| 466 | + | button.btn.btn-secondary #passkey-login-btn type="button" { "Sign in with a passkey" } | |
| 467 | + | span #passkey-login-status.muted style="margin-left:10px;font-size:13px" {} | |
| 468 | + | } | |
| 469 | + | script { (PreEscaped(LOGIN_JS)) } | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // --- helpers --------------------------------------------------------------- | |
| 474 | + | ||
| 475 | + | fn b64url() -> base64::engine::general_purpose::GeneralPurpose { | |
| 476 | + | base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | fn check_csrf(csrf: &Csrf, headers: &HeaderMap) -> Result<(), Response> { | |
| 480 | + | let submitted = headers | |
| 481 | + | .get("x-csrf-token") | |
| 482 | + | .and_then(|v| v.to_str().ok()) | |
| 483 | + | .unwrap_or_default(); | |
| 484 | + | verify_csrf(csrf, submitted) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | fn decode_handle(encoded: &str) -> Option<UserHandle64> { | |
| 488 | + | let bytes = base64::engine::general_purpose::STANDARD | |
| 489 | + | .decode(encoded) | |
| 490 | + | .ok()?; | |
| 491 | + | let bytes: [u8; passkeys::USER_HANDLE_LEN] = bytes.try_into().ok()?; | |
| 492 | + | UserHandle64::decode(bytes).ok() | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | fn decode_static_state(encoded: &str) -> Option<StaticState<StoredKey>> { | |
| 496 | + | let bytes = base64::engine::general_purpose::STANDARD | |
| 497 | + | .decode(encoded) | |
| 498 | + | .ok()?; | |
| 499 | + | StaticState::decode(bytes.as_slice()).ok() | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | fn decode_dynamic_state(encoded: &str) -> Option<DynamicState> { | |
| 503 | + | let bytes = base64::engine::general_purpose::STANDARD | |
| 504 | + | .decode(encoded) | |
| 505 | + | .ok()?; | |
| 506 | + | let bytes: [u8; 7] = bytes.try_into().ok()?; | |
| 507 | + | DynamicState::decode(bytes).ok() | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | /// Transports are stored as the crate's own compact encoding, widened to the | |
| 511 | + | /// integer column SQLite gives us. | |
| 512 | + | fn encode_transports(transports: AuthTransports) -> i64 { | |
| 513 | + | transports.encode().map(i64::from).unwrap_or_default() | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | fn decode_transports(stored: i64) -> AuthTransports { | |
| 517 | + | // An unreadable value costs a transport *hint*, nothing more: the browser | |
| 518 | + | // falls back to asking about every transport it supports. | |
| 519 | + | u8::try_from(stored) | |
| 520 | + | .ok() | |
| 521 | + | .and_then(|byte| AuthTransports::decode(byte).ok()) | |
| 522 | + | .unwrap_or_else(|| { | |
| 523 | + | AuthTransports::decode(0).unwrap_or_else(|_| unreachable!("0 is a valid transport set")) | |
| 524 | + | }) | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | fn unauthorized() -> Response { | |
| 528 | + | // Deliberately uniform: never distinguish "no such credential" from "bad | |
| 529 | + | // signature", or the endpoint becomes a credential-enumeration oracle. | |
| 530 | + | ( | |
| 531 | + | StatusCode::UNAUTHORIZED, | |
| 532 | + | "that passkey is not registered here", | |
| 533 | + | ) | |
| 534 | + | .into_response() | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | fn server_error(e: impl std::fmt::Display) -> Response { | |
| 538 | + | tracing::error!("passkeys: {e}"); | |
| 539 | + | (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | fn bad_request(e: impl std::fmt::Display) -> Response { | |
| 543 | + | (StatusCode::BAD_REQUEST, e.to_string()).into_response() | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | /// Shared browser helpers: WebAuthn speaks ArrayBuffers, JSON speaks base64url. | |
| 547 | + | /// | |
| 548 | + | /// `PublicKeyCredential.parseCreationOptionsFromJSON`/`toJSON` would do this, | |
| 549 | + | /// but they are recent enough that a hand-rolled conversion is the difference | |
| 550 | + | /// between working everywhere and working on new Chrome. | |
| 551 | + | const WEBAUTHN_JS: &str = r#" | |
| 552 | + | globalThis.anvilWebAuthn = (function () { | |
| 553 | + | function decode(value) { | |
| 554 | + | var pad = value.replace(/-/g, '+').replace(/_/g, '/'); | |
| 555 | + | var bin = atob(pad + '='.repeat((4 - pad.length % 4) % 4)); | |
| 556 | + | var out = new Uint8Array(bin.length); | |
| 557 | + | for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 558 | + | return out; | |
| 559 | + | } | |
| 560 | + | function encode(buffer) { | |
| 561 | + | var bytes = new Uint8Array(buffer), s = ''; | |
| 562 | + | for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); | |
| 563 | + | return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | |
| 564 | + | } | |
| 565 | + | return { | |
| 566 | + | decode: decode, | |
| 567 | + | encode: encode, | |
| 568 | + | // The server sends the same JSON shape browsers standardized on; the | |
| 569 | + | // binary fields just have to become buffers again. | |
| 570 | + | toCreationOptions: function (options) { | |
| 571 | + | options.challenge = decode(options.challenge); | |
| 572 | + | options.user.id = decode(options.user.id); | |
| 573 | + | (options.excludeCredentials || []).forEach(function (c) { c.id = decode(c.id); }); | |
| 574 | + | return options; | |
| 575 | + | }, | |
| 576 | + | toRequestOptions: function (options) { | |
| 577 | + | options.challenge = decode(options.challenge); | |
| 578 | + | (options.allowCredentials || []).forEach(function (c) { c.id = decode(c.id); }); | |
| 579 | + | return options; | |
| 580 | + | }, | |
| 581 | + | registrationJson: function (credential) { | |
| 582 | + | return { | |
| 583 | + | id: credential.id, | |
| 584 | + | rawId: encode(credential.rawId), | |
| 585 | + | type: credential.type, | |
| 586 | + | clientExtensionResults: credential.getClientExtensionResults(), | |
| 587 | + | response: { | |
| 588 | + | clientDataJSON: encode(credential.response.clientDataJSON), | |
| 589 | + | attestationObject: encode(credential.response.attestationObject), | |
| 590 | + | transports: credential.response.getTransports ? credential.response.getTransports() : [], | |
| 591 | + | }, | |
| 592 | + | }; | |
| 593 | + | }, | |
| 594 | + | assertionJson: function (credential) { | |
| 595 | + | return { | |
| 596 | + | id: credential.id, | |
| 597 | + | rawId: encode(credential.rawId), | |
| 598 | + | type: credential.type, | |
| 599 | + | clientExtensionResults: credential.getClientExtensionResults(), | |
| 600 | + | response: { | |
| 601 | + | clientDataJSON: encode(credential.response.clientDataJSON), | |
| 602 | + | authenticatorData: encode(credential.response.authenticatorData), | |
| 603 | + | signature: encode(credential.response.signature), | |
| 604 | + | userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null, | |
| 605 | + | }, | |
| 606 | + | }; | |
| 607 | + | }, | |
| 608 | + | }; | |
| 609 | + | })(); | |
| 610 | + | "#; | |
| 611 | + | ||
| 612 | + | /// Registration, driven from account settings. | |
| 613 | + | const REGISTER_JS: &str = r#" | |
| 614 | + | (function () { | |
| 615 | + | var root = document.getElementById('passkey-add'); | |
| 616 | + | if (!root) return; | |
| 617 | + | var button = document.getElementById('passkey-register'); | |
| 618 | + | var statusEl = document.getElementById('passkey-status'); | |
| 619 | + | var nameEl = document.getElementById('passkey-name'); | |
| 620 | + | ||
| 621 | + | function fail(message) { | |
| 622 | + | statusEl.textContent = message; | |
| 623 | + | statusEl.style.color = '#cf222e'; | |
| 624 | + | button.disabled = false; | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | if (!window.PublicKeyCredential) { | |
| 628 | + | button.disabled = true; | |
| 629 | + | statusEl.textContent = 'This browser does not support passkeys.'; | |
| 630 | + | return; | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | button.addEventListener('click', async function () { | |
| 634 | + | button.disabled = true; | |
| 635 | + | statusEl.style.color = ''; | |
| 636 | + | statusEl.textContent = 'Waiting for your authenticator…'; | |
| 637 | + | var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }; | |
| 638 | + | try { | |
| 639 | + | var res = await fetch('/-/settings/passkeys/begin', { method: 'POST', headers: headers }); | |
| 640 | + | if (!res.ok) return fail(await res.text()); | |
| 641 | + | var begin = await res.json(); | |
| 642 | + | var credential = await navigator.credentials.create({ | |
| 643 | + | publicKey: anvilWebAuthn.toCreationOptions(begin.options.publicKey || begin.options), | |
| 644 | + | }); | |
| 645 | + | if (!credential) return fail('No passkey was created.'); | |
| 646 | + | statusEl.textContent = 'Saving…'; | |
| 647 | + | var save = await fetch('/-/settings/passkeys/finish', { | |
| 648 | + | method: 'POST', | |
| 649 | + | headers: headers, | |
| 650 | + | body: JSON.stringify({ | |
| 651 | + | ceremony: begin.ceremony, | |
| 652 | + | handle: begin.handle, | |
| 653 | + | name: nameEl.value, | |
| 654 | + | credential: anvilWebAuthn.registrationJson(credential), | |
| 655 | + | }), | |
| 656 | + | }); | |
| 657 | + | if (!save.ok) return fail(await save.text()); | |
| 658 | + | location.reload(); | |
| 659 | + | } catch (e) { | |
| 660 | + | // NotAllowedError is the user cancelling or letting the prompt time out. | |
| 661 | + | fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e))); | |
| 662 | + | } | |
| 663 | + | }); | |
| 664 | + | })(); | |
| 665 | + | "#; | |
| 666 | + | ||
| 667 | + | /// Sign-in, driven from the login page. | |
| 668 | + | const LOGIN_JS: &str = r#" | |
| 669 | + | (function () { | |
| 670 | + | var button = document.getElementById('passkey-login-btn'); | |
| 671 | + | if (!button) return; | |
| 672 | + | var statusEl = document.getElementById('passkey-login-status'); | |
| 673 | + | ||
| 674 | + | function fail(message) { | |
| 675 | + | statusEl.textContent = message; | |
| 676 | + | statusEl.style.color = '#cf222e'; | |
| 677 | + | button.disabled = false; | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | if (!window.PublicKeyCredential) { | |
| 681 | + | document.getElementById('passkey-login').style.display = 'none'; | |
| 682 | + | return; | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | button.addEventListener('click', async function () { | |
| 686 | + | button.disabled = true; | |
| 687 | + | statusEl.style.color = ''; | |
| 688 | + | statusEl.textContent = 'Waiting for your authenticator…'; | |
| 689 | + | try { | |
| 690 | + | var res = await fetch('/-/login/passkey/begin', { method: 'POST' }); | |
| 691 | + | if (!res.ok) return fail(await res.text()); | |
| 692 | + | var begin = await res.json(); | |
| 693 | + | var credential = await navigator.credentials.get({ | |
| 694 | + | publicKey: anvilWebAuthn.toRequestOptions(begin.options.publicKey || begin.options), | |
| 695 | + | }); | |
| 696 | + | if (!credential) return fail('No passkey was used.'); | |
| 697 | + | statusEl.textContent = 'Signing in…'; | |
| 698 | + | var done = await fetch('/-/login/passkey/finish', { | |
| 699 | + | method: 'POST', | |
| 700 | + | headers: { 'Content-Type': 'application/json' }, | |
| 701 | + | body: JSON.stringify({ | |
| 702 | + | ceremony: begin.ceremony, | |
| 703 | + | credential: anvilWebAuthn.assertionJson(credential), | |
| 704 | + | }), | |
| 705 | + | }); | |
| 706 | + | if (!done.ok) return fail(await done.text()); | |
| 707 | + | var result = await done.json(); | |
| 708 | + | location.href = result.redirect || '/'; | |
| 709 | + | } catch (e) { | |
| 710 | + | fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e))); | |
| 711 | + | } | |
| 712 | + | }); | |
| 713 | + | })(); | |
| 714 | + | "#; | |
| 715 | + | ||
| 716 | + | /// Emitted once per page that uses either ceremony. | |
| 717 | + | pub fn shared_script() -> Markup { | |
| 718 | + | html! { script { (PreEscaped(WEBAUTHN_JS)) } } | |
| 719 | + | } |
modifiedcrates/anvil-web/src/ui.rs+32 −3
| ⋯ 658 unchanged lines | |||
| 659 | 659 | Err(e) => return server_error(e), | |
| 660 | 660 | }; | |
| 661 | 661 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 662 | - | account_page(&user, &keys, &tokens, None, None, &csrf.0).into_response() | |
| 662 | + | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 663 | + | .await | |
| 664 | + | .unwrap_or_default(); | |
| 665 | + | account_page(&user, &keys, &tokens, &passkeys, None, None, &csrf.0).into_response() | |
| 663 | 666 | } | |
| 664 | 667 | ||
| 665 | 668 | /// `POST /settings/keys` — register an SSH public key for the current user. | |
| ⋯ 24 unchanged lines | |||
| 690 | 693 | .await | |
| 691 | 694 | .unwrap_or_default(); | |
| 692 | 695 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 696 | + | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 697 | + | .await | |
| 698 | + | .unwrap_or_default(); | |
| 693 | 699 | ( | |
| 694 | 700 | StatusCode::BAD_REQUEST, | |
| 695 | - | account_page(&user, &keys, &tokens, None, Some(&e.to_string()), &csrf.0), | |
| 701 | + | account_page( | |
| 702 | + | &user, | |
| 703 | + | &keys, | |
| 704 | + | &tokens, | |
| 705 | + | &passkeys, | |
| 706 | + | None, | |
| 707 | + | Some(&e.to_string()), | |
| 708 | + | &csrf.0, | |
| 709 | + | ), | |
| 696 | 710 | ) | |
| 697 | 711 | .into_response() | |
| 698 | 712 | } | |
| ⋯ 34 unchanged lines | |||
| 733 | 747 | .await | |
| 734 | 748 | .unwrap_or_default(); | |
| 735 | 749 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 736 | - | account_page(&user, &keys, &tokens, Some(&plaintext), None, &csrf.0).into_response() | |
| 750 | + | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 751 | + | .await | |
| 752 | + | .unwrap_or_default(); | |
| 753 | + | account_page( | |
| 754 | + | &user, | |
| 755 | + | &keys, | |
| 756 | + | &tokens, | |
| 757 | + | &passkeys, | |
| 758 | + | Some(&plaintext), | |
| 759 | + | None, | |
| 760 | + | &csrf.0, | |
| 761 | + | ) | |
| 762 | + | .into_response() | |
| 737 | 763 | } | |
| 738 | 764 | ||
| 739 | 765 | /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's | |
| ⋯ 45 unchanged lines | |||
| 785 | 811 | user: &User, | |
| 786 | 812 | keys: &[SshKey], | |
| 787 | 813 | tokens: &[ApiToken], | |
| 814 | + | passkeys: &[anvil_core::Passkey], | |
| 788 | 815 | new_token: Option<&str>, | |
| 789 | 816 | error: Option<&str>, | |
| 790 | 817 | csrf: &str, | |
| ⋯ 69 unchanged lines | |||
| 860 | 887 | p { label { "Name" br; input type="text" name="name" placeholder="claude"; } } | |
| 861 | 888 | p { button.btn type="submit" { "Create token" } } | |
| 862 | 889 | } | |
| 890 | + | (crate::passkeys::shared_script()) | |
| 891 | + | (crate::passkeys::settings_section(user, passkeys, csrf)) | |
| 863 | 892 | }, | |
| 864 | 893 | ) | |
| 865 | 894 | } | |
| ⋯ 1946 unchanged lines | |||
addeddocs/passkeys.md+78 −0
| 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. |