anvilsign in

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
2626 ```
2727
2828 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`.
2936
3037 ## Docs
3138
3239 - [CI artifacts](docs/ci-artifacts.md)
40+- [Passkeys](docs/passkeys.md) — WebAuthn sign-in
3341 - [Repository secrets](docs/secrets.md) — encrypted to your ssh keys in the
3442 browser; anvil stores ciphertext it cannot open
3543 - [Threat model for untrusted users](docs/untrusted-mode.md)
modifiedTODO.md+3 −0
⋯ 29 unchanged lines
3030 repo listings for visual browsing
3131 - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and
3232 `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
3336 - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an
3437 ssh signature instead of the account password; per-step rather than per-
3538 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
186186 Box::new(anvil_core::periodic::SecretVaultSweepJob)
187187 as Box<dyn anvil_core::periodic::PeriodicJob>,
188188 ),
189+ (
190+ std::time::Duration::from_secs(300),
191+ Box::new(anvil_core::periodic::PasskeyCeremonySweepJob)
192+ as Box<dyn anvil_core::periodic::PeriodicJob>,
193+ ),
189194 ];
190195 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
191196
⋯ 140 unchanged lines
modifiedcrates/anvil-core/Cargo.toml+1 −0
⋯ 18 unchanged lines
1919 hmac.workspace = true
2020 sha2.workspace = true
2121 ssh-key.workspace = true
22+webauthn_rp.workspace = true
2223 serde.workspace = true
2324 serde_json.workspace = true
2425 serde_yaml.workspace = true
⋯ 13 unchanged lines
modifiedcrates/anvil-core/src/db.rs+19 −1
⋯ 11 unchanged lines
1212 CiRun,
1313 Issue,
1414 IssueComment,
15+ Passkey,
1516 RepoSecret,
1617 Repository,
1718 Session,
⋯ 29 unchanged lines
4748 Attachment,
4849 ApiToken,
4950 AdminCache,
50- RepoSecret
51+ RepoSecret,
52+ Passkey
5153 ))
5254 .connect(&url)
5355 .await?;
⋯ 26 unchanged lines
8082 ADMIN_CACHE_DDL,
8183 REPO_SECRETS_DDL,
8284 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")"#,
8388 ];
8489
8590 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 52 unchanged lines
138143 "created_at" BIGINT NOT NULL,
139144 "updated_at" BIGINT NOT NULL )"#;
140145
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+
141158 const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" (
142159 "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
143160 "key" TEXT NOT NULL,
⋯ 66 unchanged lines
210227 "attachments",
211228 "api_tokens",
212229 "repo_secrets",
230+ "passkeys",
213231 ];
214232
215233 /// Every schema object (table + indexes) for `table`, normalized.
⋯ 118 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+5 −0
⋯ 15 unchanged lines
1616 pub mod issues;
1717 pub mod language;
1818 pub mod models;
19+pub mod passkeys;
1920 pub mod periodic;
2021 pub mod preview_images;
2122 pub mod repos;
⋯ 16 unchanged lines
3839 CiRun,
3940 Issue,
4041 IssueComment,
42+ Passkey,
4143 RepoSecret,
4244 Repository,
4345 Session,
⋯ 23 unchanged lines
6769 /// Plaintext repo secrets for CI, held in memory only and lost on
6870 /// restart — see [`secrets::Vault`].
6971 pub vault: secrets::Vault,
72+ /// WebAuthn challenges awaiting an answer — see [`passkeys::Ceremonies`].
73+ pub ceremonies: passkeys::Ceremonies,
7074 /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
7175 /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
7276 csrf_secret: std::sync::Arc<[u8; 32]>,
⋯ 14 unchanged lines
8791 db,
8892 ci_tx: None,
8993 vault: secrets::Vault::default(),
94+ ceremonies: passkeys::Ceremonies::default(),
9095 csrf_secret,
9196 })
9297 }
⋯ 52 unchanged lines
modifiedcrates/anvil-core/src/models.rs+34 −0
⋯ 214 unchanged lines
215215 pub created_at: i64,
216216 }
217217
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+
218252 /// A per-repository secret, stored only as a sealed envelope.
219253 ///
220254 /// 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
182182 }
183183 }
184184
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+
185200 /// Try to extract the first image URL from a repository's README file.
186201 /// Scans the repository for a README file, reads it, and returns the first image URL found.
187202 async fn extract_readme_image(repo_path: &Path) -> Option<String> {
⋯ 21 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+1 −0
⋯ 9 unchanged lines
1010 [dependencies]
1111 anvil-core.workspace = true
1212 anvil-git.workspace = true
13+webauthn_rp.workspace = true
1314 axum.workspace = true
1415 axum-extra.workspace = true
1516 tokio.workspace = true
⋯ 18 unchanged lines
modifiedcrates/anvil-web/src/auth.rs+20 −10
⋯ 40 unchanged lines
4141
4242 use crate::ui::layout;
4343
44-const SESSION_COOKIE: &str = "anvil_session";
44+pub(crate) const SESSION_COOKIE: &str = "anvil_session";
4545
4646 /// Hidden form field (and header) name carrying the CSRF token.
4747 pub const CSRF_FIELD: &str = "csrf";
⋯ 156 unchanged lines
204204 };
205205
206206 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(),
216212 Err(e) => {
217213 tracing::error!("session create failed: {e}");
218214 (
⋯ 31 unchanged lines
250246 p { label { "Password" br; input name="password" type="password"; } }
251247 button type="submit" { "Sign in" }
252248 }
249+ (crate::passkeys::shared_script())
250+ (crate::passkeys::login_button())
253251 },
254252 )
255253 }
256254
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+
257267 /// Verify HTTP Basic credentials from the `Authorization` header against a user.
258268 /// Returns the authenticated user, or `None` if absent/invalid.
259269 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
2626 pub mod auth;
2727 pub mod git_http;
2828 pub mod pages;
29+pub mod passkeys;
2930 pub mod secrets;
3031 pub mod todomd;
3132 pub mod ui;
⋯ 12 unchanged lines
4445 router,
4546 app.config.http.attachment_max_mb.saturating_mul(1 << 20),
4647 ); // uploaded image attachments
48+ router = passkeys::routes(router); // WebAuthn sign-in
4749 router = secrets::routes(router); // sealed per-repo secrets + unlock API
4850 router = git_http::routes(router); // smart-HTTP git endpoints
4951 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, &registration, &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
659659 Err(e) => return server_error(e),
660660 };
661661 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()
663666 }
664667
665668 /// `POST /settings/keys` — register an SSH public key for the current user.
⋯ 24 unchanged lines
690693 .await
691694 .unwrap_or_default();
692695 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();
693699 (
694700 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+ ),
696710 )
697711 .into_response()
698712 }
⋯ 34 unchanged lines
733747 .await
734748 .unwrap_or_default();
735749 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()
737763 }
738764
739765 /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's
⋯ 45 unchanged lines
785811 user: &User,
786812 keys: &[SshKey],
787813 tokens: &[ApiToken],
814+ passkeys: &[anvil_core::Passkey],
788815 new_token: Option<&str>,
789816 error: Option<&str>,
790817 csrf: &str,
⋯ 69 unchanged lines
860887 p { label { "Name" br; input type="text" name="name" placeholder="claude"; } }
861888 p { button.btn type="submit" { "Create token" } }
862889 }
890+ (crate::passkeys::shared_script())
891+ (crate::passkeys::settings_section(user, passkeys, csrf))
863892 },
864893 )
865894 }
⋯ 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.