1use anyhow::Result;
13use rusqlite::{Connection, OptionalExtension, Row};
14
15use super::{Outcome, Store};
16
17pub(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#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AdminCredential {
63 pub id: String,
65 pub user_handle: String,
67 pub name: String,
69 pub passkey: String,
71 pub sign_count: u32,
73 pub created_at: String,
75 pub last_used_at: Option<String>,
77}
78
79#[derive(Debug, Clone)]
81pub struct NewAdminCredential<'a> {
82 pub id: &'a str,
84 pub user_handle: &'a str,
86 pub name: &'a str,
88 pub passkey: &'a str,
90 pub sign_count: u32,
92 pub created_at: &'a str,
94}
95
96#[derive(Debug, Clone, Copy)]
99pub struct FirstPasskey<'a> {
100 pub code_sha256: &'a str,
102 pub now: &'a str,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum AddedCredential {
109 Added,
111 NotFirst,
114 Duplicate,
116 Code(BootstrapCode),
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum BootstrapCode {
123 Valid,
125 Expired,
127 Wrong,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum RemovedCredential {
134 Removed(AdminCredential),
136 Last,
138 NotFound,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct AdminSession {
145 pub credential_id: String,
147 pub created_at: String,
149 pub last_used_at: String,
151 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 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 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 pub fn check_bootstrap_code(&self, code_sha256: &str, now: &str) -> Result<BootstrapCode> {
270 bootstrap_code(&self.lock(), code_sha256, now)
271 }
272
273 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 pub fn admin_credential(&self, id: &str) -> Result<Option<AdminCredential>> {
294 get_credential(&self.lock(), id)
295 }
296
297 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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}