1use std::time::Duration;
8
9use anyhow::{bail, Context, 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::{Outcome, Store};
17use crate::audit::leaf;
18use crate::{format_timestamp, parse_timestamp};
19
20macro_rules! devices_columns {
23 () => {
24 "(
25 id TEXT PRIMARY KEY,
26 -- Always the one owner. Reserved now because adding it later would
27 -- be a migration: see Part 3 of docs/design/handshake.md.
28 owner_id TEXT NOT NULL DEFAULT 'owner',
29 name TEXT NOT NULL,
30 public_key TEXT NOT NULL,
31 scope TEXT NOT NULL CHECK (scope IN ('sync', 'admin', 'worker')),
32 agent TEXT NOT NULL DEFAULT '',
33 ephemeral INTEGER NOT NULL DEFAULT 0,
34 authkey_id TEXT,
35 created_at TEXT NOT NULL,
36 last_seen TEXT,
37 revoked_at TEXT
38 )"
39 };
40}
41
42pub(super) const SCHEMA: &str = concat!(
45 "CREATE TABLE IF NOT EXISTS devices ",
46 devices_columns!(),
47 ";",
48 "
49 CREATE TABLE IF NOT EXISTS device_enrollments (
50 enrollment_id TEXT PRIMARY KEY,
51 user_code TEXT NOT NULL,
52 name TEXT NOT NULL,
53 public_key TEXT NOT NULL,
54 agent TEXT NOT NULL DEFAULT '',
55 created_at TEXT NOT NULL,
56 expires_at TEXT NOT NULL,
57 -- The device approving it made; NULL while it waits.
58 device_id TEXT,
59 denied INTEGER NOT NULL DEFAULT 0,
60 last_poll_at TEXT,
61 -- The address it came from, as the rate limiter keys it: what caps
62 -- how many one address may have waiting.
63 client_ip TEXT NOT NULL DEFAULT ''
64 );
65 CREATE INDEX IF NOT EXISTS device_enrollments_user_code
66 ON device_enrollments (user_code);
67 CREATE TABLE IF NOT EXISTS authkeys (
68 id TEXT PRIMARY KEY,
69 -- The key itself is never stored: it is shown once, and a copy of
70 -- the database is not a copy of it.
71 key_sha256 TEXT NOT NULL UNIQUE,
72 tag TEXT NOT NULL DEFAULT '',
73 ephemeral INTEGER NOT NULL DEFAULT 0,
74 -- The most unrevoked devices it may have enrolled at once. Always
75 -- stored; NULL would be read as the default, not as no limit.
76 max_devices INTEGER,
77 created_at TEXT NOT NULL,
78 expires_at TEXT NOT NULL,
79 revoked_at TEXT
80 );
81"
82);
83
84pub(super) fn allow_worker_scope(conn: &Connection) -> Result<()> {
112 let sql: String = conn.query_row(
113 "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'devices'",
114 [],
115 |r| r.get(0),
116 )?;
117 if sql.contains("'worker'") {
118 return Ok(());
119 }
120 let columns = {
121 let mut stmt =
122 conn.prepare("SELECT name FROM pragma_table_info('devices') ORDER BY cid")?;
123 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
124 rows.collect::<rusqlite::Result<Vec<_>>>()?
125 };
126 if columns != REBUILT_COLUMNS {
127 anyhow::bail!(
128 "the devices table has the columns {columns:?}, where this server expects \
129 {REBUILT_COLUMNS:?}, so it will not rebuild the table to allow the worker scope: \
130 the rebuild copies the columns it knows, and would drop the others. Nothing was \
131 changed; look at the table (PRAGMA table_info(devices)) before starting this \
132 server on it again"
133 );
134 }
135 conn.execute_batch(concat!(
136 "BEGIN IMMEDIATE;
137 DROP TABLE IF EXISTS devices_rebuilt;
138 CREATE TABLE devices_rebuilt ",
139 devices_columns!(),
140 ";
141 INSERT INTO devices_rebuilt
142 (id, owner_id, name, public_key, scope, agent, ephemeral, authkey_id,
143 created_at, last_seen, revoked_at)
144 SELECT id, owner_id, name, public_key, scope, agent, ephemeral, authkey_id,
145 created_at, last_seen, revoked_at
146 FROM devices;
147 DROP TABLE devices;
148 ALTER TABLE devices_rebuilt RENAME TO devices;
149 COMMIT;"
150 ))?;
151 Ok(())
152}
153
154const REBUILT_COLUMNS: [&str; 11] = [
157 "id",
158 "owner_id",
159 "name",
160 "public_key",
161 "scope",
162 "agent",
163 "ephemeral",
164 "authkey_id",
165 "created_at",
166 "last_seen",
167 "revoked_at",
168];
169
170const DEVICE_COLUMNS: &str = "id, name, scope, ephemeral, agent, public_key, authkey_id, \
171 created_at, last_seen, revoked_at";
172
173const AUTHKEY_COLUMNS: &str = "id, tag, ephemeral, max_devices, created_at, expires_at, revoked_at";
174
175fn device_from(r: &Row<'_>) -> rusqlite::Result<Device> {
176 let public_key: String = r.get(5)?;
177 Ok(Device {
178 id: r.get(0)?,
179 name: r.get(1)?,
180 scope: r.get(2)?,
181 ephemeral: r.get::<_, i64>(3)? != 0,
182 agent: r.get(4)?,
183 fingerprint: parse_public_key(&public_key)
185 .map(|k| fingerprint(&k))
186 .unwrap_or_default(),
187 public_key,
188 authkey_id: r.get(6)?,
189 created_at: r.get(7)?,
190 last_seen: r.get(8)?,
191 revoked_at: r.get(9)?,
192 })
193}
194
195fn authkey_from(r: &Row<'_>) -> rusqlite::Result<Authkey> {
196 Ok(Authkey {
197 id: r.get(0)?,
198 tag: r.get(1)?,
199 ephemeral: r.get::<_, i64>(2)? != 0,
200 max_devices: r.get(3)?,
201 created_at: r.get(4)?,
202 expires_at: r.get(5)?,
203 revoked_at: r.get(6)?,
204 })
205}
206
207pub fn plain_name(name: &str) -> String {
211 name.trim().nfc().collect()
212}
213
214fn name_keys(name: &str) -> [String; 2] {
227 let plain: String = name.trim().nfkc().collect();
228 let skeleton = |s: String| unicode_security::skeleton(&s).collect::<String>();
229 [
230 skeleton(plain.to_lowercase()),
231 skeleton(plain.to_uppercase()),
232 ]
233}
234
235fn name_taken(conn: &Connection, name: &str) -> Result<bool> {
239 let [lower, upper] = name_keys(name);
240 let mut stmt = conn.prepare("SELECT name FROM devices WHERE revoked_at IS NULL")?;
241 let mut rows = stmt.query([])?;
242 while let Some(row) = rows.next()? {
243 let [their_lower, their_upper] = name_keys(&row.get::<_, String>(0)?);
244 if lower == their_lower || upper == their_upper {
245 return Ok(true);
246 }
247 }
248 Ok(false)
249}
250
251fn get_device(conn: &Connection, id: &str) -> Result<Option<Device>> {
252 Ok(conn
253 .query_row(
254 &format!("SELECT {DEVICE_COLUMNS} FROM devices WHERE id = ?1"),
255 (id,),
256 device_from,
257 )
258 .optional()?)
259}
260
261fn get_authkey(conn: &Connection, column: &str, value: &str) -> Result<Option<Authkey>> {
262 Ok(conn
263 .query_row(
264 &format!("SELECT {AUTHKEY_COLUMNS} FROM authkeys WHERE {column} = ?1"),
265 (value,),
266 authkey_from,
267 )
268 .optional()?)
269}
270
271#[derive(Debug, Clone)]
273pub struct NewDevice<'a> {
274 pub id: &'a str,
276 pub name: &'a str,
278 pub public_key: &'a str,
280 pub scope: &'a str,
282 pub agent: &'a str,
284 pub ephemeral: bool,
286 pub authkey_id: Option<&'a str>,
288 pub created_at: &'a str,
290}
291
292#[derive(Debug, Clone)]
294pub struct NewEnrollment<'a> {
295 pub enrollment_id: &'a str,
297 pub user_code: &'a str,
299 pub name: &'a str,
301 pub public_key: &'a str,
303 pub agent: &'a str,
305 pub created_at: &'a str,
307 pub expires_at: &'a str,
309 pub client_ip: &'a str,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Created {
316 Created,
318 CodeTaken,
320 Full,
322 AddressFull,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum Inserted {
329 Done(Box<Device>),
331 NameTaken,
333 KeyFull,
335}
336
337#[derive(Debug, Clone)]
339pub struct NewAuthkey<'a> {
340 pub id: &'a str,
342 pub key_sha256: &'a str,
344 pub tag: &'a str,
346 pub ephemeral: bool,
348 pub max_devices: Option<u32>,
350 pub created_at: &'a str,
352 pub expires_at: &'a str,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum Poll {
360 Approved {
362 device_id: String,
364 scope: String,
366 },
367 Pending,
369 SlowDown,
371 Expired,
373 Denied,
375 Unknown,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum Decision<T> {
382 Done(T),
384 NotFound,
386 Expired,
388 AlreadyDecided,
390 KeyMismatch,
393 NameTaken(String),
395}
396
397type Pending = (String, String, String, String, String, bool);
400
401#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct Waiting {
404 pub name: String,
406 pub public_key: String,
408 pub agent: String,
410 pub expires_at: String,
412}
413
414fn pending_by_code(conn: &Connection, user_code: &str) -> Result<Option<Pending>> {
415 Ok(conn
416 .query_row(
417 "SELECT enrollment_id, name, public_key, agent, expires_at,
418 device_id IS NOT NULL OR denied != 0
419 FROM device_enrollments WHERE user_code = ?1
420 ORDER BY created_at DESC LIMIT 1",
421 (user_code,),
422 |r| {
423 Ok((
424 r.get(0)?,
425 r.get(1)?,
426 r.get(2)?,
427 r.get(3)?,
428 r.get(4)?,
429 r.get::<_, bool>(5)?,
430 ))
431 },
432 )
433 .optional()?)
434}
435
436impl Store {
437 pub fn create_enrollment(
441 &self,
442 e: &NewEnrollment<'_>,
443 max_pending: usize,
444 max_per_address: usize,
445 ) -> Result<Created> {
446 let conn = self.lock();
447 let waiting = "expires_at > ?1 AND device_id IS NULL AND denied = 0";
451 let count: i64 = conn.query_row(
452 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting}"),
453 (e.created_at,),
454 |r| r.get(0),
455 )?;
456 if count as usize >= max_pending {
457 return Ok(Created::Full);
458 }
459 let from_here: i64 = conn.query_row(
460 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND client_ip = ?2"),
461 (e.created_at, e.client_ip),
462 |r| r.get(0),
463 )?;
464 if from_here as usize >= max_per_address {
465 return Ok(Created::AddressFull);
466 }
467 let taken: i64 = conn.query_row(
468 &format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND user_code = ?2"),
469 (e.created_at, e.user_code),
470 |r| r.get(0),
471 )?;
472 if taken > 0 {
473 return Ok(Created::CodeTaken);
474 }
475 conn.execute(
476 "INSERT INTO device_enrollments
477 (enrollment_id, user_code, name, public_key, agent, created_at, expires_at,
478 client_ip)
479 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
480 (
481 e.enrollment_id,
482 e.user_code,
483 e.name,
484 e.public_key,
485 e.agent,
486 e.created_at,
487 e.expires_at,
488 e.client_ip,
489 ),
490 )?;
491 Ok(Created::Created)
492 }
493
494 pub fn poll_enrollment(
501 &self,
502 enrollment_id: &str,
503 now: OffsetDateTime,
504 interval: Duration,
505 ) -> Result<Poll> {
506 let conn = self.lock();
507 let row = conn
508 .query_row(
509 "SELECT e.expires_at, e.denied, e.last_poll_at, e.device_id, d.scope, d.revoked_at
510 FROM device_enrollments e LEFT JOIN devices d ON d.id = e.device_id
511 WHERE e.enrollment_id = ?1",
512 (enrollment_id,),
513 |r| {
514 Ok((
515 r.get::<_, String>(0)?,
516 r.get::<_, i64>(1)? != 0,
517 r.get::<_, Option<String>>(2)?,
518 r.get::<_, Option<String>>(3)?,
519 r.get::<_, Option<String>>(4)?,
520 r.get::<_, Option<String>>(5)?,
521 ))
522 },
523 )
524 .optional()?;
525 let Some((expires_at, denied, last_poll_at, device_id, scope, revoked_at)) = row else {
526 return Ok(Poll::Unknown);
527 };
528 if denied {
529 return Ok(Poll::Denied);
530 }
531 if let Some(device_id) = device_id {
534 return Ok(match (scope, revoked_at) {
535 (Some(scope), None) => Poll::Approved { device_id, scope },
536 _ => Poll::Denied,
539 });
540 }
541 let now_text = format_timestamp(now);
542 if expires_at <= now_text {
543 return Ok(Poll::Expired);
544 }
545 let too_soon = last_poll_at
546 .as_deref()
547 .and_then(parse_timestamp)
548 .is_some_and(|last| {
549 let min = interval.saturating_sub(Duration::from_secs(1));
550 now - last < min
551 });
552 conn.execute(
553 "UPDATE device_enrollments SET last_poll_at = ?1 WHERE enrollment_id = ?2",
554 (&now_text, enrollment_id),
555 )?;
556 Ok(if too_soon {
557 Poll::SlowDown
558 } else {
559 Poll::Pending
560 })
561 }
562
563 pub fn approve_enrollment_audited(
574 &self,
575 user_code: &str,
576 device_id: &str,
577 scope: &str,
578 now: &str,
579 expected_fingerprint: Option<&str>,
580 build_leaf: impl FnOnce(u64, &str, &Device) -> Vec<u8>,
581 ) -> Result<Decision<Device>> {
582 self.audited(
583 |tx, _| {
584 let Some((enrollment_id, name, public_key, agent, expires_at, decided)) =
585 pending_by_code(tx, user_code)?
586 else {
587 return Ok(Outcome::Refuse(Decision::NotFound));
588 };
589 if decided {
590 return Ok(Outcome::Refuse(Decision::AlreadyDecided));
591 }
592 if expires_at.as_str() <= now {
593 return Ok(Outcome::Refuse(Decision::Expired));
594 }
595 if let Some(expected) = expected_fingerprint {
596 let actual = parse_public_key(&public_key)
597 .map(|k| fingerprint(&k))
598 .unwrap_or_default();
599 if expected.trim() != actual {
600 return Ok(Outcome::Refuse(Decision::KeyMismatch));
601 }
602 }
603 if name_taken(tx, &name)? {
607 return Ok(Outcome::Refuse(Decision::NameTaken(name)));
608 }
609 insert_device(
610 tx,
611 &NewDevice {
612 id: device_id,
613 name: &name,
614 public_key: &public_key,
615 scope,
616 agent: &agent,
617 ephemeral: false,
618 authkey_id: None,
619 created_at: now,
620 },
621 )?;
622 tx.execute(
626 "UPDATE device_enrollments SET device_id = ?1 WHERE enrollment_id = ?2",
627 (device_id, &enrollment_id),
628 )?;
629 let device = get_device(tx, device_id)?.context("the device just inserted")?;
630 Ok(Outcome::Commit(Decision::Done(device)))
631 },
632 |seq, at, decision| match decision {
633 Decision::Done(device) => build_leaf(seq, at, device),
634 _ => unreachable!("build_leaf runs only when write committed"),
635 },
636 )
637 }
638
639 pub fn deny_enrollment_audited(
643 &self,
644 user_code: &str,
645 now: &str,
646 build_leaf: impl FnOnce(u64, &str, &str) -> Vec<u8>,
647 ) -> Result<Decision<String>> {
648 self.audited(
649 |tx, _| {
650 let Some((enrollment_id, name, _, _, expires_at, decided)) =
651 pending_by_code(tx, user_code)?
652 else {
653 return Ok(Outcome::Refuse(Decision::NotFound));
654 };
655 if decided {
656 return Ok(Outcome::Refuse(Decision::AlreadyDecided));
657 }
658 if expires_at.as_str() <= now {
659 return Ok(Outcome::Refuse(Decision::Expired));
660 }
661 tx.execute(
662 "UPDATE device_enrollments SET denied = 1 WHERE enrollment_id = ?1",
663 (&enrollment_id,),
664 )?;
665 Ok(Outcome::Commit(Decision::Done(name)))
666 },
667 |seq, at, decision| match decision {
668 Decision::Done(name) => build_leaf(seq, at, name),
669 _ => unreachable!("build_leaf runs only when write committed"),
670 },
671 )
672 }
673
674 pub fn pending_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<Waiting>> {
678 let conn = self.lock();
679 let Some((_, name, public_key, agent, expires_at, decided)) =
680 pending_by_code(&conn, user_code)?
681 else {
682 return Ok(Decision::NotFound);
683 };
684 if decided {
685 return Ok(Decision::AlreadyDecided);
686 }
687 if expires_at.as_str() <= now {
688 return Ok(Decision::Expired);
689 }
690 Ok(Decision::Done(Waiting {
691 name,
692 public_key,
693 agent,
694 expires_at,
695 }))
696 }
697
698 pub fn enroll_device_audited(
708 &self,
709 d: &NewDevice<'_>,
710 max_for_key: Option<u32>,
711 build_leaf: impl FnOnce(u64, &str, &Device) -> Vec<u8>,
712 ) -> Result<Inserted> {
713 self.audited(
714 |tx, _| {
715 if name_taken(tx, d.name)? {
716 return Ok(Outcome::Refuse(Inserted::NameTaken));
717 }
718 if let (Some(max), Some(key)) = (max_for_key, d.authkey_id) {
719 let live: i64 = tx.query_row(
720 "SELECT COUNT(*) FROM devices WHERE authkey_id = ?1 AND revoked_at IS NULL",
721 (key,),
722 |r| r.get(0),
723 )?;
724 if live >= i64::from(max) {
725 return Ok(Outcome::Refuse(Inserted::KeyFull));
726 }
727 }
728 insert_device(tx, d)?;
729 let device = get_device(tx, d.id)?.context("the device just inserted")?;
730 Ok(Outcome::Commit(Inserted::Done(Box::new(device))))
731 },
732 |seq, at, inserted| match inserted {
733 Inserted::Done(device) => build_leaf(seq, at, device),
734 _ => unreachable!("build_leaf runs only when write committed"),
735 },
736 )
737 }
738
739 pub fn device(&self, id: &str) -> Result<Option<Device>> {
741 get_device(&self.lock(), id)
742 }
743
744 pub fn enrolled_worker(&self) -> Result<Option<Device>> {
747 Ok(self
748 .lock()
749 .query_row(
750 &format!(
751 "SELECT {DEVICE_COLUMNS} FROM devices
752 WHERE scope = 'worker' AND revoked_at IS NULL
753 ORDER BY created_at DESC, id LIMIT 1"
754 ),
755 [],
756 device_from,
757 )
758 .optional()?)
759 }
760
761 pub fn devices(&self) -> Result<Vec<Device>> {
763 let conn = self.lock();
764 let mut stmt = conn.prepare(&format!(
765 "SELECT {DEVICE_COLUMNS} FROM devices ORDER BY created_at DESC, id"
766 ))?;
767 let rows = stmt.query_map([], device_from)?;
768 Ok(rows.collect::<rusqlite::Result<_>>()?)
769 }
770
771 pub fn revoke_device_audited(
776 &self,
777 id: &str,
778 now: &str,
779 build_leaf: impl FnOnce(u64, &str, &Device) -> Vec<u8>,
780 ) -> Result<Option<Device>> {
781 self.audited(
782 |tx, _| {
783 let Some(before) = get_device(tx, id)? else {
784 return Ok(Outcome::Refuse(None));
785 };
786 if before.revoked_at.is_some() {
787 return Ok(Outcome::Refuse(Some(before)));
788 }
789 tx.execute(
790 "UPDATE devices SET revoked_at = ?1 WHERE id = ?2",
791 (now, id),
792 )?;
793 let after = get_device(tx, id)?.context("the device just revoked")?;
794 Ok(Outcome::Commit(Some(after)))
795 },
796 |seq, at, device| {
797 build_leaf(
798 seq,
799 at,
800 device
801 .as_ref()
802 .expect("build_leaf runs only on a real revoke"),
803 )
804 },
805 )
806 }
807
808 pub fn touch_device(&self, id: &str, now: &str) -> Result<()> {
811 self.lock()
812 .execute("UPDATE devices SET last_seen = ?1 WHERE id = ?2", (now, id))?;
813 Ok(())
814 }
815
816 pub fn insert_authkey_audited(
819 &self,
820 k: &NewAuthkey<'_>,
821 build_leaf: impl FnOnce(u64, &str, &Authkey) -> Vec<u8>,
822 ) -> Result<Authkey> {
823 self.audited(
824 |tx, _| {
825 tx.execute(
826 "INSERT INTO authkeys
827 (id, key_sha256, tag, ephemeral, max_devices, created_at, expires_at)
828 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
829 (
830 k.id,
831 k.key_sha256,
832 k.tag,
833 k.ephemeral as i64,
834 k.max_devices,
835 k.created_at,
836 k.expires_at,
837 ),
838 )?;
839 let key = get_authkey(tx, "id", k.id)?.context("the authkey just inserted")?;
840 Ok(Outcome::Commit(key))
841 },
842 build_leaf,
843 )
844 }
845
846 pub fn authkey_by_hash(&self, key_sha256: &str) -> Result<Option<Authkey>> {
848 get_authkey(&self.lock(), "key_sha256", key_sha256)
849 }
850
851 pub fn authkeys(&self) -> Result<Vec<Authkey>> {
853 let conn = self.lock();
854 let mut stmt = conn.prepare(&format!(
855 "SELECT {AUTHKEY_COLUMNS} FROM authkeys ORDER BY created_at DESC, id"
856 ))?;
857 let rows = stmt.query_map([], authkey_from)?;
858 Ok(rows.collect::<rusqlite::Result<_>>()?)
859 }
860
861 pub fn revoke_authkey_audited(
872 &self,
873 id: &str,
874 now: &str,
875 devices: bool,
876 build_leaf: impl FnOnce(u64, &str, &Authkey, &[String]) -> Vec<u8>,
877 ) -> Result<Option<Authkey>> {
878 let revoked = self.audited(
879 |tx, _| {
880 let Some(before) = get_authkey(tx, "id", id)? else {
881 return Ok(Outcome::Refuse(None));
882 };
883 let newly_revoked = before.revoked_at.is_none();
884 if newly_revoked {
885 tx.execute(
886 "UPDATE authkeys SET revoked_at = ?1 WHERE id = ?2",
887 (now, id),
888 )?;
889 }
890 let mut revoked_devices = Vec::new();
891 if devices {
892 let mut stmt = tx.prepare(
893 "UPDATE devices SET revoked_at = ?1 \
894 WHERE authkey_id = ?2 AND revoked_at IS NULL RETURNING id",
895 )?;
896 let rows = stmt.query_map((now, id), |r| r.get::<_, String>(0))?;
897 revoked_devices = rows.collect::<rusqlite::Result<_>>()?;
898 revoked_devices.sort();
899 }
900 let after = get_authkey(tx, "id", id)?.context("the authkey just revoked")?;
901 Ok(if newly_revoked || !revoked_devices.is_empty() {
902 Outcome::Commit(Some((after, revoked_devices)))
903 } else {
904 Outcome::Refuse(Some((after, revoked_devices)))
905 })
906 },
907 |seq, at, revoked| {
908 let (key, devices) = revoked.as_ref().expect("build_leaf runs only on a change");
909 build_leaf(seq, at, key, devices)
910 },
911 )?;
912 Ok(revoked.map(|(key, _)| key))
913 }
914
915 pub fn sweep_devices_audited(
927 &self,
928 idle_before: &str,
929 expired_before: &str,
930 ) -> Result<(usize, usize)> {
931 let idle = "ephemeral = 1 AND COALESCE(last_seen, created_at) < ?1";
932 let (swept, enrollments) = self.audited_each(
933 |tx, _| {
934 let swept: Vec<(String, String)> = {
935 let mut stmt = tx.prepare(&format!(
936 "SELECT id, name FROM devices WHERE {idle} ORDER BY id"
937 ))?;
938 let rows = stmt.query_map((idle_before,), |r| Ok((r.get(0)?, r.get(1)?)))?;
939 rows.collect::<rusqlite::Result<_>>()?
940 };
941 let deleted =
942 tx.execute(&format!("DELETE FROM devices WHERE {idle}"), (idle_before,))?;
943 if deleted != swept.len() {
944 bail!(
945 "the sweep chose {} devices and deleted {deleted}",
946 swept.len()
947 );
948 }
949 let enrollments = tx.execute(
950 "DELETE FROM device_enrollments WHERE expires_at < ?1",
951 (expired_before,),
952 )?;
953 Ok(Outcome::Commit((swept, enrollments)))
954 },
955 |seq, at, (swept, _)| {
956 swept
957 .iter()
958 .enumerate()
959 .map(|(i, (id, name))| {
960 leaf::encode(
961 seq + i as u64,
962 at,
963 leaf::action::SWEEP,
964 &leaf::Actor::Server,
965 leaf::subject_device_id(id, name),
966 None,
967 )
968 })
969 .collect()
970 },
971 )?;
972 Ok((swept.len(), enrollments))
973 }
974}
975
976fn insert_device(conn: &Connection, d: &NewDevice<'_>) -> Result<()> {
977 conn.execute(
978 "INSERT INTO devices
979 (id, name, public_key, scope, agent, ephemeral, authkey_id, created_at)
980 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
981 (
982 d.id,
983 d.name,
984 d.public_key,
985 d.scope,
986 d.agent,
987 d.ephemeral as i64,
988 d.authkey_id,
989 d.created_at,
990 ),
991 )?;
992 Ok(())
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use crate::store::test_leaf;
999
1000 impl Store {
1004 fn approve_enrollment(
1005 &self,
1006 user_code: &str,
1007 device_id: &str,
1008 scope: &str,
1009 now: &str,
1010 expected_fingerprint: Option<&str>,
1011 ) -> Result<Decision<Device>> {
1012 self.approve_enrollment_audited(
1013 user_code,
1014 device_id,
1015 scope,
1016 now,
1017 expected_fingerprint,
1018 |seq, at, _| test_leaf(seq, at),
1019 )
1020 }
1021
1022 fn deny_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<String>> {
1023 self.deny_enrollment_audited(user_code, now, |seq, at, _| test_leaf(seq, at))
1024 }
1025
1026 fn insert_device(&self, d: &NewDevice<'_>, max_for_key: Option<u32>) -> Result<Inserted> {
1027 self.enroll_device_audited(d, max_for_key, |seq, at, _| test_leaf(seq, at))
1028 }
1029
1030 fn revoke_device(&self, id: &str, now: &str) -> Result<Option<Device>> {
1031 let revoked = self.revoke_device_audited(id, now, |seq, at, _| test_leaf(seq, at))?;
1032 Ok(revoked)
1033 }
1034
1035 fn insert_authkey(&self, k: &NewAuthkey<'_>) -> Result<Authkey> {
1036 self.insert_authkey_audited(k, |seq, at, _| test_leaf(seq, at))
1037 }
1038
1039 fn revoke_authkey(&self, id: &str, now: &str, devices: bool) -> Result<Option<Authkey>> {
1040 self.revoke_authkey_audited(id, now, devices, |seq, at, _, _| test_leaf(seq, at))
1041 }
1042
1043 fn sweep_devices(&self, idle_before: &str, expired_before: &str) -> Result<(usize, usize)> {
1044 self.sweep_devices_audited(idle_before, expired_before)
1045 }
1046 }
1047
1048 const KEY: &str = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
1049
1050 fn at(secs: i64) -> OffsetDateTime {
1051 OffsetDateTime::from_unix_timestamp(1_790_000_000 + secs).unwrap()
1052 }
1053
1054 fn ts(secs: i64) -> String {
1055 format_timestamp(at(secs))
1056 }
1057
1058 fn enroll_from(st: &Store, id: &str, code: &str, created: i64, ip: &str) -> Created {
1059 st.create_enrollment(
1060 &NewEnrollment {
1061 enrollment_id: id,
1062 user_code: code,
1063 name: "laptop",
1064 public_key: KEY,
1065 agent: "recall/0.4.1",
1066 created_at: &ts(created),
1067 expires_at: &ts(created + 900),
1068 client_ip: ip,
1069 },
1070 3,
1071 2,
1072 )
1073 .unwrap()
1074 }
1075
1076 fn enroll(st: &Store, id: &str, code: &str, created: i64) -> Created {
1077 enroll_from(st, id, code, created, id)
1080 }
1081
1082 fn device<'a>(id: &'a str, name: &'a str, key: Option<&'a str>) -> NewDevice<'a> {
1083 NewDevice {
1084 id,
1085 name,
1086 public_key: KEY,
1087 scope: "sync",
1088 agent: "",
1089 ephemeral: false,
1090 authkey_id: key,
1091 created_at: "2026-09-23T00:00:00.000Z",
1092 }
1093 }
1094
1095 fn inserted(st: &Store, d: &NewDevice<'_>) -> Device {
1096 match st.insert_device(d, None).unwrap() {
1097 Inserted::Done(device) => *device,
1098 other => panic!("not inserted: {other:?}"),
1099 }
1100 }
1101
1102 #[test]
1105 fn an_older_devices_table_is_rebuilt_to_allow_workers() {
1106 let dir = tempfile::tempdir().unwrap();
1107 let path = dir.path().join("r.db");
1108 {
1109 let conn = Connection::open(&path).unwrap();
1110 conn.execute_batch(&SCHEMA.replace("'sync', 'admin', 'worker'", "'sync', 'admin'"))
1111 .unwrap();
1112 conn.execute(
1113 "INSERT INTO devices (id, name, public_key, scope, agent, created_at, last_seen)
1114 VALUES ('dev_old', 'laptop', ?1, 'admin', 'recall/0.4.1', ?2, ?2)",
1115 (KEY, ts(0)),
1116 )
1117 .unwrap();
1118 assert!(conn
1119 .execute(
1120 "INSERT INTO devices (id, name, public_key, scope, created_at)
1121 VALUES ('dev_w', 'w', ?1, 'worker', ?2)",
1122 (KEY, ts(0)),
1123 )
1124 .is_err());
1125 }
1126 let st = Store::open(&path).unwrap();
1127 let kept = st.device("dev_old").unwrap().unwrap();
1128 assert_eq!(
1129 (kept.name.as_str(), kept.scope.as_str(), kept.last_seen),
1130 ("laptop", "admin", Some(ts(0)))
1131 );
1132 inserted(
1133 &st,
1134 &NewDevice {
1135 scope: "worker",
1136 ..device("dev_w", "worker", None)
1137 },
1138 );
1139 assert_eq!(st.enrolled_worker().unwrap().unwrap().id, "dev_w");
1140 drop(st);
1142 let st = Store::open(&path).unwrap();
1143 assert_eq!(st.devices().unwrap().len(), 2);
1144 assert!(st
1146 .insert_device(
1147 &NewDevice {
1148 scope: "root",
1149 ..device("dev_x", "x", None)
1150 },
1151 None
1152 )
1153 .is_err());
1154 }
1155
1156 #[test]
1160 fn the_rebuild_keeps_every_column() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let path = dir.path().join("r.db");
1163 {
1164 let conn = Connection::open(&path).unwrap();
1165 conn.execute_batch(&SCHEMA.replace("'sync', 'admin', 'worker'", "'sync', 'admin'"))
1166 .unwrap();
1167 conn.execute(
1168 "INSERT INTO devices (id, owner_id, name, public_key, scope, agent, ephemeral,
1169 authkey_id, created_at, last_seen, revoked_at)
1170 VALUES ('dev_gone', 'owner', 'old laptop', ?1, 'sync', 'recall/0.4.0', 0,
1171 NULL, ?2, ?3, ?4),
1172 ('dev_cloud', 'owner', 'cloud-ab12', ?1, 'sync', 'recall/0.4.1', 1,
1173 'akey_x', ?2, ?3, NULL)",
1174 (KEY, ts(0), ts(1), ts(2)),
1175 )
1176 .unwrap();
1177 }
1178 let st = Store::open(&path).unwrap();
1179 let gone = st.device("dev_gone").unwrap().unwrap();
1180 assert_eq!(gone.revoked_at, Some(ts(2)), "still revoked");
1181 assert_eq!(
1182 (gone.name.as_str(), gone.agent.as_str(), gone.last_seen),
1183 ("old laptop", "recall/0.4.0", Some(ts(1)))
1184 );
1185 let cloud = st.device("dev_cloud").unwrap().unwrap();
1186 assert!(cloud.ephemeral);
1187 assert_eq!(cloud.authkey_id.as_deref(), Some("akey_x"));
1188 assert_eq!(cloud.revoked_at, None);
1189 assert_eq!(cloud.created_at, ts(0));
1190 }
1191
1192 #[test]
1196 fn a_devices_table_with_other_columns_is_not_rebuilt() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let path = dir.path().join("r.db");
1199 {
1200 let conn = Connection::open(&path).unwrap();
1201 conn.execute_batch(&SCHEMA.replace("'sync', 'admin', 'worker'", "'sync', 'admin'"))
1202 .unwrap();
1203 conn.execute_batch(
1204 "ALTER TABLE devices ADD COLUMN encryption_key TEXT;
1205 INSERT INTO devices (id, name, public_key, scope, created_at, encryption_key)
1206 VALUES ('dev_a', 'laptop', 'k', 'sync', 'x', 'kept');",
1207 )
1208 .unwrap();
1209 }
1210 let err = Store::open(&path).err().expect("refused").to_string();
1211 assert!(err.contains("encryption_key"), "{err}");
1212 let conn = Connection::open(&path).unwrap();
1213 let kept: String = conn
1214 .query_row("SELECT encryption_key FROM devices", [], |r| r.get(0))
1215 .unwrap();
1216 assert_eq!(kept, "kept");
1217 }
1218
1219 #[test]
1222 fn an_admin_device_is_no_worker() {
1223 let st = Store::open_in_memory().unwrap();
1224 for (id, scope) in [("dev_a", "admin"), ("dev_s", "sync")] {
1225 inserted(
1226 &st,
1227 &NewDevice {
1228 scope,
1229 ..device(id, id, None)
1230 },
1231 );
1232 }
1233 assert!(st.enrolled_worker().unwrap().is_none());
1234 }
1235
1236 #[test]
1237 fn a_revoked_worker_is_no_worker() {
1238 let st = Store::open_in_memory().unwrap();
1239 assert!(st.enrolled_worker().unwrap().is_none());
1240 inserted(
1241 &st,
1242 &NewDevice {
1243 scope: "worker",
1244 ..device("dev_w", "worker", None)
1245 },
1246 );
1247 assert!(st.enrolled_worker().unwrap().is_some());
1248 st.revoke_device("dev_w", &ts(1)).unwrap();
1249 assert!(st.enrolled_worker().unwrap().is_none());
1250 }
1251
1252 #[test]
1253 fn the_tables_are_created_once_and_reopening_keeps_them() {
1254 let dir = tempfile::tempdir().unwrap();
1255 let path = dir.path().join("r.db");
1256 {
1257 let st = Store::open(&path).unwrap();
1258 assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
1259 }
1260 let st = Store::open(&path).unwrap();
1261 assert_eq!(
1262 st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
1263 .unwrap(),
1264 Poll::Pending
1265 );
1266 }
1267
1268 #[test]
1269 fn a_code_in_use_is_refused_and_the_waiting_list_is_capped() {
1270 let st = Store::open_in_memory().unwrap();
1271 assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
1272 assert_eq!(enroll(&st, "enr_b", "BCDF-GHJK", 1), Created::CodeTaken);
1273 assert_eq!(enroll(&st, "enr_b", "BCDF-GHJL", 1), Created::Created);
1274 assert_eq!(enroll(&st, "enr_c", "BCDF-GHJM", 2), Created::Created);
1275 assert_eq!(enroll(&st, "enr_d", "BCDF-GHJN", 3), Created::Full);
1276 assert_eq!(enroll(&st, "enr_d", "BCDF-GHJK", 1000), Created::Created);
1278 }
1279
1280 #[test]
1283 fn one_address_may_have_only_so_many_waiting() {
1284 let st = Store::open_in_memory().unwrap();
1285 let from = |id, code, ip| enroll_from(&st, id, code, 0, ip);
1286 assert_eq!(from("enr_a", "BCDF-GHJK", "198.51.100.4"), Created::Created);
1287 assert_eq!(from("enr_b", "BCDF-GHJL", "198.51.100.4"), Created::Created);
1288 assert_eq!(
1289 from("enr_c", "BCDF-GHJM", "198.51.100.4"),
1290 Created::AddressFull
1291 );
1292 assert_eq!(from("enr_c", "BCDF-GHJM", "198.51.100.5"), Created::Created);
1293 st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap();
1295 assert_eq!(from("enr_d", "BCDF-GHJN", "198.51.100.4"), Created::Created);
1296 }
1297
1298 #[test]
1299 fn a_poll_follows_rfc8628() {
1300 let st = Store::open_in_memory().unwrap();
1301 let every = Duration::from_secs(5);
1302 enroll(&st, "enr_a", "BCDF-GHJK", 0);
1303 assert_eq!(
1304 st.poll_enrollment("enr_a", at(1), every).unwrap(),
1305 Poll::Pending
1306 );
1307 assert_eq!(
1308 st.poll_enrollment("enr_a", at(3), every).unwrap(),
1309 Poll::SlowDown
1310 );
1311 assert_eq!(
1313 st.poll_enrollment("enr_a", at(7), every).unwrap(),
1314 Poll::Pending
1315 );
1316 assert_eq!(
1317 st.poll_enrollment("enr_x", at(8), every).unwrap(),
1318 Poll::Unknown
1319 );
1320 assert_eq!(
1321 st.poll_enrollment("enr_a", at(900), every).unwrap(),
1322 Poll::Expired
1323 );
1324
1325 enroll(&st, "enr_b", "BCDF-GHJL", 0);
1326 let Decision::Done(device) = st
1327 .approve_enrollment("BCDF-GHJL", "dev_1", "admin", &ts(10), None)
1328 .unwrap()
1329 else {
1330 panic!("not approved");
1331 };
1332 assert_eq!(
1333 (device.name.as_str(), device.scope.as_str()),
1334 ("laptop", "admin")
1335 );
1336 assert!(!device.fingerprint.is_empty());
1337 assert_eq!(
1339 st.poll_enrollment("enr_b", at(2000), every).unwrap(),
1340 Poll::Approved {
1341 device_id: "dev_1".into(),
1342 scope: "admin".into()
1343 }
1344 );
1345 st.revoke_device("dev_1", &ts(20)).unwrap();
1346 assert_eq!(
1347 st.poll_enrollment("enr_b", at(30), every).unwrap(),
1348 Poll::Denied
1349 );
1350 }
1351
1352 #[test]
1353 fn a_code_is_decided_once() {
1354 let st = Store::open_in_memory().unwrap();
1355 enroll(&st, "enr_a", "BCDF-GHJK", 0);
1356 let Decision::Done(waiting) = st.pending_enrollment("BCDF-GHJK", &ts(1)).unwrap() else {
1357 panic!("not waiting");
1358 };
1359 assert_eq!(
1360 (waiting.name.as_str(), waiting.expires_at),
1361 ("laptop", ts(900))
1362 );
1363 assert_eq!(
1364 st.pending_enrollment("BCDF-GHJK", &ts(900)).unwrap(),
1365 Decision::Expired
1366 );
1367 assert_eq!(
1368 st.pending_enrollment("ZZZZ-ZZZZ", &ts(1)).unwrap(),
1369 Decision::NotFound
1370 );
1371 assert_eq!(
1372 st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap(),
1373 Decision::Done("laptop".into())
1374 );
1375 assert_eq!(
1376 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), None)
1377 .unwrap(),
1378 Decision::AlreadyDecided
1379 );
1380 assert_eq!(
1381 st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
1382 .unwrap(),
1383 Poll::Denied
1384 );
1385 assert_eq!(
1386 st.approve_enrollment("ZZZZ-ZZZZ", "dev_1", "sync", &ts(2), None)
1387 .unwrap(),
1388 Decision::NotFound
1389 );
1390 enroll(&st, "enr_b", "BCDF-GHJL", 0);
1391 assert_eq!(
1392 st.approve_enrollment("BCDF-GHJL", "dev_1", "sync", &ts(901), None)
1393 .unwrap(),
1394 Decision::Expired
1395 );
1396 assert!(st.devices().unwrap().is_empty(), "nothing was approved");
1397 }
1398
1399 #[test]
1401 fn an_approval_is_bound_to_the_fingerprint_it_names() {
1402 let st = Store::open_in_memory().unwrap();
1403 enroll(&st, "enr_a", "BCDF-GHJK", 0);
1404 let right = fingerprint(&parse_public_key(KEY).unwrap());
1405 assert_eq!(
1406 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(1), Some("SHA256:other"))
1407 .unwrap(),
1408 Decision::KeyMismatch
1409 );
1410 assert!(st.devices().unwrap().is_empty(), "nothing was approved");
1411 assert!(matches!(
1412 st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), Some(&right))
1413 .unwrap(),
1414 Decision::Done(_)
1415 ));
1416 }
1417
1418 #[test]
1421 fn names_are_unique_among_devices_not_revoked() {
1422 let st = Store::open_in_memory().unwrap();
1423 inserted(&st, &device("dev_1", "laptop", None));
1424 assert_eq!(
1425 st.insert_device(&device("dev_2", "Laptop", None), None)
1426 .unwrap(),
1427 Inserted::NameTaken
1428 );
1429
1430 enroll(&st, "enr_a", "BCDF-GHJK", 0);
1431 assert_eq!(
1432 st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(1), None)
1433 .unwrap(),
1434 Decision::NameTaken("laptop".into())
1435 );
1436
1437 st.revoke_device("dev_1", &ts(2)).unwrap();
1438 assert!(matches!(
1439 st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(3), None)
1440 .unwrap(),
1441 Decision::Done(_)
1442 ));
1443 }
1444
1445 #[test]
1448 fn names_that_look_alike_are_one_name() {
1449 for (a, b) in [
1450 ("laptop", "l\u{0430}ptop"),
1452 ("caf\u{00E9}", "cafe\u{0301}"),
1454 ("STRASSE", "Stra\u{00DF}e"),
1455 ("laptop", "LAPTOP"),
1456 ("laptop", "1aptop"),
1457 ("Kiosk", "\u{041A}iosk"),
1459 ("file", "\u{FB01}le"),
1461 ("desk", "\u{FF44}\u{FF45}\u{FF53}\u{FF4B}"),
1462 ] {
1463 for (taken, wanted) in [(a, b), (b, a)] {
1464 let st = Store::open_in_memory().unwrap();
1465 inserted(&st, &device("dev_1", taken, None));
1466 assert_eq!(
1467 st.insert_device(&device("dev_2", wanted, None), None)
1468 .unwrap(),
1469 Inserted::NameTaken,
1470 "{wanted:?} beside {taken:?}"
1471 );
1472 }
1473 }
1474
1475 let st = Store::open_in_memory().unwrap();
1477 for (i, name) in ["laptop", "laptops", "lapdog", "desk", "desk-2"]
1478 .into_iter()
1479 .enumerate()
1480 {
1481 inserted(&st, &device(&format!("dev_{i}"), name, None));
1482 }
1483 }
1484
1485 #[test]
1486 fn a_name_is_stored_composed_and_trimmed() {
1487 assert_eq!(plain_name(" cafe\u{0301} "), "caf\u{00E9}");
1488 assert_eq!(plain_name("laptop"), "laptop");
1489 }
1490
1491 #[test]
1492 fn revoking_keeps_the_first_time_and_the_row() {
1493 let st = Store::open_in_memory().unwrap();
1494 inserted(&st, &device("dev_1", "laptop", None));
1495 let first = st.revoke_device("dev_1", &ts(1)).unwrap().unwrap();
1496 let again = st.revoke_device("dev_1", &ts(2)).unwrap().unwrap();
1497 assert_eq!(first.revoked_at, Some(ts(1)));
1498 assert_eq!(again.revoked_at, Some(ts(1)));
1499 assert!(st.revoke_device("dev_none", &ts(3)).unwrap().is_none());
1500 assert_eq!(st.devices().unwrap().len(), 1);
1501 }
1502
1503 #[test]
1504 fn only_idle_ephemeral_devices_and_long_expired_enrolments_are_swept() {
1505 let st = Store::open_in_memory().unwrap();
1506 for (id, ephemeral) in [("dev_kept", false), ("dev_idle", true), ("dev_busy", true)] {
1507 inserted(
1508 &st,
1509 &NewDevice {
1510 ephemeral,
1511 created_at: &ts(0),
1512 ..device(id, id, None)
1513 },
1514 );
1515 }
1516 st.touch_device("dev_busy", &ts(5000)).unwrap();
1517 enroll(&st, "enr_old", "BCDF-GHJK", 0);
1518 enroll(&st, "enr_new", "BCDF-GHJL", 4000);
1519
1520 let (devices, enrollments) = st.sweep_devices(&ts(3600), &ts(3600)).unwrap();
1521 assert_eq!((devices, enrollments), (1, 1));
1522 let left: Vec<String> = st.devices().unwrap().into_iter().map(|d| d.id).collect();
1523 assert!(left.contains(&"dev_kept".to_string()) && left.contains(&"dev_busy".to_string()));
1524 assert_eq!(
1525 st.poll_enrollment("enr_old", at(3700), Duration::from_secs(5))
1526 .unwrap(),
1527 Poll::Unknown
1528 );
1529 }
1530
1531 fn key(st: &Store, max_devices: Option<u32>) {
1532 st.insert_authkey(&NewAuthkey {
1533 id: "ak_1",
1534 key_sha256: "abc",
1535 tag: "cloud",
1536 ephemeral: true,
1537 max_devices,
1538 created_at: &ts(0),
1539 expires_at: &ts(86400),
1540 })
1541 .unwrap();
1542 }
1543
1544 #[test]
1545 fn authkeys_are_found_by_hash_and_revoked_once() {
1546 let st = Store::open_in_memory().unwrap();
1547 key(&st, None);
1548 let found = st.authkey_by_hash("abc").unwrap().unwrap();
1549 assert_eq!(
1550 (found.id.as_str(), found.ephemeral, found.max_devices),
1551 ("ak_1", true, None)
1552 );
1553 assert!(st.authkey_by_hash("abd").unwrap().is_none());
1554 let revoked = st.revoke_authkey("ak_1", &ts(1), false).unwrap().unwrap();
1555 assert_eq!(revoked.revoked_at, Some(ts(1)));
1556 assert_eq!(
1557 st.revoke_authkey("ak_1", &ts(2), false)
1558 .unwrap()
1559 .unwrap()
1560 .revoked_at,
1561 Some(ts(1))
1562 );
1563 assert_eq!(st.authkeys().unwrap().len(), 1);
1564 }
1565
1566 #[test]
1570 fn a_key_enrols_no_more_than_its_cap() {
1571 let st = Store::open_in_memory().unwrap();
1572 key(&st, Some(2));
1573 inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
1574 assert!(matches!(
1575 st.insert_device(&device("dev_2", "cloud-2", Some("ak_1")), Some(2))
1576 .unwrap(),
1577 Inserted::Done(_)
1578 ));
1579 assert_eq!(
1580 st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
1581 .unwrap(),
1582 Inserted::KeyFull
1583 );
1584 st.revoke_device("dev_1", &ts(1)).unwrap();
1585 assert!(matches!(
1586 st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
1587 .unwrap(),
1588 Inserted::Done(_)
1589 ));
1590 }
1591
1592 #[test]
1593 fn revoking_a_key_can_revoke_what_it_enrolled() {
1594 let st = Store::open_in_memory().unwrap();
1595 key(&st, None);
1596 inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
1597 inserted(&st, &device("dev_2", "laptop", None));
1598 st.revoke_authkey("ak_1", &ts(1), true).unwrap();
1599 let revoked: Vec<(String, bool)> = st
1600 .devices()
1601 .unwrap()
1602 .into_iter()
1603 .map(|d| (d.id, d.revoked_at.is_some()))
1604 .collect();
1605 assert!(revoked.contains(&("dev_1".into(), true)));
1606 assert!(
1607 revoked.contains(&("dev_2".into(), false)),
1608 "only the key's own devices"
1609 );
1610 }
1611}