1use std::time::Duration;
8
9use anyhow::Result;
10use recall_wire::signature::{fingerprint, parse_public_key};
11use recall_wire::{Authkey, Device};
12use rusqlite::{Connection, OptionalExtension, Row};
13use time::OffsetDateTime;
14use unicode_normalization::UnicodeNormalization;
15
16use super::Store;
17use crate::{format_timestamp, parse_timestamp};
18
19pub(super) const SCHEMA: &str = "
22 CREATE TABLE IF NOT EXISTS devices (
23 id TEXT PRIMARY KEY,
24 -- Always the one owner. Reserved now because adding it later would
25 -- be a migration: see Part 3 of docs/design/handshake.md.
26 owner_id TEXT NOT NULL DEFAULT 'owner',
27 name TEXT NOT NULL,
28 public_key TEXT NOT NULL,
29 scope TEXT NOT NULL CHECK (scope IN ('sync', 'admin')),
30 agent TEXT NOT NULL DEFAULT '',
31 ephemeral INTEGER NOT NULL DEFAULT 0,
32 authkey_id TEXT,
33 created_at TEXT NOT NULL,
34 last_seen TEXT,
35 revoked_at TEXT
36 );
37 CREATE TABLE IF NOT EXISTS device_enrollments (
38 enrollment_id TEXT PRIMARY KEY,
39 user_code TEXT NOT NULL,
40 name TEXT NOT NULL,
41 public_key TEXT NOT NULL,
42 agent TEXT NOT NULL DEFAULT '',
43 created_at TEXT NOT NULL,
44 expires_at TEXT NOT NULL,
45 -- The device approving it made; NULL while it waits.
46 device_id TEXT,
47 denied INTEGER NOT NULL DEFAULT 0,
48 last_poll_at TEXT,
49 -- The address it came from, as the rate limiter keys it: what caps
50 -- how many one address may have waiting.
51 client_ip TEXT NOT NULL DEFAULT ''
52 );
53 CREATE INDEX IF NOT EXISTS device_enrollments_user_code
54 ON device_enrollments (user_code);
55 CREATE TABLE IF NOT EXISTS authkeys (
56 id TEXT PRIMARY KEY,
57 -- The key itself is never stored: it is shown once, and a copy of
58 -- the database is not a copy of it.
59 key_sha256 TEXT NOT NULL UNIQUE,
60 tag TEXT NOT NULL DEFAULT '',
61 ephemeral INTEGER NOT NULL DEFAULT 0,
62 -- The most unrevoked devices it may have enrolled at once. Always
63 -- stored; NULL would be read as the default, not as no limit.
64 max_devices INTEGER,
65 created_at TEXT NOT NULL,
66 expires_at TEXT NOT NULL,
67 revoked_at TEXT
68 );
69";
70
71const DEVICE_COLUMNS: &str = "id, name, scope, ephemeral, agent, public_key, authkey_id, \
72 created_at, last_seen, revoked_at";
73
74const AUTHKEY_COLUMNS: &str = "id, tag, ephemeral, max_devices, created_at, expires_at, revoked_at";
75
76fn device_from(r: &Row<'_>) -> rusqlite::Result<Device> {
77 let public_key: String = r.get(5)?;
78 Ok(Device {
79 id: r.get(0)?,
80 name: r.get(1)?,
81 scope: r.get(2)?,
82 ephemeral: r.get::<_, i64>(3)? != 0,
83 agent: r.get(4)?,
84 fingerprint: parse_public_key(&public_key)
86 .map(|k| fingerprint(&k))
87 .unwrap_or_default(),
88 public_key,
89 authkey_id: r.get(6)?,
90 created_at: r.get(7)?,
91 last_seen: r.get(8)?,
92 revoked_at: r.get(9)?,
93 })
94}
95
96fn authkey_from(r: &Row<'_>) -> rusqlite::Result<Authkey> {
97 Ok(Authkey {
98 id: r.get(0)?,
99 tag: r.get(1)?,
100 ephemeral: r.get::<_, i64>(2)? != 0,
101 max_devices: r.get(3)?,
102 created_at: r.get(4)?,
103 expires_at: r.get(5)?,
104 revoked_at: r.get(6)?,
105 })
106}
107
108pub fn plain_name(name: &str) -> String {
112 name.trim().nfc().collect()
113}
114
115fn name_keys(name: &str) -> [String; 2] {
128 let plain: String = name.trim().nfkc().collect();
129 let skeleton = |s: String| unicode_security::skeleton(&s).collect::<String>();
130 [
131 skeleton(plain.to_lowercase()),
132 skeleton(plain.to_uppercase()),
133 ]
134}
135
136fn name_taken(conn: &Connection, name: &str) -> Result<bool> {
140 let [lower, upper] = name_keys(name);
141 let mut stmt = conn.prepare("SELECT name FROM devices WHERE revoked_at IS NULL")?;
142 let mut rows = stmt.query([])?;
143 while let Some(row) = rows.next()? {
144 let [their_lower, their_upper] = name_keys(&row.get::<_, String>(0)?);
145 if lower == their_lower || upper == their_upper {
146 return Ok(true);
147 }
148 }
149 Ok(false)
150}
151
152fn get_device(conn: &Connection, id: &str) -> Result<Option<Device>> {
153 Ok(conn
154 .query_row(
155 &format!("SELECT {DEVICE_COLUMNS} FROM devices WHERE id = ?1"),
156 (id,),
157 device_from,
158 )
159 .optional()?)
160}
161
162fn get_authkey(conn: &Connection, column: &str, value: &str) -> Result<Option<Authkey>> {
163 Ok(conn
164 .query_row(
165 &format!("SELECT {AUTHKEY_COLUMNS} FROM authkeys WHERE {column} = ?1"),
166 (value,),
167 authkey_from,
168 )
169 .optional()?)
170}
171
172#[derive(Debug, Clone)]
174pub struct NewDevice<'a> {
175 pub id: &'a str,
177 pub name: &'a str,
179 pub public_key: &'a str,
181 pub scope: &'a str,
183 pub agent: &'a str,
185 pub ephemeral: bool,
187 pub authkey_id: Option<&'a str>,
189 pub created_at: &'a str,
191}
192
193#[derive(Debug, Clone)]
195pub struct NewEnrollment<'a> {
196 pub enrollment_id: &'a str,
198 pub user_code: &'a str,
200 pub name: &'a str,
202 pub public_key: &'a str,
204 pub agent: &'a str,
206 pub created_at: &'a str,
208 pub expires_at: &'a str,
210 pub client_ip: &'a str,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Created {
217 Created,
219 CodeTaken,
221 Full,
223 AddressFull,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum Inserted {
230 Done(Box<Device>),
232 NameTaken,
234 KeyFull,
236}
237
238#[derive(Debug, Clone)]
240pub struct NewAuthkey<'a> {
241 pub id: &'a str,
243 pub key_sha256: &'a str,
245 pub tag: &'a str,
247 pub ephemeral: bool,
249 pub max_devices: Option<u32>,
251 pub created_at: &'a str,
253 pub expires_at: &'a str,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Poll {
261 Approved {
263 device_id: String,
265 scope: String,
267 },
268 Pending,
270 SlowDown,
272 Expired,
274 Denied,
276 Unknown,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum Decision<T> {
283 Done(T),
285 NotFound,
287 Expired,
289 AlreadyDecided,
291 KeyMismatch,
294 NameTaken(String),
296}
297
298type Pending = (String, String, String, String, String, bool);
301
302#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct Waiting {
305 pub name: String,
307 pub public_key: String,
309 pub agent: String,
311 pub expires_at: String,
313}
314
315fn pending_by_code(conn: &Connection, user_code: &str) -> Result<Option<Pending>> {
316 Ok(conn
317 .query_row(
318 "SELECT enrollment_id, name, public_key, agent, expires_at,
319 device_id IS NOT NULL OR denied != 0
320 FROM device_enrollments WHERE user_code = ?1
321 ORDER BY created_at DESC LIMIT 1",
322 (user_code,),
323 |r| {
324 Ok((
325 r.get(0)?,
326 r.get(1)?,
327 r.get(2)?,
328 r.get(3)?,
329 r.get(4)?,
330 r.get::<_, bool>(5)?,
331 ))
332 },
333 )
334 .optional()?)
335}
336
337impl Store {
338 pub fn create_enrollment(
342 &self,
343 e: &NewEnrollment<'_>,
344 max_pending: usize,
345 max_per_address: usize,
346 ) -> Result<Created> {
347 let conn = self.lock();
348 let waiting = "expires_at > ?1 AND device_id IS NULL AND denied = 0";
352 let count: i64 = conn.query_row(
353 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting}"),
354 (e.created_at,),
355 |r| r.get(0),
356 )?;
357 if count as usize >= max_pending {
358 return Ok(Created::Full);
359 }
360 let from_here: i64 = conn.query_row(
361 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND client_ip = ?2"),
362 (e.created_at, e.client_ip),
363 |r| r.get(0),
364 )?;
365 if from_here as usize >= max_per_address {
366 return Ok(Created::AddressFull);
367 }
368 let taken: i64 = conn.query_row(
369 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND user_code = ?2"),
370 (e.created_at, e.user_code),
371 |r| r.get(0),
372 )?;
373 if taken > 0 {
374 return Ok(Created::CodeTaken);
375 }
376 conn.execute(
377 "INSERT INTO device_enrollments
378 (enrollment_id, user_code, name, public_key, agent, created_at, expires_at,
379 client_ip)
380 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
381 (
382 e.enrollment_id,
383 e.user_code,
384 e.name,
385 e.public_key,
386 e.agent,
387 e.created_at,
388 e.expires_at,
389 e.client_ip,
390 ),
391 )?;
392 Ok(Created::Created)
393 }
394
395 pub fn poll_enrollment(
402 &self,
403 enrollment_id: &str,
404 now: OffsetDateTime,
405 interval: Duration,
406 ) -> Result<Poll> {
407 let conn = self.lock();
408 let row = conn
409 .query_row(
410 "SELECT e.expires_at, e.denied, e.last_poll_at, e.device_id, d.scope, d.revoked_at
411 FROM device_enrollments e LEFT JOIN devices d ON d.id = e.device_id
412 WHERE e.enrollment_id = ?1",
413 (enrollment_id,),
414 |r| {
415 Ok((
416 r.get::<_, String>(0)?,
417 r.get::<_, i64>(1)? != 0,
418 r.get::<_, Option<String>>(2)?,
419 r.get::<_, Option<String>>(3)?,
420 r.get::<_, Option<String>>(4)?,
421 r.get::<_, Option<String>>(5)?,
422 ))
423 },
424 )
425 .optional()?;
426 let Some((expires_at, denied, last_poll_at, device_id, scope, revoked_at)) = row else {
427 return Ok(Poll::Unknown);
428 };
429 if denied {
430 return Ok(Poll::Denied);
431 }
432 if let Some(device_id) = device_id {
435 return Ok(match (scope, revoked_at) {
436 (Some(scope), None) => Poll::Approved { device_id, scope },
437 _ => Poll::Denied,
440 });
441 }
442 let now_text = format_timestamp(now);
443 if expires_at <= now_text {
444 return Ok(Poll::Expired);
445 }
446 let too_soon = last_poll_at
447 .as_deref()
448 .and_then(parse_timestamp)
449 .is_some_and(|last| {
450 let min = interval.saturating_sub(Duration::from_secs(1));
451 now - last < min
452 });
453 conn.execute(
454 "UPDATE device_enrollments SET last_poll_at = ?1 WHERE enrollment_id = ?2",
455 (&now_text, enrollment_id),
456 )?;
457 Ok(if too_soon {
458 Poll::SlowDown
459 } else {
460 Poll::Pending
461 })
462 }
463
464 pub fn approve_enrollment(
468 &self,
469 user_code: &str,
470 device_id: &str,
471 scope: &str,
472 now: &str,
473 expected_fingerprint: Option<&str>,
474 ) -> Result<Decision<Device>> {
475 let mut conn = self.lock();
476 let Some((enrollment_id, name, public_key, agent, expires_at, decided)) =
477 pending_by_code(&conn, user_code)?
478 else {
479 return Ok(Decision::NotFound);
480 };
481 if decided {
482 return Ok(Decision::AlreadyDecided);
483 }
484 if expires_at.as_str() <= now {
485 return Ok(Decision::Expired);
486 }
487 if let Some(expected) = expected_fingerprint {
488 let actual = parse_public_key(&public_key)
489 .map(|k| fingerprint(&k))
490 .unwrap_or_default();
491 if expected.trim() != actual {
492 return Ok(Decision::KeyMismatch);
493 }
494 }
495 if name_taken(&conn, &name)? {
498 return Ok(Decision::NameTaken(name));
499 }
500 let tx = conn.transaction()?;
503 insert_device(
504 &tx,
505 &NewDevice {
506 id: device_id,
507 name: &name,
508 public_key: &public_key,
509 scope,
510 agent: &agent,
511 ephemeral: false,
512 authkey_id: None,
513 created_at: now,
514 },
515 )?;
516 tx.execute(
517 "UPDATE device_enrollments SET device_id = ?1 WHERE enrollment_id = ?2",
518 (device_id, &enrollment_id),
519 )?;
520 tx.commit()?;
521 let device = get_device(&conn, device_id)?.expect("inserted above");
522 Ok(Decision::Done(device))
523 }
524
525 pub fn deny_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<String>> {
528 let conn = self.lock();
529 let Some((enrollment_id, name, _, _, expires_at, decided)) =
530 pending_by_code(&conn, user_code)?
531 else {
532 return Ok(Decision::NotFound);
533 };
534 if decided {
535 return Ok(Decision::AlreadyDecided);
536 }
537 if expires_at.as_str() <= now {
538 return Ok(Decision::Expired);
539 }
540 conn.execute(
541 "UPDATE device_enrollments SET denied = 1 WHERE enrollment_id = ?1",
542 (&enrollment_id,),
543 )?;
544 Ok(Decision::Done(name))
545 }
546
547 pub fn pending_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<Waiting>> {
551 let conn = self.lock();
552 let Some((_, name, public_key, agent, expires_at, decided)) =
553 pending_by_code(&conn, user_code)?
554 else {
555 return Ok(Decision::NotFound);
556 };
557 if decided {
558 return Ok(Decision::AlreadyDecided);
559 }
560 if expires_at.as_str() <= now {
561 return Ok(Decision::Expired);
562 }
563 Ok(Decision::Done(Waiting {
564 name,
565 public_key,
566 agent,
567 expires_at,
568 }))
569 }
570
571 pub fn insert_device(&self, d: &NewDevice<'_>, max_for_key: Option<u32>) -> Result<Inserted> {
575 let conn = self.lock();
576 if name_taken(&conn, d.name)? {
577 return Ok(Inserted::NameTaken);
578 }
579 if let (Some(max), Some(key)) = (max_for_key, d.authkey_id) {
580 let live: i64 = conn.query_row(
581 "SELECT COUNT(*) FROM devices WHERE authkey_id = ?1 AND revoked_at IS NULL",
582 (key,),
583 |r| r.get(0),
584 )?;
585 if live >= i64::from(max) {
586 return Ok(Inserted::KeyFull);
587 }
588 }
589 insert_device(&conn, d)?;
590 Ok(Inserted::Done(Box::new(
591 get_device(&conn, d.id)?.expect("inserted above"),
592 )))
593 }
594
595 pub fn device(&self, id: &str) -> Result<Option<Device>> {
597 get_device(&self.lock(), id)
598 }
599
600 pub fn devices(&self) -> Result<Vec<Device>> {
602 let conn = self.lock();
603 let mut stmt = conn.prepare(&format!(
604 "SELECT {DEVICE_COLUMNS} FROM devices ORDER BY created_at DESC, id"
605 ))?;
606 let rows = stmt.query_map([], device_from)?;
607 Ok(rows.collect::<rusqlite::Result<_>>()?)
608 }
609
610 pub fn revoke_device(&self, id: &str, now: &str) -> Result<Option<Device>> {
613 let conn = self.lock();
614 conn.execute(
615 "UPDATE devices SET revoked_at = COALESCE(revoked_at, ?1) WHERE id = ?2",
616 (now, id),
617 )?;
618 get_device(&conn, id)
619 }
620
621 pub fn touch_device(&self, id: &str, now: &str) -> Result<()> {
623 self.lock()
624 .execute("UPDATE devices SET last_seen = ?1 WHERE id = ?2", (now, id))?;
625 Ok(())
626 }
627
628 pub fn insert_authkey(&self, k: &NewAuthkey<'_>) -> Result<Authkey> {
630 let conn = self.lock();
631 conn.execute(
632 "INSERT INTO authkeys
633 (id, key_sha256, tag, ephemeral, max_devices, created_at, expires_at)
634 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
635 (
636 k.id,
637 k.key_sha256,
638 k.tag,
639 k.ephemeral as i64,
640 k.max_devices,
641 k.created_at,
642 k.expires_at,
643 ),
644 )?;
645 Ok(get_authkey(&conn, "id", k.id)?.expect("inserted above"))
646 }
647
648 pub fn authkey_by_hash(&self, key_sha256: &str) -> Result<Option<Authkey>> {
650 get_authkey(&self.lock(), "key_sha256", key_sha256)
651 }
652
653 pub fn authkeys(&self) -> Result<Vec<Authkey>> {
655 let conn = self.lock();
656 let mut stmt = conn.prepare(&format!(
657 "SELECT {AUTHKEY_COLUMNS} FROM authkeys ORDER BY created_at DESC, id"
658 ))?;
659 let rows = stmt.query_map([], authkey_from)?;
660 Ok(rows.collect::<rusqlite::Result<_>>()?)
661 }
662
663 pub fn revoke_authkey(&self, id: &str, now: &str, devices: bool) -> Result<Option<Authkey>> {
667 let mut conn = self.lock();
668 let tx = conn.transaction()?;
669 tx.execute(
670 "UPDATE authkeys SET revoked_at = COALESCE(revoked_at, ?1) WHERE id = ?2",
671 (now, id),
672 )?;
673 if devices {
674 tx.execute(
675 "UPDATE devices SET revoked_at = COALESCE(revoked_at, ?1) WHERE authkey_id = ?2",
676 (now, id),
677 )?;
678 }
679 tx.commit()?;
680 get_authkey(&conn, "id", id)
681 }
682
683 pub fn sweep_devices(&self, idle_before: &str, expired_before: &str) -> Result<(usize, usize)> {
687 let conn = self.lock();
688 let devices = conn.execute(
689 "DELETE FROM devices WHERE ephemeral = 1 AND COALESCE(last_seen, created_at) < ?1",
690 (idle_before,),
691 )?;
692 let enrollments = conn.execute(
693 "DELETE FROM device_enrollments WHERE expires_at < ?1",
694 (expired_before,),
695 )?;
696 Ok((devices, enrollments))
697 }
698}
699
700fn insert_device(conn: &Connection, d: &NewDevice<'_>) -> Result<()> {
701 conn.execute(
702 "INSERT INTO devices
703 (id, name, public_key, scope, agent, ephemeral, authkey_id, created_at)
704 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
705 (
706 d.id,
707 d.name,
708 d.public_key,
709 d.scope,
710 d.agent,
711 d.ephemeral as i64,
712 d.authkey_id,
713 d.created_at,
714 ),
715 )?;
716 Ok(())
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 const KEY: &str = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
724
725 fn at(secs: i64) -> OffsetDateTime {
726 OffsetDateTime::from_unix_timestamp(1_790_000_000 + secs).unwrap()
727 }
728
729 fn ts(secs: i64) -> String {
730 format_timestamp(at(secs))
731 }
732
733 fn enroll_from(st: &Store, id: &str, code: &str, created: i64, ip: &str) -> Created {
734 st.create_enrollment(
735 &NewEnrollment {
736 enrollment_id: id,
737 user_code: code,
738 name: "laptop",
739 public_key: KEY,
740 agent: "recall/0.4.1",
741 created_at: &ts(created),
742 expires_at: &ts(created + 900),
743 client_ip: ip,
744 },
745 3,
746 2,
747 )
748 .unwrap()
749 }
750
751 fn enroll(st: &Store, id: &str, code: &str, created: i64) -> Created {
752 enroll_from(st, id, code, created, id)
755 }
756
757 fn device<'a>(id: &'a str, name: &'a str, key: Option<&'a str>) -> NewDevice<'a> {
758 NewDevice {
759 id,
760 name,
761 public_key: KEY,
762 scope: "sync",
763 agent: "",
764 ephemeral: false,
765 authkey_id: key,
766 created_at: "2026-09-23T00:00:00.000Z",
767 }
768 }
769
770 fn inserted(st: &Store, d: &NewDevice<'_>) -> Device {
771 match st.insert_device(d, None).unwrap() {
772 Inserted::Done(device) => *device,
773 other => panic!("not inserted: {other:?}"),
774 }
775 }
776
777 #[test]
778 fn the_tables_are_created_once_and_reopening_keeps_them() {
779 let dir = tempfile::tempdir().unwrap();
780 let path = dir.path().join("r.db");
781 {
782 let st = Store::open(&path).unwrap();
783 assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
784 }
785 let st = Store::open(&path).unwrap();
786 assert_eq!(
787 st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
788 .unwrap(),
789 Poll::Pending
790 );
791 }
792
793 #[test]
794 fn a_code_in_use_is_refused_and_the_waiting_list_is_capped() {
795 let st = Store::open_in_memory().unwrap();
796 assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
797 assert_eq!(enroll(&st, "enr_b", "BCDF-GHJK", 1), Created::CodeTaken);
798 assert_eq!(enroll(&st, "enr_b", "BCDF-GHJL", 1), Created::Created);
799 assert_eq!(enroll(&st, "enr_c", "BCDF-GHJM", 2), Created::Created);
800 assert_eq!(enroll(&st, "enr_d", "BCDF-GHJN", 3), Created::Full);
801 assert_eq!(enroll(&st, "enr_d", "BCDF-GHJK", 1000), Created::Created);
803 }
804
805 #[test]
808 fn one_address_may_have_only_so_many_waiting() {
809 let st = Store::open_in_memory().unwrap();
810 let from = |id, code, ip| enroll_from(&st, id, code, 0, ip);
811 assert_eq!(from("enr_a", "BCDF-GHJK", "198.51.100.4"), Created::Created);
812 assert_eq!(from("enr_b", "BCDF-GHJL", "198.51.100.4"), Created::Created);
813 assert_eq!(
814 from("enr_c", "BCDF-GHJM", "198.51.100.4"),
815 Created::AddressFull
816 );
817 assert_eq!(from("enr_c", "BCDF-GHJM", "198.51.100.5"), Created::Created);
818 st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap();
820 assert_eq!(from("enr_d", "BCDF-GHJN", "198.51.100.4"), Created::Created);
821 }
822
823 #[test]
824 fn a_poll_follows_rfc8628() {
825 let st = Store::open_in_memory().unwrap();
826 let every = Duration::from_secs(5);
827 enroll(&st, "enr_a", "BCDF-GHJK", 0);
828 assert_eq!(
829 st.poll_enrollment("enr_a", at(1), every).unwrap(),
830 Poll::Pending
831 );
832 assert_eq!(
833 st.poll_enrollment("enr_a", at(3), every).unwrap(),
834 Poll::SlowDown
835 );
836 assert_eq!(
838 st.poll_enrollment("enr_a", at(7), every).unwrap(),
839 Poll::Pending
840 );
841 assert_eq!(
842 st.poll_enrollment("enr_x", at(8), every).unwrap(),
843 Poll::Unknown
844 );
845 assert_eq!(
846 st.poll_enrollment("enr_a", at(900), every).unwrap(),
847 Poll::Expired
848 );
849
850 enroll(&st, "enr_b", "BCDF-GHJL", 0);
851 let Decision::Done(device) = st
852 .approve_enrollment("BCDF-GHJL", "dev_1", "admin", &ts(10), None)
853 .unwrap()
854 else {
855 panic!("not approved");
856 };
857 assert_eq!(
858 (device.name.as_str(), device.scope.as_str()),
859 ("laptop", "admin")
860 );
861 assert!(!device.fingerprint.is_empty());
862 assert_eq!(
864 st.poll_enrollment("enr_b", at(2000), every).unwrap(),
865 Poll::Approved {
866 device_id: "dev_1".into(),
867 scope: "admin".into()
868 }
869 );
870 st.revoke_device("dev_1", &ts(20)).unwrap();
871 assert_eq!(
872 st.poll_enrollment("enr_b", at(30), every).unwrap(),
873 Poll::Denied
874 );
875 }
876
877 #[test]
878 fn a_code_is_decided_once() {
879 let st = Store::open_in_memory().unwrap();
880 enroll(&st, "enr_a", "BCDF-GHJK", 0);
881 let Decision::Done(waiting) = st.pending_enrollment("BCDF-GHJK", &ts(1)).unwrap() else {
882 panic!("not waiting");
883 };
884 assert_eq!(
885 (waiting.name.as_str(), waiting.expires_at),
886 ("laptop", ts(900))
887 );
888 assert_eq!(
889 st.pending_enrollment("BCDF-GHJK", &ts(900)).unwrap(),
890 Decision::Expired
891 );
892 assert_eq!(
893 st.pending_enrollment("ZZZZ-ZZZZ", &ts(1)).unwrap(),
894 Decision::NotFound
895 );
896 assert_eq!(
897 st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap(),
898 Decision::Done("laptop".into())
899 );
900 assert_eq!(
901 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), None)
902 .unwrap(),
903 Decision::AlreadyDecided
904 );
905 assert_eq!(
906 st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
907 .unwrap(),
908 Poll::Denied
909 );
910 assert_eq!(
911 st.approve_enrollment("ZZZZ-ZZZZ", "dev_1", "sync", &ts(2), None)
912 .unwrap(),
913 Decision::NotFound
914 );
915 enroll(&st, "enr_b", "BCDF-GHJL", 0);
916 assert_eq!(
917 st.approve_enrollment("BCDF-GHJL", "dev_1", "sync", &ts(901), None)
918 .unwrap(),
919 Decision::Expired
920 );
921 assert!(st.devices().unwrap().is_empty(), "nothing was approved");
922 }
923
924 #[test]
926 fn an_approval_is_bound_to_the_fingerprint_it_names() {
927 let st = Store::open_in_memory().unwrap();
928 enroll(&st, "enr_a", "BCDF-GHJK", 0);
929 let right = fingerprint(&parse_public_key(KEY).unwrap());
930 assert_eq!(
931 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(1), Some("SHA256:other"))
932 .unwrap(),
933 Decision::KeyMismatch
934 );
935 assert!(st.devices().unwrap().is_empty(), "nothing was approved");
936 assert!(matches!(
937 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), Some(&right))
938 .unwrap(),
939 Decision::Done(_)
940 ));
941 }
942
943 #[test]
946 fn names_are_unique_among_devices_not_revoked() {
947 let st = Store::open_in_memory().unwrap();
948 inserted(&st, &device("dev_1", "laptop", None));
949 assert_eq!(
950 st.insert_device(&device("dev_2", "Laptop", None), None)
951 .unwrap(),
952 Inserted::NameTaken
953 );
954
955 enroll(&st, "enr_a", "BCDF-GHJK", 0);
956 assert_eq!(
957 st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(1), None)
958 .unwrap(),
959 Decision::NameTaken("laptop".into())
960 );
961
962 st.revoke_device("dev_1", &ts(2)).unwrap();
963 assert!(matches!(
964 st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(3), None)
965 .unwrap(),
966 Decision::Done(_)
967 ));
968 }
969
970 #[test]
973 fn names_that_look_alike_are_one_name() {
974 for (a, b) in [
975 ("laptop", "l\u{0430}ptop"),
977 ("caf\u{00E9}", "cafe\u{0301}"),
979 ("STRASSE", "Stra\u{00DF}e"),
980 ("laptop", "LAPTOP"),
981 ("laptop", "1aptop"),
982 ("Kiosk", "\u{041A}iosk"),
984 ("file", "\u{FB01}le"),
986 ("desk", "\u{FF44}\u{FF45}\u{FF53}\u{FF4B}"),
987 ] {
988 for (taken, wanted) in [(a, b), (b, a)] {
989 let st = Store::open_in_memory().unwrap();
990 inserted(&st, &device("dev_1", taken, None));
991 assert_eq!(
992 st.insert_device(&device("dev_2", wanted, None), None)
993 .unwrap(),
994 Inserted::NameTaken,
995 "{wanted:?} beside {taken:?}"
996 );
997 }
998 }
999
1000 let st = Store::open_in_memory().unwrap();
1002 for (i, name) in ["laptop", "laptops", "lapdog", "desk", "desk-2"]
1003 .into_iter()
1004 .enumerate()
1005 {
1006 inserted(&st, &device(&format!("dev_{i}"), name, None));
1007 }
1008 }
1009
1010 #[test]
1011 fn a_name_is_stored_composed_and_trimmed() {
1012 assert_eq!(plain_name(" cafe\u{0301} "), "caf\u{00E9}");
1013 assert_eq!(plain_name("laptop"), "laptop");
1014 }
1015
1016 #[test]
1017 fn revoking_keeps_the_first_time_and_the_row() {
1018 let st = Store::open_in_memory().unwrap();
1019 inserted(&st, &device("dev_1", "laptop", None));
1020 let first = st.revoke_device("dev_1", &ts(1)).unwrap().unwrap();
1021 let again = st.revoke_device("dev_1", &ts(2)).unwrap().unwrap();
1022 assert_eq!(first.revoked_at, Some(ts(1)));
1023 assert_eq!(again.revoked_at, Some(ts(1)));
1024 assert!(st.revoke_device("dev_none", &ts(3)).unwrap().is_none());
1025 assert_eq!(st.devices().unwrap().len(), 1);
1026 }
1027
1028 #[test]
1029 fn only_idle_ephemeral_devices_and_long_expired_enrolments_are_swept() {
1030 let st = Store::open_in_memory().unwrap();
1031 for (id, ephemeral) in [("dev_kept", false), ("dev_idle", true), ("dev_busy", true)] {
1032 inserted(
1033 &st,
1034 &NewDevice {
1035 ephemeral,
1036 created_at: &ts(0),
1037 ..device(id, id, None)
1038 },
1039 );
1040 }
1041 st.touch_device("dev_busy", &ts(5000)).unwrap();
1042 enroll(&st, "enr_old", "BCDF-GHJK", 0);
1043 enroll(&st, "enr_new", "BCDF-GHJL", 4000);
1044
1045 let (devices, enrollments) = st.sweep_devices(&ts(3600), &ts(3600)).unwrap();
1046 assert_eq!((devices, enrollments), (1, 1));
1047 let left: Vec<String> = st.devices().unwrap().into_iter().map(|d| d.id).collect();
1048 assert!(left.contains(&"dev_kept".to_string()) && left.contains(&"dev_busy".to_string()));
1049 assert_eq!(
1050 st.poll_enrollment("enr_old", at(3700), Duration::from_secs(5))
1051 .unwrap(),
1052 Poll::Unknown
1053 );
1054 }
1055
1056 fn key(st: &Store, max_devices: Option<u32>) {
1057 st.insert_authkey(&NewAuthkey {
1058 id: "ak_1",
1059 key_sha256: "abc",
1060 tag: "cloud",
1061 ephemeral: true,
1062 max_devices,
1063 created_at: &ts(0),
1064 expires_at: &ts(86400),
1065 })
1066 .unwrap();
1067 }
1068
1069 #[test]
1070 fn authkeys_are_found_by_hash_and_revoked_once() {
1071 let st = Store::open_in_memory().unwrap();
1072 key(&st, None);
1073 let found = st.authkey_by_hash("abc").unwrap().unwrap();
1074 assert_eq!(
1075 (found.id.as_str(), found.ephemeral, found.max_devices),
1076 ("ak_1", true, None)
1077 );
1078 assert!(st.authkey_by_hash("abd").unwrap().is_none());
1079 let revoked = st.revoke_authkey("ak_1", &ts(1), false).unwrap().unwrap();
1080 assert_eq!(revoked.revoked_at, Some(ts(1)));
1081 assert_eq!(
1082 st.revoke_authkey("ak_1", &ts(2), false)
1083 .unwrap()
1084 .unwrap()
1085 .revoked_at,
1086 Some(ts(1))
1087 );
1088 assert_eq!(st.authkeys().unwrap().len(), 1);
1089 }
1090
1091 #[test]
1095 fn a_key_enrols_no_more_than_its_cap() {
1096 let st = Store::open_in_memory().unwrap();
1097 key(&st, Some(2));
1098 inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
1099 assert!(matches!(
1100 st.insert_device(&device("dev_2", "cloud-2", Some("ak_1")), Some(2))
1101 .unwrap(),
1102 Inserted::Done(_)
1103 ));
1104 assert_eq!(
1105 st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
1106 .unwrap(),
1107 Inserted::KeyFull
1108 );
1109 st.revoke_device("dev_1", &ts(1)).unwrap();
1110 assert!(matches!(
1111 st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
1112 .unwrap(),
1113 Inserted::Done(_)
1114 ));
1115 }
1116
1117 #[test]
1118 fn revoking_a_key_can_revoke_what_it_enrolled() {
1119 let st = Store::open_in_memory().unwrap();
1120 key(&st, None);
1121 inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
1122 inserted(&st, &device("dev_2", "laptop", None));
1123 st.revoke_authkey("ak_1", &ts(1), true).unwrap();
1124 let revoked: Vec<(String, bool)> = st
1125 .devices()
1126 .unwrap()
1127 .into_iter()
1128 .map(|d| (d.id, d.revoked_at.is_some()))
1129 .collect();
1130 assert!(revoked.contains(&("dev_1".into(), true)));
1131 assert!(
1132 revoked.contains(&("dev_2".into(), false)),
1133 "only the key's own devices"
1134 );
1135 }
1136}