Skip to main content

recall_server/store/
devices.rs

1//! Devices, the enrolments waiting for approval, and authkeys.
2//!
3//! Every timestamp is stored in [`crate::now`]'s format. That is the format
4//! the API answers in, and, being fixed-width, it compares correctly as a
5//! string, so expiry and idleness are plain `<` in SQL.
6
7use 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
19/// Created alongside `memory_files`, every time the store opens: `IF NOT
20/// EXISTS` makes that a no-op once they exist.
21pub(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        // Only a key that parsed was ever stored.
85        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
108/// A device name, or an authkey's tag, as it is stored: trimmed, and
109/// in Unicode's composed form (NFC), so the same letters typed as one
110/// character or as a letter and its accent are stored alike.
111pub fn plain_name(name: &str) -> String {
112    name.trim().nfc().collect()
113}
114
115/// What a name is compared by: two keys, and two names are the same name
116/// when either key is.
117///
118/// Both begin with NFKC, so a name typed decomposed, or with a ligature or
119/// a full-width letter, is the name typed plainly. Then each takes the
120/// name in one case and reduces it to its confusable skeleton (UTS #39),
121/// which maps every character to the one it can be mistaken for, so
122/// `lаptop` with a Cyrillic `а`, or `1aptop`, is `laptop`. It takes two
123/// because a pair can look alike in one case and not the other: Cyrillic
124/// `к` does not look like `k`, but `К` looks like `K`; and lowercasing
125/// keeps `ß` apart from `ss`, where uppercasing makes it `SS`, so
126/// `Straße` is `STRASSE`.
127fn 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
136/// Whether an unrevoked device already has `name`, or one a person would
137/// read as it (see [`name_keys`]), so neither `Laptop` nor `lаptop` can
138/// stand beside `laptop`. A revoked device's name is free again.
139fn 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/// A device about to be stored.
173#[derive(Debug, Clone)]
174pub struct NewDevice<'a> {
175    /// `dev_…`.
176    pub id: &'a str,
177    /// What the owner sees it as.
178    pub name: &'a str,
179    /// Base64url, as `recall_wire::signature::encode_public_key` writes it.
180    pub public_key: &'a str,
181    /// `sync` or `admin`.
182    pub scope: &'a str,
183    /// The client's `User-Agent`.
184    pub agent: &'a str,
185    /// Removed once idle, when true.
186    pub ephemeral: bool,
187    /// The authkey it came in with, if any.
188    pub authkey_id: Option<&'a str>,
189    /// Now.
190    pub created_at: &'a str,
191}
192
193/// An enrolment about to be stored.
194#[derive(Debug, Clone)]
195pub struct NewEnrollment<'a> {
196    /// `enr_…`: the secret the machine polls with.
197    pub enrollment_id: &'a str,
198    /// `XXXX-XXXX`.
199    pub user_code: &'a str,
200    /// What the machine asked to be called.
201    pub name: &'a str,
202    /// Its public key, base64url.
203    pub public_key: &'a str,
204    /// Its `User-Agent`.
205    pub agent: &'a str,
206    /// Now.
207    pub created_at: &'a str,
208    /// When the code stops being approvable.
209    pub expires_at: &'a str,
210    /// The address it came from, as the rate limiter keys it.
211    pub client_ip: &'a str,
212}
213
214/// What storing an enrolment came to.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Created {
217    /// Stored.
218    Created,
219    /// Another enrolment still waiting has that user code; pick another.
220    CodeTaken,
221    /// Too many enrolments are waiting already.
222    Full,
223    /// Too many enrolments from this address are waiting already.
224    AddressFull,
225}
226
227/// What storing a device came to.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum Inserted {
230    /// Stored. Boxed: the refusals are small, and a device is not.
231    Done(Box<Device>),
232    /// An unrevoked device already has that name.
233    NameTaken,
234    /// Its authkey already has as many unrevoked devices as it may.
235    KeyFull,
236}
237
238/// An authkey about to be stored.
239#[derive(Debug, Clone)]
240pub struct NewAuthkey<'a> {
241    /// `ak_…`.
242    pub id: &'a str,
243    /// SHA-256 of the key, lowercase hex.
244    pub key_sha256: &'a str,
245    /// Its label.
246    pub tag: &'a str,
247    /// Whether it enrols ephemeral devices.
248    pub ephemeral: bool,
249    /// The most unrevoked devices it may have enrolled at once.
250    pub max_devices: Option<u32>,
251    /// Now.
252    pub created_at: &'a str,
253    /// When it stops working.
254    pub expires_at: &'a str,
255}
256
257/// Where an enrolment stands when its machine polls, in RFC 8628 §3.5's
258/// terms.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Poll {
261    /// Approved: here is the device.
262    Approved {
263        /// Its id.
264        device_id: String,
265        /// Its scope.
266        scope: String,
267    },
268    /// Still waiting.
269    Pending,
270    /// Still waiting, and asked too soon.
271    SlowDown,
272    /// Nobody approved it in time.
273    Expired,
274    /// Denied, or approved and then revoked.
275    Denied,
276    /// No such enrolment, or one swept away long after it expired.
277    Unknown,
278}
279
280/// What approving or denying a code came to.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum Decision<T> {
283    /// Done.
284    Done(T),
285    /// No enrolment has that code.
286    NotFound,
287    /// It had, but the code expired.
288    Expired,
289    /// It was approved or denied already.
290    AlreadyDecided,
291    /// The approver named a key fingerprint, and the code's key has
292    /// another.
293    KeyMismatch,
294    /// An unrevoked device already has the name it asked for.
295    NameTaken(String),
296}
297
298/// The newest enrolment with `user_code`: `(enrollment_id, name,
299/// public_key, agent, expires_at, decided)`.
300type Pending = (String, String, String, String, String, bool);
301
302/// An enrolment still waiting for a decision, as an approver is shown it.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct Waiting {
305    /// What the machine asked to be called.
306    pub name: String,
307    /// Its public key, base64url.
308    pub public_key: String,
309    /// Its `User-Agent`.
310    pub agent: String,
311    /// When its code stops being approvable.
312    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    /// Stores a pending enrolment, unless its code is in use by another
339    /// one still waiting, `max_pending` are waiting already, or
340    /// `max_per_address` from its address are.
341    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        // Waiting means unexpired and undecided. Counting and inserting
349        // under one lock is what makes the cap and the code's uniqueness
350        // hold under concurrent requests.
351        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    /// Answers a machine's poll, and records when it asked.
396    ///
397    /// A poll sooner than `interval` after the last one is told to slow
398    /// down. A second of slack keeps a client that sleeps exactly the
399    /// interval from being told so because its previous request spent a
400    /// moment in flight.
401    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        // Approval wins over expiry: a code approved in its last second
433        // is still collected by the poll after it.
434        if let Some(device_id) = device_id {
435            return Ok(match (scope, revoked_at) {
436                (Some(scope), None) => Poll::Approved { device_id, scope },
437                // Revoked, or an ephemeral device already swept: either
438                // way the owner no longer wants it.
439                _ => 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    /// Approves the enrolment waiting with `user_code`, making it device
465    /// `device_id`. When `expected_fingerprint` is given, the code's key
466    /// must have exactly that fingerprint.
467    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        // Checked under the same lock as the insert, so two approvals of
496        // two machines both called `laptop` cannot both succeed.
497        if name_taken(&conn, &name)? {
498            return Ok(Decision::NameTaken(name));
499        }
500        // One transaction, so a device never exists without the enrolment
501        // that made it knowing, and a crash between the two leaves neither.
502        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    /// Denies the enrolment waiting with `user_code`, answering with the
526    /// name it asked for.
527    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    /// What the enrolment waiting with `user_code` asked for, judged the
548    /// way approving it would be, so a lookup and the approval after it
549    /// never disagree about whether the code is still good.
550    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    /// Stores a device, unless an unrevoked one has its name or, when
572    /// `max_for_key` is given, its authkey already has that many
573    /// unrevoked devices. Both are checked under the lock the insert holds.
574    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    /// One device, revoked or not.
596    pub fn device(&self, id: &str) -> Result<Option<Device>> {
597        get_device(&self.lock(), id)
598    }
599
600    /// Every device, newest first.
601    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    /// Revokes a device. Revoking one already revoked keeps the first
611    /// time. [`None`] when there is no such device.
612    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    /// Records that a device was just seen.
622    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    /// Stores an authkey's hash and details.
629    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    /// The authkey whose SHA-256 is `key_sha256`, in any state.
649    pub fn authkey_by_hash(&self, key_sha256: &str) -> Result<Option<Authkey>> {
650        get_authkey(&self.lock(), "key_sha256", key_sha256)
651    }
652
653    /// Every authkey, newest first.
654    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    /// Revokes an authkey, keeping the first time if it already
664    /// was, and with `devices` every device it enrolled too; without it
665    /// they are untouched. [`None`] when there is no such key.
666    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    /// Removes ephemeral devices last seen (or, never seen, created)
684    /// before `idle_before`, and enrolments that expired before
685    /// `expired_before`. Answers how many of each went.
686    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        // Each from its own address, so only the tests about the address
753        // cap meet it.
754        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        // Once they expire they no longer count, and their codes are free.
802        assert_eq!(enroll(&st, "enr_d", "BCDF-GHJK", 1000), Created::Created);
803    }
804
805    /// One address cannot hold the whole waiting list: the cap the review
806    /// asked for, keyed the way the rate limiter keys it.
807    #[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        // A decided one no longer counts against its address.
819        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        // Four seconds after the last poll is within the slack.
837        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        // Approved in time and collected late is still collected.
863        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    /// An approval that names a fingerprint approves only that key.
925    #[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    /// Two live devices may not share a name, in any case; a revoked one's
944    /// name is free again.
945    #[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    /// Verification finding N2: names a person reads as one name are one
971    /// name, whatever characters spell them, in either order.
972    #[test]
973    fn names_that_look_alike_are_one_name() {
974        for (a, b) in [
975            // A Cyrillic а.
976            ("laptop", "l\u{0430}ptop"),
977            // é as one character, and as e and a combining accent.
978            ("caf\u{00E9}", "cafe\u{0301}"),
979            ("STRASSE", "Stra\u{00DF}e"),
980            ("laptop", "LAPTOP"),
981            ("laptop", "1aptop"),
982            // A Cyrillic К, which looks like K only as a capital.
983            ("Kiosk", "\u{041A}iosk"),
984            // A ligature, and full-width letters.
985            ("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        // Names that merely share letters are still two names.
1001        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    /// A key's device cap counts the devices it enrolled that are still
1092    /// there and unrevoked, so a leaked key cannot mint them without end,
1093    /// and a legitimate one frees a place each time one goes.
1094    #[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}