Skip to main content

hennery_kernel/
passkeys.rs

1//! Passkeys (kernel spec §3.2, plan 3c): WebAuthn with `webauthn-rs`.
2//!
3//! - **The relying party is `public_url`** (decision 1): its host is the RP
4//!   id and its origin the one origin accepted, with no subdomain and no
5//!   other port. A `public_url` whose host is an IP address has no RP id,
6//!   so passkeys are unavailable there.
7//! - **The user handle is derived from the owner's id** (decision 3), not
8//!   stored: an authenticator keeps it, and it names no one.
9//! - **Ceremonies live in memory** (decision 5, kernel spec §3.2), keyed by
10//!   a random id: each is taken once, whatever its finish then finds, and
11//!   lives at most `CEREMONY_TTL_SECS`. A restart drops them all; the
12//!   browser starts again.
13//! - **Passkeys add to the password** (decision 2): registering one needs
14//!   a stepped-up session (decision 7), and removing one always leaves the
15//!   password. Every query names the owner (kernel spec §1).
16
17use crate::operator::{Operator, PublicUrl, STEP_UP_SECS, insert_auth_session};
18use crate::secret::random_bytes;
19use anyhow::Result;
20use rusqlite::{OptionalExtension, params};
21use sha2::{Digest, Sha256};
22use std::collections::HashMap;
23use std::sync::Mutex;
24use webauthn_rs::prelude::{
25    AuthenticationResult, Passkey, PasskeyAuthentication, PasskeyRegistration, PublicKeyCredential,
26    RegisterPublicKeyCredential, Url, Uuid, Webauthn, WebauthnBuilder, WebauthnError,
27};
28
29/// How long a ceremony may take from its start to its finish, and the
30/// timeout the browser is given (decision 5).
31pub const CEREMONY_TTL_SECS: i64 = 5 * 60;
32
33/// At most this many login ceremonies are live at once, and as many
34/// registrations and step-ups: two pools, so a flood of unauthenticated
35/// login starts never pushes out a signed-in session's ceremony (decision
36/// 5; 3c review, O1).
37pub const MAX_LOGIN_CEREMONIES: usize = 1024;
38pub const MAX_SESSION_CEREMONIES: usize = 1024;
39
40/// The relying party's name, which an authenticator may show.
41const RP_NAME: &str = "hennery";
42
43/// The relying party for `public_url`: `None` when its host is an IP
44/// address (decision 1). Any other `None` is a parsed `public_url` that
45/// `webauthn-rs` refuses, unreachable as `PublicUrl::parse` stands; it is
46/// logged, so passkeys never stop working unexplained.
47pub fn relying_party(public_url: &PublicUrl) -> Option<Webauthn> {
48    let rp_id = public_url.rp_id()?;
49    let origin = Url::parse(public_url.origin())
50        .inspect_err(|err| {
51            tracing::warn!(origin = ?public_url.origin(), error = %err, "public_url's origin does not parse: passkeys are unavailable");
52        })
53        .ok()?;
54    WebauthnBuilder::new(rp_id, &origin)
55        .inspect_err(|err| {
56            tracing::warn!(rp_id = ?rp_id, origin = ?public_url.origin(), error = %err, "no relying party for public_url: passkeys are unavailable");
57        })
58        .ok()?
59        .rp_name(RP_NAME)
60        .allow_subdomains(false)
61        .allow_any_port(false)
62        .timeout(std::time::Duration::from_secs(CEREMONY_TTL_SECS as u64))
63        .build()
64        .ok()
65}
66
67/// The owner's WebAuthn user handle: the first 16 bytes of the SHA-256 of
68/// their id. The same owner always gets the same handle.
69pub fn user_handle(owner_id: &str) -> Uuid {
70    let digest = Sha256::digest(owner_id.as_bytes());
71    let mut bytes = [0u8; 16];
72    bytes.copy_from_slice(&digest[..16]);
73    Uuid::from_bytes(bytes)
74}
75
76/// A ceremony begun and not yet finished: what it is for, and the state
77/// `webauthn-rs` checks its finish against.
78pub enum Ceremony {
79    /// Registering a passkey, labelled `label`, from a signed-in session.
80    Register {
81        session_id: String,
82        label: String,
83        state: PasskeyRegistration,
84    },
85    /// Signing in.
86    Login { state: PasskeyAuthentication },
87    /// Stepping up a signed-in session.
88    StepUp {
89        session_id: String,
90        state: PasskeyAuthentication,
91    },
92}
93
94impl Ceremony {
95    /// The session a registration or a step-up belongs to, and its kind:
96    /// a session has at most one live ceremony of each.
97    fn slot(&self) -> Option<(&str, u8)> {
98        match self {
99            Self::Register { session_id, .. } => Some((session_id, 0)),
100            Self::StepUp { session_id, .. } => Some((session_id, 1)),
101            Self::Login { .. } => None,
102        }
103    }
104}
105
106struct Live {
107    ceremony: Ceremony,
108    expires_at: i64,
109    /// The order ceremonies began in: past capacity, the oldest goes.
110    seq: u64,
111}
112
113#[derive(Default)]
114struct Table {
115    live: HashMap<String, Live>,
116    seq: u64,
117}
118
119/// The live ceremonies (decision 5).
120pub struct Ceremonies {
121    /// Login ceremonies, and the session-bound ones, each pool apart.
122    logins: usize,
123    sessions: usize,
124    table: Mutex<Table>,
125}
126
127impl Default for Ceremonies {
128    fn default() -> Self {
129        Self::with_capacity(MAX_LOGIN_CEREMONIES, MAX_SESSION_CEREMONIES)
130    }
131}
132
133impl Ceremonies {
134    pub fn with_capacity(logins: usize, sessions: usize) -> Self {
135        Self {
136            logins,
137            sessions,
138            table: Mutex::new(Table::default()),
139        }
140    }
141
142    fn table(&self) -> std::sync::MutexGuard<'_, Table> {
143        self.table.lock().expect("ceremonies lock")
144    }
145
146    /// Begin `ceremony` and return its id (64 hex characters). Expired
147    /// ceremonies are dropped first, and the session's earlier ceremony of
148    /// the same kind; past its pool's capacity, the oldest live one of that
149    /// pool goes too.
150    pub fn begin(&self, ceremony: Ceremony, now: i64) -> String {
151        let id = hex::encode(random_bytes::<32>());
152        let mut table = self.table();
153        table.live.retain(|_, live| live.expires_at > now);
154        if let Some(slot) = ceremony.slot() {
155            table.live.retain(|_, live| live.ceremony.slot() != Some(slot));
156        }
157        let login = ceremony.slot().is_none();
158        let capacity = if login { self.logins } else { self.sessions };
159        loop {
160            let pool = table
161                .live
162                .iter()
163                .filter(|(_, live)| live.ceremony.slot().is_none() == login);
164            if pool.clone().count() < capacity.max(1) {
165                break;
166            }
167            let Some(oldest) = pool.min_by_key(|(_, live)| live.seq).map(|(id, _)| id.clone()) else {
168                break;
169            };
170            table.live.remove(&oldest);
171        }
172        table.seq += 1;
173        let seq = table.seq;
174        table.live.insert(
175            id.clone(),
176            Live {
177                ceremony,
178                expires_at: now + CEREMONY_TTL_SECS,
179                seq,
180            },
181        );
182        id
183    }
184
185    /// Take the ceremony `id` names: it is gone whatever its finish then
186    /// finds. `None` when there is none, or it has expired.
187    pub fn take(&self, id: &str, now: i64) -> Option<Ceremony> {
188        let live = self.table().live.remove(id)?;
189        (live.expires_at > now).then_some(live.ceremony)
190    }
191
192    /// Drop every ceremony (`public_url` moved: decision 9).
193    pub fn clear(&self) {
194        self.table().live.clear();
195    }
196
197    /// How many are live, expired ones included until the next `begin`.
198    pub fn len(&self) -> usize {
199        self.table().live.len()
200    }
201
202    pub fn is_empty(&self) -> bool {
203        self.len() == 0
204    }
205}
206
207/// The longest label a passkey takes, in characters.
208pub const MAX_LABEL_CHARS: usize = 64;
209
210/// The account name and display name an authenticator stores with the
211/// passkey and may show beside the RP id. The label is hennery's own.
212const USER_NAME: &str = "owner";
213const USER_DISPLAY_NAME: &str = "hennery owner";
214
215/// One of the owner's passkeys, as Settings lists them (kernel spec §3.2).
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct PasskeyRecord {
218    pub id: String,
219    pub label: String,
220    pub created_at: i64,
221    pub last_used_at: Option<i64>,
222}
223
224/// The outcome of starting a ceremony.
225#[derive(Debug, Clone, PartialEq)]
226pub enum Start {
227    /// Begun: `options` go to the browser's `navigator.credentials`, and
228    /// the finish names `ceremony_id`.
229    Begun {
230        ceremony_id: String,
231        options: serde_json::Value,
232    },
233    /// There is no `public_url` yet, or its host is an IP address
234    /// (decision 1).
235    Unavailable,
236    /// The owner has no passkey to sign in or step up with.
237    NoPasskeys,
238    /// The label is not acceptable (why).
239    Invalid(String),
240}
241
242/// Why a finish was refused. Nothing changed.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Refused {
245    /// The ceremony is unknown, used, expired, or of another kind or
246    /// session: one answer for all.
247    Ceremony,
248    /// The authenticator's answer is malformed or did not verify (why, for
249    /// the log).
250    Credential(String),
251    /// The credential is registered already, to this owner or another
252    /// (decision 3).
253    AlreadyRegistered,
254    /// The passkey is gone: removed since the ceremony began, or not the
255    /// owner's.
256    Passkey,
257    /// The passkey's counter did not move on: it may have been cloned
258    /// (decision 6). Logged at `warn` with the passkey's id, whichever check
259    /// caught it (3c review, A3).
260    CounterWentBack,
261    /// `public_url` no longer names a host (decision 1).
262    Unavailable,
263}
264
265/// Why `label` cannot name a passkey, if it cannot.
266fn label_problem(label: &str) -> Option<String> {
267    let chars = label.chars().count();
268    if chars == 0 || chars > MAX_LABEL_CHARS {
269        return Some(format!("a passkey's label is 1 to {MAX_LABEL_CHARS} characters"));
270    }
271    if label.chars().any(char::is_control) {
272        return Some("a passkey's label has no control characters".into());
273    }
274    None
275}
276
277/// How a credential id is stored: lowercase hex of its bytes.
278fn stored_id(id: &[u8]) -> String {
279    hex::encode(id)
280}
281
282/// Whether an authenticator's signature counter `sent` may follow
283/// `stored`, the one last accepted (decision 6): it must move on, unless
284/// both are 0, as they stay for an authenticator with no counter (synced
285/// passkeys). One that does not may be a clone.
286pub fn counter_moves_on(stored: u32, sent: u32) -> bool {
287    sent > stored || (stored == 0 && sent == 0)
288}
289
290/// The browser's answer to an authentication, checked against `state`.
291/// `webauthn-rs` checks the counter against the ceremony's snapshot first,
292/// and refuses one that did not move on as `CredentialPossibleCompromise`:
293/// the path a real clone takes. That is logged as `record_use` logs its
294/// own check, with the passkey's id, read from `conn` (3c review, A3).
295fn verify_assertion(
296    conn: &rusqlite::Connection,
297    owner: &str,
298    rp: &Webauthn,
299    state: &PasskeyAuthentication,
300    credential: &serde_json::Value,
301) -> Result<std::result::Result<AuthenticationResult, Refused>> {
302    let credential: PublicKeyCredential = match serde_json::from_value(credential.clone()) {
303        Ok(credential) => credential,
304        Err(err) => return Ok(Err(Refused::Credential(format!("malformed: {err}")))),
305    };
306    match rp.finish_passkey_authentication(&credential, state) {
307        Ok(result) => Ok(Ok(result)),
308        Err(WebauthnError::CredentialPossibleCompromise) => {
309            let id: Option<String> = conn
310                .query_row(
311                    "SELECT id FROM passkeys WHERE credential_id = ?1 AND owner_id = ?2",
312                    params![stored_id(credential.raw_id.as_ref()), owner],
313                    |r| r.get(0),
314                )
315                .optional()?;
316            const WHY: &str = "a passkey's counter did not move on, so it may have been cloned: refused";
317            match id {
318                Some(id) => tracing::warn!(passkey = %id, "{WHY}"),
319                // Removed since: the warning still stands.
320                None => tracing::warn!("{WHY}"),
321            }
322            Ok(Err(Refused::CounterWentBack))
323        }
324        Err(err) => Ok(Err(Refused::Credential(err.to_string()))),
325    }
326}
327
328impl Operator {
329    /// The relying party for the `public_url` in effect (decision 1).
330    fn relying_party(&self) -> Option<Webauthn> {
331        self.public_url().as_ref().and_then(relying_party)
332    }
333
334    /// The owner's passkeys, oldest first, as `webauthn-rs` reads them.
335    fn owner_passkeys(&self) -> Result<Vec<Passkey>> {
336        let conn = self.conn();
337        let mut stmt =
338            conn.prepare("SELECT credential FROM passkeys WHERE owner_id = ?1 ORDER BY created_at, rowid")?;
339        let rows = stmt.query_map([self.owner_id()], |r| r.get::<_, String>(0))?;
340        let mut passkeys = Vec::new();
341        for json in rows {
342            passkeys.push(serde_json::from_str(&json?)?);
343        }
344        Ok(passkeys)
345    }
346
347    /// Begin registering a passkey labelled `label` for the signed-in
348    /// session `session_id` (decision 7: the route requires step-up). The
349    /// owner's passkeys are excluded, so an authenticator that holds one
350    /// says so rather than making a second.
351    pub fn start_passkey_registration(&self, session_id: &str, label: &str, now: i64) -> Result<Start> {
352        let label = label.trim();
353        if let Some(problem) = label_problem(label) {
354            return Ok(Start::Invalid(problem));
355        }
356        let Some(rp) = self.relying_party() else {
357            return Ok(Start::Unavailable);
358        };
359        let exclude = self.owner_passkeys()?.iter().map(|p| p.cred_id().clone()).collect();
360        let (options, state) = rp.start_passkey_registration(
361            user_handle(self.owner_id()),
362            USER_NAME,
363            USER_DISPLAY_NAME,
364            Some(exclude),
365        )?;
366        let ceremony = Ceremony::Register {
367            session_id: session_id.into(),
368            label: label.into(),
369            state,
370        };
371        Ok(Start::Begun {
372            ceremony_id: self.ceremonies.begin(ceremony, now),
373            options: serde_json::to_value(options)?,
374        })
375    }
376
377    /// Finish the registration `ceremony_id` names, begun by `session_id`,
378    /// with the browser's `credential` (`RegisterPublicKeyCredential` as
379    /// JSON), and store the passkey.
380    pub fn finish_passkey_registration(
381        &self,
382        session_id: &str,
383        ceremony_id: &str,
384        credential: &serde_json::Value,
385        now: i64,
386    ) -> Result<std::result::Result<PasskeyRecord, Refused>> {
387        let Some(Ceremony::Register {
388            session_id: begun_by,
389            label,
390            state,
391        }) = self.ceremonies.take(ceremony_id, now)
392        else {
393            return Ok(Err(Refused::Ceremony));
394        };
395        if begun_by != session_id {
396            return Ok(Err(Refused::Ceremony));
397        }
398        // Held from the relying party's read to the write (3c review, A4):
399        // a `public_url` reset, which takes this lock too, lands before or
400        // after the whole finish, never between its check and its write.
401        let conn = self.conn();
402        let Some(rp) = self.relying_party() else {
403            return Ok(Err(Refused::Unavailable));
404        };
405        let credential: RegisterPublicKeyCredential = match serde_json::from_value(credential.clone()) {
406            Ok(credential) => credential,
407            Err(err) => return Ok(Err(Refused::Credential(format!("malformed: {err}")))),
408        };
409        let passkey = match rp.finish_passkey_registration(&credential, &state) {
410            Ok(passkey) => passkey,
411            Err(err) => return Ok(Err(Refused::Credential(err.to_string()))),
412        };
413        let record = PasskeyRecord {
414            id: format!("passkey-{}", hex::encode(random_bytes::<8>())),
415            label,
416            created_at: now,
417            last_used_at: None,
418        };
419        // The counter starts at 0, below any the authenticator sends next
420        // (decision 6). The write itself requires the session to be live
421        // and stepped up (decision 7; 3c review, A2), as `open_session`
422        // requires its password: a session ended, or a step-up lapsed,
423        // since the route's check stores nothing.
424        let inserted = conn.execute(
425            "INSERT INTO passkeys(id, owner_id, credential_id, credential, sign_count, label, created_at)
426             SELECT ?1, ?2, ?3, ?4, 0, ?5, ?6
427             WHERE EXISTS (SELECT 1 FROM auth_sessions
428                           WHERE id_hash = ?7 AND owner_id = ?2 AND expires_at > ?6 AND last_step_up_at > ?6 - ?8)
429             ON CONFLICT(credential_id) DO NOTHING",
430            params![
431                record.id,
432                self.owner_id(),
433                stored_id(passkey.cred_id().as_ref()),
434                serde_json::to_string(&passkey)?,
435                record.label,
436                now,
437                session_id,
438                STEP_UP_SECS
439            ],
440        )?;
441        if inserted == 0 {
442            // Nothing written: the session, or else a clash (decision 3).
443            let live: bool = conn.query_row(
444                "SELECT EXISTS (SELECT 1 FROM auth_sessions
445                     WHERE id_hash = ?1 AND owner_id = ?2 AND expires_at > ?3 AND last_step_up_at > ?3 - ?4)",
446                params![session_id, self.owner_id(), now, STEP_UP_SECS],
447                |r| r.get(0),
448            )?;
449            return Ok(Err(if live {
450                Refused::AlreadyRegistered
451            } else {
452                Refused::Ceremony
453            }));
454        }
455        Ok(Ok(record))
456    }
457
458    /// The owner's passkeys, oldest first.
459    pub fn passkeys(&self) -> Result<Vec<PasskeyRecord>> {
460        let conn = self.conn();
461        let mut stmt = conn.prepare(
462            "SELECT id, label, created_at, last_used_at FROM passkeys WHERE owner_id = ?1 ORDER BY created_at, rowid",
463        )?;
464        let rows = stmt.query_map([self.owner_id()], |r| {
465            Ok(PasskeyRecord {
466                id: r.get(0)?,
467                label: r.get(1)?,
468                created_at: r.get(2)?,
469                last_used_at: r.get(3)?,
470            })
471        })?;
472        Ok(rows.collect::<rusqlite::Result<_>>()?)
473    }
474
475    /// Remove the passkey `id` (the route requires step-up, kernel spec
476    /// §3.4). Whether there was one. The password stays, so a login method
477    /// always remains (decision 2).
478    pub fn remove_passkey(&self, id: &str) -> Result<bool> {
479        let removed = self.conn().execute(
480            "DELETE FROM passkeys WHERE id = ?1 AND owner_id = ?2",
481            params![id, self.owner_id()],
482        )?;
483        Ok(removed > 0)
484    }
485
486    /// Begin an authentication over every passkey of the owner's.
487    fn begin_authentication(
488        &self,
489        ceremony: impl FnOnce(PasskeyAuthentication) -> Ceremony,
490        now: i64,
491    ) -> Result<Start> {
492        let Some(rp) = self.relying_party() else {
493            return Ok(Start::Unavailable);
494        };
495        let passkeys = self.owner_passkeys()?;
496        if passkeys.is_empty() {
497            return Ok(Start::NoPasskeys);
498        }
499        let (options, state) = rp.start_passkey_authentication(&passkeys)?;
500        Ok(Start::Begun {
501            ceremony_id: self.ceremonies.begin(ceremony(state), now),
502            options: serde_json::to_value(options)?,
503        })
504    }
505
506    /// Begin signing in with a passkey (decision 8).
507    pub fn start_passkey_login(&self, now: i64) -> Result<Start> {
508        self.begin_authentication(|state| Ceremony::Login { state }, now)
509    }
510
511    /// Finish the login `ceremony_id` names with the browser's
512    /// `credential` (`PublicKeyCredential` as JSON), and open a session:
513    /// its token, the cookie's value. The session starts stepped up, as a
514    /// password login's does. The passkey's counter moves on and the
515    /// session opens in one transaction, and only while the passkey is
516    /// still stored: one removed since the ceremony began opens nothing.
517    pub fn finish_passkey_login(
518        &self,
519        ceremony_id: &str,
520        credential: &serde_json::Value,
521        user_agent: &str,
522        now: i64,
523    ) -> Result<std::result::Result<String, Refused>> {
524        let Some(Ceremony::Login { state }) = self.ceremonies.take(ceremony_id, now) else {
525            return Ok(Err(Refused::Ceremony));
526        };
527        // Held from the relying party's read to the commit (3c review, A4),
528        // as in every finish.
529        let mut conn = self.conn();
530        let Some(rp) = self.relying_party() else {
531            return Ok(Err(Refused::Unavailable));
532        };
533        let result = match verify_assertion(&conn, self.owner_id(), &rp, &state, credential)? {
534            Ok(result) => result,
535            Err(why) => return Ok(Err(why)),
536        };
537        let token = hex::encode(random_bytes::<32>());
538        let tx = conn.transaction()?;
539        if let Err(why) = record_use(&tx, self.owner_id(), &result, now)? {
540            return Ok(Err(why));
541        }
542        // `record_use` just proved the passkey, in this transaction: the
543        // session needs no password binding.
544        let opened = insert_auth_session(&tx, self.owner_id(), &token, user_agent, None, now)?;
545        anyhow::ensure!(opened, "a passkey login's session was not stored");
546        tx.commit()?;
547        Ok(Ok(token))
548    }
549
550    /// Begin stepping the session `session_id` up with a passkey (kernel
551    /// spec §3.4, decision 8).
552    pub fn start_passkey_step_up(&self, session_id: &str, now: i64) -> Result<Start> {
553        let session_id = session_id.to_string();
554        self.begin_authentication(|state| Ceremony::StepUp { session_id, state }, now)
555    }
556
557    /// Finish the step-up `ceremony_id` names, begun by `session_id`, and
558    /// record it on the session, in one transaction with the passkey's
559    /// counter. `Ok(false)` when the session has ended meanwhile.
560    pub fn finish_passkey_step_up(
561        &self,
562        session_id: &str,
563        ceremony_id: &str,
564        credential: &serde_json::Value,
565        now: i64,
566    ) -> Result<std::result::Result<bool, Refused>> {
567        let Some(Ceremony::StepUp {
568            session_id: begun_by,
569            state,
570        }) = self.ceremonies.take(ceremony_id, now)
571        else {
572            return Ok(Err(Refused::Ceremony));
573        };
574        if begun_by != session_id {
575            return Ok(Err(Refused::Ceremony));
576        }
577        // Held from the relying party's read to the commit (3c review, A4).
578        let mut conn = self.conn();
579        let Some(rp) = self.relying_party() else {
580            return Ok(Err(Refused::Unavailable));
581        };
582        let result = match verify_assertion(&conn, self.owner_id(), &rp, &state, credential)? {
583            Ok(result) => result,
584            Err(why) => return Ok(Err(why)),
585        };
586        let tx = conn.transaction()?;
587        if let Err(why) = record_use(&tx, self.owner_id(), &result, now)? {
588            return Ok(Err(why));
589        }
590        let stepped_up = tx.execute(
591            "UPDATE auth_sessions SET last_step_up_at = ?2 WHERE id_hash = ?1 AND owner_id = ?3",
592            params![session_id, now, self.owner_id()],
593        )?;
594        if stepped_up == 0 {
595            return Ok(Ok(false));
596        }
597        tx.commit()?;
598        Ok(Ok(true))
599    }
600}
601
602/// Move the counter of the passkey `result` names on, and mark it used at
603/// `now`, in `tx` (decision 6). Refused, changing nothing, when the owner
604/// has no such passkey (removed since the ceremony began) or its counter
605/// did not move on. The counter is compared and set in one statement.
606fn record_use(
607    tx: &rusqlite::Transaction<'_>,
608    owner: &str,
609    result: &AuthenticationResult,
610    now: i64,
611) -> Result<std::result::Result<(), Refused>> {
612    let stored: Option<(String, String, i64)> = tx
613        .query_row(
614            "SELECT id, credential, sign_count FROM passkeys WHERE credential_id = ?1 AND owner_id = ?2",
615            params![stored_id(result.cred_id().as_ref()), owner],
616            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
617        )
618        .optional()?;
619    let Some((id, credential, sign_count)) = stored else {
620        return Ok(Err(Refused::Passkey));
621    };
622    if !counter_moves_on(sign_count as u32, result.counter()) {
623        tracing::warn!(
624            passkey = %id,
625            stored = sign_count,
626            sent = result.counter(),
627            "a passkey's counter did not move on, so it may have been cloned: refused"
628        );
629        return Ok(Err(Refused::CounterWentBack));
630    }
631    let mut passkey: Passkey = serde_json::from_str(&credential)?;
632    passkey.update_credential(result);
633    let updated = tx.execute(
634        "UPDATE passkeys SET credential = ?1, sign_count = ?2, last_used_at = ?3
635         WHERE id = ?4 AND owner_id = ?5 AND sign_count = ?6",
636        params![
637            serde_json::to_string(&passkey)?,
638            result.counter(),
639            now,
640            id,
641            owner,
642            sign_count
643        ],
644    )?;
645    if updated != 1 {
646        tracing::debug!(
647            passkey = %id,
648            "a passkey changed or was removed while its use was recorded: refused"
649        );
650        return Ok(Err(Refused::Passkey));
651    }
652    Ok(Ok(()))
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    const NOW: i64 = 1_800_000_000;
660
661    fn rp() -> Webauthn {
662        relying_party(&PublicUrl::parse("https://hennery.example").unwrap()).unwrap()
663    }
664
665    fn login() -> Ceremony {
666        let (_, state) = rp().start_passkey_authentication(&[]).unwrap();
667        Ceremony::Login { state }
668    }
669
670    fn step_up(session_id: &str) -> Ceremony {
671        let (_, state) = rp().start_passkey_authentication(&[]).unwrap();
672        Ceremony::StepUp {
673            session_id: session_id.into(),
674            state,
675        }
676    }
677
678    fn register(session_id: &str) -> Ceremony {
679        let (_, state) = rp()
680            .start_passkey_registration(Uuid::nil(), "owner", "owner", None)
681            .unwrap();
682        Ceremony::Register {
683            session_id: session_id.into(),
684            label: "laptop".into(),
685            state,
686        }
687    }
688
689    #[test]
690    fn a_ceremony_is_taken_once() {
691        let ceremonies = Ceremonies::default();
692        let id = ceremonies.begin(login(), NOW);
693        assert_eq!(id.len(), 64);
694        assert!(matches!(ceremonies.take(&id, NOW), Some(Ceremony::Login { .. })));
695        assert!(ceremonies.take(&id, NOW).is_none());
696        assert!(ceremonies.take("unknown", NOW).is_none());
697    }
698
699    #[test]
700    fn a_ceremony_expires_after_its_ttl() {
701        let ceremonies = Ceremonies::default();
702        let live = ceremonies.begin(login(), NOW);
703        let expired = ceremonies.begin(login(), NOW);
704        assert!(ceremonies.take(&live, NOW + CEREMONY_TTL_SECS - 1).is_some());
705        assert!(ceremonies.take(&expired, NOW + CEREMONY_TTL_SECS).is_none());
706        // An expired ceremony is gone once taken, even at an earlier time.
707        assert!(ceremonies.take(&expired, NOW).is_none());
708    }
709
710    #[test]
711    fn a_sessions_new_ceremony_replaces_its_last_of_that_kind() {
712        let ceremonies = Ceremonies::default();
713        let first = ceremonies.begin(register("s1"), NOW);
714        let step_up_s1 = ceremonies.begin(step_up("s1"), NOW);
715        let other_session = ceremonies.begin(register("s2"), NOW);
716        let login_1 = ceremonies.begin(login(), NOW);
717        let login_2 = ceremonies.begin(login(), NOW);
718        let second = ceremonies.begin(register("s1"), NOW);
719        assert!(ceremonies.take(&first, NOW).is_none());
720        for id in [&step_up_s1, &other_session, &login_1, &login_2, &second] {
721            assert!(ceremonies.take(id, NOW).is_some());
722        }
723    }
724
725    #[test]
726    fn past_capacity_the_oldest_ceremony_goes() {
727        let ceremonies = Ceremonies::with_capacity(3, 3);
728        let ids: Vec<String> = (0..4).map(|_| ceremonies.begin(login(), NOW)).collect();
729        assert_eq!(ceremonies.len(), 3);
730        assert!(ceremonies.take(&ids[0], NOW).is_none());
731        for id in &ids[1..] {
732            assert!(ceremonies.take(id, NOW).is_some());
733        }
734    }
735
736    /// Expired ceremonies make room before a live one is pushed out.
737    #[test]
738    fn expired_ceremonies_go_before_the_oldest_live_one() {
739        let ceremonies = Ceremonies::with_capacity(2, 2);
740        let expiring = ceremonies.begin(login(), NOW - CEREMONY_TTL_SECS);
741        let live = ceremonies.begin(login(), NOW - 1);
742        let new = ceremonies.begin(login(), NOW);
743        assert_eq!(ceremonies.len(), 2);
744        assert!(ceremonies.take(&expiring, NOW - CEREMONY_TTL_SECS).is_none());
745        assert!(ceremonies.take(&live, NOW).is_some());
746        assert!(ceremonies.take(&new, NOW).is_some());
747    }
748
749    /// Login ceremonies have a pool of their own (3c review, O1): a flood
750    /// of unauthenticated starts pushes out only other logins, never a
751    /// signed-in session's registration or step-up. Past their own
752    /// capacity, those push out only each other.
753    #[test]
754    fn a_login_flood_never_pushes_out_a_sessions_ceremony() {
755        let ceremonies = Ceremonies::with_capacity(2, 2);
756        let registration = ceremonies.begin(register("s1"), NOW);
757        let step_up_s1 = ceremonies.begin(step_up("s1"), NOW);
758        let logins: Vec<String> = (0..5).map(|_| ceremonies.begin(login(), NOW)).collect();
759        assert_eq!(ceremonies.len(), 4);
760        assert!(ceremonies.take(&registration, NOW).is_some());
761        assert!(ceremonies.take(&step_up_s1, NOW).is_some());
762        assert!(ceremonies.take(&logins[2], NOW).is_none());
763        assert!(ceremonies.take(&logins[4], NOW).is_some());
764        let sessions: Vec<String> = (0..3)
765            .map(|n| ceremonies.begin(register(&format!("s{n}")), NOW))
766            .collect();
767        assert!(ceremonies.take(&logins[3], NOW).is_some());
768        assert!(ceremonies.take(&sessions[0], NOW).is_none());
769        assert!(ceremonies.take(&sessions[2], NOW).is_some());
770    }
771
772    /// `with_capacity(logins, sessions)`: unequal pools pin the order, so
773    /// swapped arguments keep both logins and only the last registration.
774    #[test]
775    fn each_pool_has_the_capacity_given_for_it() {
776        let ceremonies = Ceremonies::with_capacity(1, 3);
777        let sessions: Vec<String> = (0..3)
778            .map(|n| ceremonies.begin(register(&format!("s{n}")), NOW))
779            .collect();
780        let logins: Vec<String> = (0..2).map(|_| ceremonies.begin(login(), NOW)).collect();
781        assert_eq!(ceremonies.len(), 4);
782        assert!(ceremonies.take(&logins[0], NOW).is_none());
783        assert!(ceremonies.take(&logins[1], NOW).is_some());
784        for id in &sessions {
785            assert!(ceremonies.take(id, NOW).is_some());
786        }
787    }
788
789    #[test]
790    fn clear_drops_every_ceremony() {
791        let ceremonies = Ceremonies::default();
792        let ids = [
793            ceremonies.begin(login(), NOW),
794            ceremonies.begin(register("s1"), NOW),
795            ceremonies.begin(step_up("s1"), NOW),
796        ];
797        ceremonies.clear();
798        assert!(ceremonies.is_empty());
799        for id in &ids {
800            assert!(ceremonies.take(id, NOW).is_none());
801        }
802    }
803}