Skip to main content

recall_server/store/
passkeys.rs

1//! The owner's passkeys, the admin page's sessions, and the one-time code
2//! that registers the first passkey.
3//!
4//! A passkey is stored as `webauthn-rs` serialises it, in `passkey`, and
5//! that JSON is opaque to everything here: the store never needs to look
6//! inside it, so it does not depend on the crate that wrote it. What the
7//! store does need to compare, the credential id, the user handle and the
8//! signature counter, has a column of its own.
9//!
10//! Timestamps are [`crate::now`]'s format, so they compare as strings.
11
12use anyhow::Result;
13use rusqlite::{Connection, OptionalExtension, Row};
14
15use super::{Outcome, Store};
16
17/// Created alongside the other tables, every time the store opens.
18pub(super) const SCHEMA: &str = "
19    CREATE TABLE IF NOT EXISTS admin_credentials (
20        -- The WebAuthn credential id, base64url without padding.
21        id           TEXT PRIMARY KEY,
22        -- The WebAuthn user handle, a UUID. Every passkey has the same one:
23        -- there is one owner, and an authenticator given the same handle
24        -- twice for a site replaces its passkey rather than adding one.
25        user_handle  TEXT NOT NULL,
26        -- What the owner called it, such as 'iPhone'.
27        name         TEXT NOT NULL,
28        -- webauthn-rs's Passkey, as JSON: the public key and its flags.
29        passkey      TEXT NOT NULL,
30        -- The authenticator's signature counter at the last sign-in. Kept
31        -- beside the JSON so that checking it and moving it forward is one
32        -- conditional UPDATE, which two sign-ins at once cannot both pass.
33        sign_count   INTEGER NOT NULL DEFAULT 0,
34        created_at   TEXT NOT NULL,
35        last_used_at TEXT
36    );
37    CREATE TABLE IF NOT EXISTS admin_sessions (
38        -- SHA-256 of the cookie's value, lowercase hex. The value itself is
39        -- never stored, so a copy of the database signs nobody in.
40        token_sha256  TEXT PRIMARY KEY,
41        -- The passkey that signed in: removing it ends its sessions.
42        credential_id TEXT NOT NULL,
43        created_at    TEXT NOT NULL,
44        last_used_at  TEXT NOT NULL,
45        -- The absolute limit, however recently it was used.
46        expires_at    TEXT NOT NULL
47    );
48    CREATE TABLE IF NOT EXISTS admin_bootstrap (
49        -- One row at most: a new code replaces the last.
50        id          INTEGER PRIMARY KEY CHECK (id = 1),
51        -- SHA-256 of the code, lowercase hex, as `bootstrap_code` reads
52        -- it. The code itself is never stored.
53        code_sha256 TEXT NOT NULL,
54        created_at  TEXT NOT NULL,
55        -- Registering the first passkey with it must happen before this.
56        expires_at  TEXT NOT NULL
57    );
58";
59
60/// One of the owner's passkeys, as the store holds it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AdminCredential {
63    /// The credential id, base64url.
64    pub id: String,
65    /// The WebAuthn user handle.
66    pub user_handle: String,
67    /// What the owner called it.
68    pub name: String,
69    /// webauthn-rs's `Passkey`, as JSON.
70    pub passkey: String,
71    /// The signature counter at the last sign-in.
72    pub sign_count: u32,
73    /// When it was registered.
74    pub created_at: String,
75    /// When it last signed in, if ever.
76    pub last_used_at: Option<String>,
77}
78
79/// A passkey about to be stored.
80#[derive(Debug, Clone)]
81pub struct NewAdminCredential<'a> {
82    /// The credential id, base64url.
83    pub id: &'a str,
84    /// The WebAuthn user handle.
85    pub user_handle: &'a str,
86    /// What the owner called it.
87    pub name: &'a str,
88    /// webauthn-rs's `Passkey`, as JSON.
89    pub passkey: &'a str,
90    /// Its signature counter as registered.
91    pub sign_count: u32,
92    /// Now.
93    pub created_at: &'a str,
94}
95
96/// The bootstrap code a first passkey is being registered with: the hash
97/// of what was given, and the moment it is judged at.
98#[derive(Debug, Clone, Copy)]
99pub struct FirstPasskey<'a> {
100    /// SHA-256 of the code, as `bootstrap_code` reads it.
101    pub code_sha256: &'a str,
102    /// Now.
103    pub now: &'a str,
104}
105
106/// What storing a passkey came to.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum AddedCredential {
109    /// Stored.
110    Added,
111    /// Only the first passkey may be stored this way, and there is one
112    /// already.
113    NotFirst,
114    /// A passkey with that credential id is stored already.
115    Duplicate,
116    /// The bootstrap code is not the one outstanding, or has expired.
117    Code(BootstrapCode),
118}
119
120/// Whether a bootstrap code will register a first passkey.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum BootstrapCode {
123    /// It is the one outstanding, and has not expired.
124    Valid,
125    /// It was, but it has expired.
126    Expired,
127    /// It is not one this server issued, or was replaced or used already.
128    Wrong,
129}
130
131/// What removing a passkey came to.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RemovedCredential {
134    /// Removed, with every session it signed in.
135    Removed(AdminCredential),
136    /// It is the only one; removing it would lock the owner out.
137    Last,
138    /// No passkey has that id.
139    NotFound,
140}
141
142/// An admin session, as the store holds it.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct AdminSession {
145    /// The passkey that signed in.
146    pub credential_id: String,
147    /// When it signed in.
148    pub created_at: String,
149    /// When it was last used.
150    pub last_used_at: String,
151    /// When it ends, however recently it was used.
152    pub expires_at: String,
153}
154
155const CREDENTIAL_COLUMNS: &str =
156    "id, user_handle, name, passkey, sign_count, created_at, last_used_at";
157
158fn credential_from(r: &Row<'_>) -> rusqlite::Result<AdminCredential> {
159    Ok(AdminCredential {
160        id: r.get(0)?,
161        user_handle: r.get(1)?,
162        name: r.get(2)?,
163        passkey: r.get(3)?,
164        sign_count: r.get(4)?,
165        created_at: r.get(5)?,
166        last_used_at: r.get(6)?,
167    })
168}
169
170fn get_credential(conn: &Connection, id: &str) -> Result<Option<AdminCredential>> {
171    Ok(conn
172        .query_row(
173            &format!("SELECT {CREDENTIAL_COLUMNS} FROM admin_credentials WHERE id = ?1"),
174            (id,),
175            credential_from,
176        )
177        .optional()?)
178}
179
180fn bootstrap_code(conn: &Connection, code_sha256: &str, now: &str) -> Result<BootstrapCode> {
181    let expires_at: Option<String> = conn
182        .query_row(
183            "SELECT expires_at FROM admin_bootstrap WHERE code_sha256 = ?1",
184            (code_sha256,),
185            |r| r.get(0),
186        )
187        .optional()?;
188    Ok(match expires_at {
189        Some(expires_at) if expires_at.as_str() > now => BootstrapCode::Valid,
190        Some(_) => BootstrapCode::Expired,
191        None => BootstrapCode::Wrong,
192    })
193}
194
195fn set_bootstrap_code(
196    conn: &Connection,
197    code_sha256: &str,
198    now: &str,
199    expires_at: &str,
200) -> Result<()> {
201    conn.execute(
202        "INSERT OR REPLACE INTO admin_bootstrap (id, code_sha256, created_at, expires_at)
203         VALUES (1, ?1, ?2, ?3)",
204        (code_sha256, now, expires_at),
205    )?;
206    Ok(())
207}
208
209impl Store {
210    /// Whether the owner has registered any passkey.
211    pub fn has_admin_credentials(&self) -> Result<bool> {
212        let conn = self.lock();
213        let n: i64 = conn.query_row("SELECT COUNT(*) FROM admin_credentials", [], |r| r.get(0))?;
214        Ok(n > 0)
215    }
216
217    /// Stores a passkey, with the `passkey_add` leaf `build_leaf` makes, in
218    /// one transaction.
219    ///
220    /// With `first`, only while none is stored, and only with the bootstrap
221    /// code outstanding, which it uses up. All three are one transaction,
222    /// so two bootstraps at once cannot both be the first, nor one code
223    /// register two passkeys. It takes the write lock before anything is
224    /// read, as every audited write does: `reset-passkeys` writes from
225    /// another process.
226    pub fn add_admin_credential_audited(
227        &self,
228        c: &NewAdminCredential<'_>,
229        first: Option<FirstPasskey<'_>>,
230        build_leaf: impl FnOnce(u64, &str) -> Vec<u8>,
231    ) -> Result<AddedCredential> {
232        self.audited(
233            |tx, _| {
234                if get_credential(tx, c.id)?.is_some() {
235                    return Ok(Outcome::Refuse(AddedCredential::Duplicate));
236                }
237                if let Some(first) = first {
238                    let n: i64 =
239                        tx.query_row("SELECT COUNT(*) FROM admin_credentials", [], |r| r.get(0))?;
240                    if n > 0 {
241                        return Ok(Outcome::Refuse(AddedCredential::NotFirst));
242                    }
243                    match bootstrap_code(tx, first.code_sha256, first.now)? {
244                        BootstrapCode::Valid => {}
245                        refused => return Ok(Outcome::Refuse(AddedCredential::Code(refused))),
246                    }
247                    tx.execute("DELETE FROM admin_bootstrap", [])?;
248                }
249                tx.execute(
250                    "INSERT INTO admin_credentials
251                         (id, user_handle, name, passkey, sign_count, created_at)
252                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
253                    (
254                        c.id,
255                        c.user_handle,
256                        c.name,
257                        c.passkey,
258                        c.sign_count,
259                        c.created_at,
260                    ),
261                )?;
262                Ok(Outcome::Commit(AddedCredential::Added))
263            },
264            |seq, at, _| build_leaf(seq, at),
265        )
266    }
267
268    /// Whether `code_sha256` is the bootstrap code outstanding at `now`.
269    pub fn check_bootstrap_code(&self, code_sha256: &str, now: &str) -> Result<BootstrapCode> {
270        bootstrap_code(&self.lock(), code_sha256, now)
271    }
272
273    /// Makes `code_sha256` the one bootstrap code, until `expires_at`,
274    /// replacing any other, with the `bootstrap_code` leaf `build_leaf`
275    /// makes.
276    pub fn set_bootstrap_code_audited(
277        &self,
278        code_sha256: &str,
279        now: &str,
280        expires_at: &str,
281        build_leaf: impl FnOnce(u64, &str) -> Vec<u8>,
282    ) -> Result<()> {
283        self.audited(
284            |tx, _| {
285                set_bootstrap_code(tx, code_sha256, now, expires_at)?;
286                Ok(Outcome::Commit(()))
287            },
288            |seq, at, ()| build_leaf(seq, at),
289        )
290    }
291
292    /// One passkey.
293    pub fn admin_credential(&self, id: &str) -> Result<Option<AdminCredential>> {
294        get_credential(&self.lock(), id)
295    }
296
297    /// Every passkey, oldest first.
298    pub fn admin_credentials(&self) -> Result<Vec<AdminCredential>> {
299        let conn = self.lock();
300        let mut stmt = conn.prepare(&format!(
301            "SELECT {CREDENTIAL_COLUMNS} FROM admin_credentials ORDER BY created_at, id"
302        ))?;
303        let rows = stmt.query_map([], credential_from)?;
304        Ok(rows.collect::<rusqlite::Result<_>>()?)
305    }
306
307    /// Records a sign-in, if its signature counter moved forward.
308    ///
309    /// WebAuthn ยง7.2 step 22: when either the stored counter or the new one
310    /// is nonzero, the new one must be greater, or two copies of the
311    /// credential's key may exist. Both zero is what a synced passkey
312    /// reports every time, and is accepted. The check and the update are one
313    /// statement, so two sign-ins racing with the same counter cannot both
314    /// pass. Answers whether it was recorded; `false` is a refusal.
315    pub fn record_admin_sign_in(
316        &self,
317        id: &str,
318        sign_count: u32,
319        passkey: &str,
320        now: &str,
321    ) -> Result<bool> {
322        let conn = self.lock();
323        let updated = conn.execute(
324            "UPDATE admin_credentials
325             SET sign_count = ?2, passkey = ?3, last_used_at = ?4
326             WHERE id = ?1 AND (sign_count < ?2 OR (sign_count = 0 AND ?2 = 0))",
327            (id, sign_count, passkey, now),
328        )?;
329        Ok(updated == 1)
330    }
331
332    /// Removes a passkey and every session it signed in, unless it is the
333    /// last one, with the `passkey_remove` leaf `build_leaf` makes from it.
334    pub fn remove_admin_credential_audited(
335        &self,
336        id: &str,
337        build_leaf: impl FnOnce(u64, &str, &AdminCredential) -> Vec<u8>,
338    ) -> Result<RemovedCredential> {
339        self.audited(
340            |tx, _| {
341                let Some(credential) = get_credential(tx, id)? else {
342                    return Ok(Outcome::Refuse(RemovedCredential::NotFound));
343                };
344                let n: i64 =
345                    tx.query_row("SELECT COUNT(*) FROM admin_credentials", [], |r| r.get(0))?;
346                if n <= 1 {
347                    return Ok(Outcome::Refuse(RemovedCredential::Last));
348                }
349                tx.execute("DELETE FROM admin_credentials WHERE id = ?1", (id,))?;
350                tx.execute("DELETE FROM admin_sessions WHERE credential_id = ?1", (id,))?;
351                Ok(Outcome::Commit(RemovedCredential::Removed(credential)))
352            },
353            |seq, at, removed| match removed {
354                RemovedCredential::Removed(credential) => build_leaf(seq, at, credential),
355                _ => unreachable!("a leaf only for a removal"),
356            },
357        )
358    }
359
360    /// Removes every passkey and every session, and makes `code_sha256`
361    /// the bootstrap code until `expires_at`: what `recall-server
362    /// reset-passkeys` does, for an owner who has lost them all. One
363    /// transaction with the `passkey_reset` leaf `build_leaf` makes from how
364    /// many passkeys went, which it answers, and it creates the tables
365    /// first if this database has never been opened by a server that has
366    /// them.
367    ///
368    /// Run from another process than the server's, on the same file: the
369    /// leaf takes the next `seq` the table has, and a running server reads
370    /// it into its tree before its own next append (see
371    /// [`Store::audited_each`]).
372    pub fn reset_admin_credentials_audited(
373        &self,
374        code_sha256: &str,
375        now: &str,
376        expires_at: &str,
377        build_leaf: impl FnOnce(u64, &str, usize) -> Vec<u8>,
378    ) -> Result<usize> {
379        self.lock().execute_batch(super::audit::SCHEMA)?;
380        self.audited(
381            |tx, _| {
382                tx.execute_batch(SCHEMA)?;
383                let n = tx.execute("DELETE FROM admin_credentials", [])?;
384                tx.execute("DELETE FROM admin_sessions", [])?;
385                set_bootstrap_code(tx, code_sha256, now, expires_at)?;
386                Ok(Outcome::Commit(n))
387            },
388            |seq, at, n| build_leaf(seq, at, *n),
389        )
390    }
391
392    /// Stores a new session, only if the passkey that signed it in is still
393    /// there: a sign-in that finishes as its passkey is removed must not
394    /// outlive it.
395    pub fn create_admin_session(
396        &self,
397        token_sha256: &str,
398        credential_id: &str,
399        now: &str,
400        expires_at: &str,
401    ) -> Result<bool> {
402        let conn = self.lock();
403        let inserted = conn.execute(
404            "INSERT INTO admin_sessions
405                 (token_sha256, credential_id, created_at, last_used_at, expires_at)
406             SELECT ?1, ?2, ?3, ?3, ?4
407             WHERE EXISTS (SELECT 1 FROM admin_credentials WHERE id = ?2)",
408            (token_sha256, credential_id, now, expires_at),
409        )?;
410        Ok(inserted == 1)
411    }
412
413    /// One session, by the hash of its token.
414    pub fn admin_session(&self, token_sha256: &str) -> Result<Option<AdminSession>> {
415        let conn = self.lock();
416        Ok(conn
417            .query_row(
418                "SELECT credential_id, created_at, last_used_at, expires_at
419                 FROM admin_sessions WHERE token_sha256 = ?1",
420                (token_sha256,),
421                |r| {
422                    Ok(AdminSession {
423                        credential_id: r.get(0)?,
424                        created_at: r.get(1)?,
425                        last_used_at: r.get(2)?,
426                        expires_at: r.get(3)?,
427                    })
428                },
429            )
430            .optional()?)
431    }
432
433    /// Records that a session was used, which is what keeps it from going
434    /// idle.
435    pub fn touch_admin_session(&self, token_sha256: &str, now: &str) -> Result<()> {
436        let conn = self.lock();
437        conn.execute(
438            "UPDATE admin_sessions SET last_used_at = ?2 WHERE token_sha256 = ?1",
439            (token_sha256, now),
440        )?;
441        Ok(())
442    }
443
444    /// Ends every session but `keep`, with the `sessions_end` leaf
445    /// `build_leaf` makes from how many ended, which it answers. Ending none
446    /// changes nothing and appends nothing.
447    pub fn delete_other_admin_sessions_audited(
448        &self,
449        keep: &str,
450        build_leaf: impl FnOnce(u64, &str, usize) -> Vec<u8>,
451    ) -> Result<usize> {
452        self.audited(
453            |tx, _| {
454                let n = tx.execute(
455                    "DELETE FROM admin_sessions WHERE token_sha256 != ?1",
456                    (keep,),
457                )?;
458                Ok(if n == 0 {
459                    Outcome::Refuse(0)
460                } else {
461                    Outcome::Commit(n)
462                })
463            },
464            |seq, at, n| build_leaf(seq, at, *n),
465        )
466    }
467
468    /// Ends a session.
469    pub fn delete_admin_session(&self, token_sha256: &str) -> Result<()> {
470        let conn = self.lock();
471        conn.execute(
472            "DELETE FROM admin_sessions WHERE token_sha256 = ?1",
473            (token_sha256,),
474        )?;
475        Ok(())
476    }
477
478    /// Removes sessions past their absolute limit, or idle since before
479    /// `idle_before`. Answers how many went.
480    pub fn sweep_admin_sessions(&self, now: &str, idle_before: &str) -> Result<usize> {
481        let conn = self.lock();
482        Ok(conn.execute(
483            "DELETE FROM admin_sessions WHERE expires_at <= ?1 OR last_used_at < ?2",
484            (now, idle_before),
485        )?)
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::store::test_leaf;
493
494    /// The audited writes, with a leaf these tests do not look at, under
495    /// the names the tests read best with.
496    impl Store {
497        fn add_admin_credential(
498            &self,
499            c: &NewAdminCredential<'_>,
500            first: Option<FirstPasskey<'_>>,
501        ) -> Result<AddedCredential> {
502            self.add_admin_credential_audited(c, first, test_leaf)
503        }
504
505        fn set_bootstrap_code(&self, code_sha256: &str, now: &str, expires_at: &str) -> Result<()> {
506            self.set_bootstrap_code_audited(code_sha256, now, expires_at, test_leaf)
507        }
508
509        fn remove_admin_credential(&self, id: &str) -> Result<RemovedCredential> {
510            self.remove_admin_credential_audited(id, |seq, at, _| test_leaf(seq, at))
511        }
512
513        fn reset_admin_credentials(
514            &self,
515            code_sha256: &str,
516            now: &str,
517            expires_at: &str,
518        ) -> Result<usize> {
519            self.reset_admin_credentials_audited(code_sha256, now, expires_at, |seq, at, _| {
520                test_leaf(seq, at)
521            })
522        }
523
524        fn delete_other_admin_sessions(&self, keep: &str) -> Result<usize> {
525            self.delete_other_admin_sessions_audited(keep, |seq, at, _| test_leaf(seq, at))
526        }
527    }
528
529    fn credential<'a>(id: &'a str, created_at: &'a str) -> NewAdminCredential<'a> {
530        NewAdminCredential {
531            id,
532            user_handle: "u",
533            name: "phone",
534            passkey: "{}",
535            sign_count: 0,
536            created_at,
537        }
538    }
539
540    const T0: &str = "2026-09-23T10:00:00.000Z";
541    const T1: &str = "2026-09-23T11:00:00.000Z";
542    const T2: &str = "2026-09-23T12:00:00.000Z";
543
544    fn first(code_sha256: &str) -> Option<FirstPasskey<'_>> {
545        Some(FirstPasskey {
546            code_sha256,
547            now: T0,
548        })
549    }
550
551    /// A store with the bootstrap code "code" outstanding until T1.
552    fn bootstrapping() -> Store {
553        let st = Store::open_in_memory().unwrap();
554        st.set_bootstrap_code("code", T0, T1).unwrap();
555        st
556    }
557
558    #[test]
559    fn only_the_first_passkey_can_be_added_as_the_first() {
560        let st = bootstrapping();
561        assert!(!st.has_admin_credentials().unwrap());
562        assert_eq!(
563            st.add_admin_credential(&credential("a", T0), first("code"))
564                .unwrap(),
565            AddedCredential::Added
566        );
567        assert!(st.has_admin_credentials().unwrap());
568        st.set_bootstrap_code("again", T0, T1).unwrap();
569        assert_eq!(
570            st.add_admin_credential(&credential("b", T0), first("again"))
571                .unwrap(),
572            AddedCredential::NotFirst
573        );
574        assert_eq!(
575            st.add_admin_credential(&credential("a", T0), None).unwrap(),
576            AddedCredential::Duplicate
577        );
578        assert_eq!(
579            st.add_admin_credential(&credential("b", T1), None).unwrap(),
580            AddedCredential::Added
581        );
582        let ids: Vec<String> = st
583            .admin_credentials()
584            .unwrap()
585            .into_iter()
586            .map(|c| c.id)
587            .collect();
588        assert_eq!(ids, ["a", "b"]);
589    }
590
591    /// The counter rule, including the case webauthn-rs's own check cannot
592    /// see: two sign-ins verified against the same stored counter, of which
593    /// only the first may be recorded.
594    /// Finding 3: the first passkey needs the code outstanding, before it
595    /// expires, and uses it up.
596    #[test]
597    fn the_first_passkey_needs_the_bootstrap_code_once() {
598        let st = bootstrapping();
599        assert_eq!(
600            st.check_bootstrap_code("code", T0).unwrap(),
601            BootstrapCode::Valid
602        );
603        assert_eq!(
604            st.check_bootstrap_code("code", T1).unwrap(),
605            BootstrapCode::Expired
606        );
607        assert_eq!(
608            st.check_bootstrap_code("other", T0).unwrap(),
609            BootstrapCode::Wrong
610        );
611        assert_eq!(
612            st.add_admin_credential(&credential("a", T0), first("other"))
613                .unwrap(),
614            AddedCredential::Code(BootstrapCode::Wrong)
615        );
616        let late = Some(FirstPasskey {
617            code_sha256: "code",
618            now: T1,
619        });
620        assert_eq!(
621            st.add_admin_credential(&credential("a", T0), late).unwrap(),
622            AddedCredential::Code(BootstrapCode::Expired)
623        );
624        assert!(!st.has_admin_credentials().unwrap(), "nothing stored");
625        assert_eq!(
626            st.add_admin_credential(&credential("a", T0), first("code"))
627                .unwrap(),
628            AddedCredential::Added
629        );
630        assert_eq!(
631            st.check_bootstrap_code("code", T0).unwrap(),
632            BootstrapCode::Wrong,
633            "used up"
634        );
635
636        // Reset clears the passkey and makes a new code the only one.
637        assert_eq!(st.reset_admin_credentials("new", T0, T2).unwrap(), 1);
638        assert_eq!(
639            st.check_bootstrap_code("new", T1).unwrap(),
640            BootstrapCode::Valid
641        );
642        st.set_bootstrap_code("newer", T0, T2).unwrap();
643        assert_eq!(
644            st.check_bootstrap_code("new", T1).unwrap(),
645            BootstrapCode::Wrong,
646            "replaced"
647        );
648    }
649
650    #[test]
651    fn a_sign_in_is_recorded_only_if_its_counter_moved_forward() {
652        let st = bootstrapping();
653        st.add_admin_credential(&credential("a", T0), first("code"))
654            .unwrap();
655        // Both zero: a synced passkey, every time.
656        assert!(st.record_admin_sign_in("a", 0, "{}", T1).unwrap());
657        assert!(st.record_admin_sign_in("a", 0, "{}", T1).unwrap());
658        assert!(st.record_admin_sign_in("a", 5, "{}", T1).unwrap());
659        assert!(!st.record_admin_sign_in("a", 5, "{}", T1).unwrap(), "equal");
660        assert!(!st.record_admin_sign_in("a", 4, "{}", T1).unwrap(), "lower");
661        assert!(!st.record_admin_sign_in("a", 0, "{}", T1).unwrap(), "reset");
662        assert!(st.record_admin_sign_in("a", 6, "{}", T1).unwrap());
663        let stored = st.admin_credential("a").unwrap().unwrap();
664        assert_eq!(stored.sign_count, 6);
665        assert_eq!(stored.last_used_at.as_deref(), Some(T1));
666        assert!(!st.record_admin_sign_in("nobody", 9, "{}", T1).unwrap());
667    }
668
669    #[test]
670    fn the_last_passkey_cannot_be_removed_and_removing_one_ends_its_sessions() {
671        let st = bootstrapping();
672        st.add_admin_credential(&credential("a", T0), first("code"))
673            .unwrap();
674        assert_eq!(
675            st.remove_admin_credential("a").unwrap(),
676            RemovedCredential::Last
677        );
678        st.add_admin_credential(&credential("b", T1), None).unwrap();
679        assert!(st.create_admin_session("s1", "a", T1, T1).unwrap());
680        assert!(st.create_admin_session("s2", "b", T1, T1).unwrap());
681        assert!(matches!(
682            st.remove_admin_credential("a").unwrap(),
683            RemovedCredential::Removed(c) if c.id == "a"
684        ));
685        assert_eq!(st.admin_session("s1").unwrap(), None);
686        assert!(st.admin_session("s2").unwrap().is_some());
687        assert_eq!(
688            st.remove_admin_credential("a").unwrap(),
689            RemovedCredential::NotFound
690        );
691        assert!(
692            !st.create_admin_session("s3", "a", T1, T1).unwrap(),
693            "a removed passkey signs nobody in"
694        );
695    }
696
697    #[test]
698    fn signing_out_the_others_keeps_this_session() {
699        let st = bootstrapping();
700        st.add_admin_credential(&credential("a", T0), first("code"))
701            .unwrap();
702        for s in ["mine", "theirs", "old"] {
703            st.create_admin_session(s, "a", T0, T2).unwrap();
704        }
705        assert_eq!(st.delete_other_admin_sessions("mine").unwrap(), 2);
706        assert!(st.admin_session("mine").unwrap().is_some());
707        assert_eq!(st.admin_session("theirs").unwrap(), None);
708        assert_eq!(st.delete_other_admin_sessions("mine").unwrap(), 0);
709    }
710
711    #[test]
712    fn sessions_are_swept_when_idle_or_past_their_limit() {
713        let st = bootstrapping();
714        st.add_admin_credential(&credential("a", T0), first("code"))
715            .unwrap();
716        st.create_admin_session("idle", "a", T0, "2026-10-23T10:00:00.000Z")
717            .unwrap();
718        st.create_admin_session("old", "a", T1, T1).unwrap();
719        st.create_admin_session("fine", "a", T1, "2026-10-23T10:00:00.000Z")
720            .unwrap();
721        assert_eq!(
722            st.sweep_admin_sessions(T1, "2026-09-23T10:30:00.000Z")
723                .unwrap(),
724            2
725        );
726        assert!(st.admin_session("fine").unwrap().is_some());
727        assert_eq!(st.reset_admin_credentials("new", T0, T2).unwrap(), 1);
728        assert_eq!(st.admin_session("fine").unwrap(), None);
729        assert!(!st.has_admin_credentials().unwrap());
730    }
731}