1use 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
29pub const CEREMONY_TTL_SECS: i64 = 5 * 60;
32
33pub const MAX_LOGIN_CEREMONIES: usize = 1024;
38pub const MAX_SESSION_CEREMONIES: usize = 1024;
39
40const RP_NAME: &str = "hennery";
42
43pub 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
67pub 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
76pub enum Ceremony {
79 Register {
81 session_id: String,
82 label: String,
83 state: PasskeyRegistration,
84 },
85 Login { state: PasskeyAuthentication },
87 StepUp {
89 session_id: String,
90 state: PasskeyAuthentication,
91 },
92}
93
94impl Ceremony {
95 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 seq: u64,
111}
112
113#[derive(Default)]
114struct Table {
115 live: HashMap<String, Live>,
116 seq: u64,
117}
118
119pub struct Ceremonies {
121 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 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 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 pub fn clear(&self) {
194 self.table().live.clear();
195 }
196
197 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
207pub const MAX_LABEL_CHARS: usize = 64;
209
210const USER_NAME: &str = "owner";
213const USER_DISPLAY_NAME: &str = "hennery owner";
214
215#[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#[derive(Debug, Clone, PartialEq)]
226pub enum Start {
227 Begun {
230 ceremony_id: String,
231 options: serde_json::Value,
232 },
233 Unavailable,
236 NoPasskeys,
238 Invalid(String),
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Refused {
245 Ceremony,
248 Credential(String),
251 AlreadyRegistered,
254 Passkey,
257 CounterWentBack,
261 Unavailable,
263}
264
265fn 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
277fn stored_id(id: &[u8]) -> String {
279 hex::encode(id)
280}
281
282pub fn counter_moves_on(stored: u32, sent: u32) -> bool {
287 sent > stored || (stored == 0 && sent == 0)
288}
289
290fn 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 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 fn relying_party(&self) -> Option<Webauthn> {
331 self.public_url().as_ref().and_then(relying_party)
332 }
333
334 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 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 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 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 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 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 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 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 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 pub fn start_passkey_login(&self, now: i64) -> Result<Start> {
508 self.begin_authentication(|state| Ceremony::Login { state }, now)
509 }
510
511 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 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 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 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 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 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
602fn 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 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 #[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 #[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(®istration, 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 #[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}