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::{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
20/// The `devices` table's columns, for creating it and for rebuilding it
21/// (see [`allow_worker_scope`]). A macro so both can be one literal.
22macro_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
42/// Created alongside `memory_files`, every time the store opens: `IF NOT
43/// EXISTS` makes that a no-op once they exist.
44pub(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
84/// Lets `devices.scope` be `worker`, on a database made before it could.
85///
86/// SQLite cannot change a `CHECK` constraint in place, so the table is
87/// rebuilt: created under another name with the new constraint, the rows
88/// copied, the old table dropped and the new one renamed, in one
89/// transaction, so a crash leaves either the old table or the new one and
90/// never neither. The rows are untouched, so an older server reading the
91/// rebuilt table sees exactly what it wrote.
92///
93/// A `worker` row is the exception to that, and why a rollback revokes the
94/// worker first (`deploy/README.md`). A server from before the scope that
95/// decides by `admin` alone treats everything else as `sync`: to it, an
96/// unrevoked worker is a device that may pull and push every project's
97/// memory. 0.4.1 guards against exactly this, refusing a device whose scope
98/// it does not know, so there a worker can do nothing; a revoked one can do
99/// nothing on any server.
100///
101/// Guarded by the table's own definition rather than `PRAGMA
102/// user_version`: whether the constraint allows `worker` is exactly the
103/// question, and a version number is a second thing to keep in step with
104/// it, one another change to the schema could also want to move.
105///
106/// The copy names [`REBUILT_COLUMNS`], so it first checks the table has
107/// exactly those. A table with another column (one a later release added,
108/// then rolled back past) would lose it in the copy without a word; it is
109/// refused instead, before anything changes, and the server does not
110/// start until someone looks.
111pub(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
154/// Every column of `devices`, in its order: what [`allow_worker_scope`]
155/// copies, and so what it checks the table has before copying.
156const 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        // Only a key that parsed was ever stored.
184        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
207/// A device name, or an authkey's tag, as it is stored: trimmed, and
208/// in Unicode's composed form (NFC), so the same letters typed as one
209/// character or as a letter and its accent are stored alike.
210pub fn plain_name(name: &str) -> String {
211    name.trim().nfc().collect()
212}
213
214/// What a name is compared by: two keys, and two names are the same name
215/// when either key is.
216///
217/// Both begin with NFKC, so a name typed decomposed, or with a ligature or
218/// a full-width letter, is the name typed plainly. Then each takes the
219/// name in one case and reduces it to its confusable skeleton (UTS #39),
220/// which maps every character to the one it can be mistaken for, so
221/// `lаptop` with a Cyrillic `а`, or `1aptop`, is `laptop`. It takes two
222/// because a pair can look alike in one case and not the other: Cyrillic
223/// `к` does not look like `k`, but `К` looks like `K`; and lowercasing
224/// keeps `ß` apart from `ss`, where uppercasing makes it `SS`, so
225/// `Straße` is `STRASSE`.
226fn 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
235/// Whether an unrevoked device already has `name`, or one a person would
236/// read as it (see [`name_keys`]), so neither `Laptop` nor `lаptop` can
237/// stand beside `laptop`. A revoked device's name is free again.
238fn 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/// A device about to be stored.
272#[derive(Debug, Clone)]
273pub struct NewDevice<'a> {
274    /// `dev_…`.
275    pub id: &'a str,
276    /// What the owner sees it as.
277    pub name: &'a str,
278    /// Base64url, as `recall_wire::signature::encode_public_key` writes it.
279    pub public_key: &'a str,
280    /// `sync` or `admin`.
281    pub scope: &'a str,
282    /// The client's `User-Agent`.
283    pub agent: &'a str,
284    /// Removed once idle, when true.
285    pub ephemeral: bool,
286    /// The authkey it came in with, if any.
287    pub authkey_id: Option<&'a str>,
288    /// Now.
289    pub created_at: &'a str,
290}
291
292/// An enrolment about to be stored.
293#[derive(Debug, Clone)]
294pub struct NewEnrollment<'a> {
295    /// `enr_…`: the secret the machine polls with.
296    pub enrollment_id: &'a str,
297    /// `XXXX-XXXX`.
298    pub user_code: &'a str,
299    /// What the machine asked to be called.
300    pub name: &'a str,
301    /// Its public key, base64url.
302    pub public_key: &'a str,
303    /// Its `User-Agent`.
304    pub agent: &'a str,
305    /// Now.
306    pub created_at: &'a str,
307    /// When the code stops being approvable.
308    pub expires_at: &'a str,
309    /// The address it came from, as the rate limiter keys it.
310    pub client_ip: &'a str,
311}
312
313/// What storing an enrolment came to.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Created {
316    /// Stored.
317    Created,
318    /// Another enrolment still waiting has that user code; pick another.
319    CodeTaken,
320    /// Too many enrolments are waiting already.
321    Full,
322    /// Too many enrolments from this address are waiting already.
323    AddressFull,
324}
325
326/// What storing a device came to.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum Inserted {
329    /// Stored. Boxed: the refusals are small, and a device is not.
330    Done(Box<Device>),
331    /// An unrevoked device already has that name.
332    NameTaken,
333    /// Its authkey already has as many unrevoked devices as it may.
334    KeyFull,
335}
336
337/// An authkey about to be stored.
338#[derive(Debug, Clone)]
339pub struct NewAuthkey<'a> {
340    /// `ak_…`.
341    pub id: &'a str,
342    /// SHA-256 of the key, lowercase hex.
343    pub key_sha256: &'a str,
344    /// Its label.
345    pub tag: &'a str,
346    /// Whether it enrols ephemeral devices.
347    pub ephemeral: bool,
348    /// The most unrevoked devices it may have enrolled at once.
349    pub max_devices: Option<u32>,
350    /// Now.
351    pub created_at: &'a str,
352    /// When it stops working.
353    pub expires_at: &'a str,
354}
355
356/// Where an enrolment stands when its machine polls, in RFC 8628 §3.5's
357/// terms.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum Poll {
360    /// Approved: here is the device.
361    Approved {
362        /// Its id.
363        device_id: String,
364        /// Its scope.
365        scope: String,
366    },
367    /// Still waiting.
368    Pending,
369    /// Still waiting, and asked too soon.
370    SlowDown,
371    /// Nobody approved it in time.
372    Expired,
373    /// Denied, or approved and then revoked.
374    Denied,
375    /// No such enrolment, or one swept away long after it expired.
376    Unknown,
377}
378
379/// What approving or denying a code came to.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum Decision<T> {
382    /// Done.
383    Done(T),
384    /// No enrolment has that code.
385    NotFound,
386    /// It had, but the code expired.
387    Expired,
388    /// It was approved or denied already.
389    AlreadyDecided,
390    /// The approver named a key fingerprint, and the code's key has
391    /// another.
392    KeyMismatch,
393    /// An unrevoked device already has the name it asked for.
394    NameTaken(String),
395}
396
397/// The newest enrolment with `user_code`: `(enrollment_id, name,
398/// public_key, agent, expires_at, decided)`.
399type Pending = (String, String, String, String, String, bool);
400
401/// An enrolment still waiting for a decision, as an approver is shown it.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct Waiting {
404    /// What the machine asked to be called.
405    pub name: String,
406    /// Its public key, base64url.
407    pub public_key: String,
408    /// Its `User-Agent`.
409    pub agent: String,
410    /// When its code stops being approvable.
411    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    /// Stores a pending enrolment, unless its code is in use by another
438    /// one still waiting, `max_pending` are waiting already, or
439    /// `max_per_address` from its address are.
440    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        // Waiting means unexpired and undecided. Counting and inserting
448        // under one lock is what makes the cap and the code's uniqueness
449        // hold under concurrent requests.
450        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    /// Answers a machine's poll, and records when it asked.
495    ///
496    /// A poll sooner than `interval` after the last one is told to slow
497    /// down. A second of slack keeps a client that sleeps exactly the
498    /// interval from being told so because its previous request spent a
499    /// moment in flight.
500    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        // Approval wins over expiry: a code approved in its last second
532        // is still collected by the poll after it.
533        if let Some(device_id) = device_id {
534            return Ok(match (scope, revoked_at) {
535                (Some(scope), None) => Poll::Approved { device_id, scope },
536                // Revoked, or an ephemeral device already swept: either
537                // way the owner no longer wants it.
538                _ => 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    /// Approves the enrolment waiting with `user_code`, making it device
564    /// `device_id`, and appends the `approve` leaf `build_leaf` makes in
565    /// the same transaction. When `expected_fingerprint` is given, the
566    /// code's key must have exactly that fingerprint.
567    ///
568    /// `build_leaf` is called only once the device is real, so it can carry
569    /// the device's own public key: the leaf that lets a signature of this
570    /// device's be checked after the row itself is gone. A refusal
571    /// (`NotFound`, `Expired`, `KeyMismatch`, `NameTaken`, or
572    /// `AlreadyDecided`) rolls back and appends nothing.
573    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                // Checked in the same transaction as the insert, so two
604                // approvals of two machines both called `laptop` cannot
605                // both succeed.
606                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                // One transaction, so a device never exists without the
623                // enrolment that made it knowing, and a crash between the
624                // two leaves neither.
625                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    /// Denies the enrolment waiting with `user_code`, answering with the
640    /// name it asked for, and appends the `deny` leaf in the same
641    /// transaction.
642    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    /// What the enrolment waiting with `user_code` asked for, judged the
675    /// way approving it would be, so a lookup and the approval after it
676    /// never disagree about whether the code is still good.
677    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    /// Stores a device an authkey enrolled, and the `enroll` leaf
699    /// `build_leaf` makes for it, in one transaction — unless an unrevoked
700    /// device has its name or, when `max_for_key` is given, its authkey
701    /// already has that many unrevoked devices, both checked in the same
702    /// transaction, which then appends nothing.
703    ///
704    /// The one way a device comes to exist without an approval, so its leaf
705    /// is what carries its public key into the log: without it, nothing the
706    /// device later signs could be checked offline once it is swept.
707    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    /// One device, revoked or not.
740    pub fn device(&self, id: &str) -> Result<Option<Device>> {
741        get_device(&self.lock(), id)
742    }
743
744    /// The newest unrevoked device with the `worker` scope. While there is
745    /// one, a stale push is queued for it rather than merged inline.
746    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    /// Every device, newest first.
762    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    /// Revokes a device, with a `revoke` leaf appended when this call is
772    /// what revoked it. Revoking one already revoked keeps the first time,
773    /// changes nothing and appends nothing — there is no new fact for a
774    /// leaf to record. [`None`] when there is no such device.
775    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    /// Records that a device was just seen. Not a change the audit log
809    /// records: see the module docs of `store/audit.rs`.
810    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    /// Stores an authkey's hash and details, with its `authkey_create` leaf
817    /// appended in the same transaction.
818    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    /// The authkey whose SHA-256 is `key_sha256`, in any state.
847    pub fn authkey_by_hash(&self, key_sha256: &str) -> Result<Option<Authkey>> {
848        get_authkey(&self.lock(), "key_sha256", key_sha256)
849    }
850
851    /// Every authkey, newest first.
852    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    /// Revokes an authkey, keeping the first time if it already was, and
862    /// with `devices` every device it enrolled that is not revoked yet;
863    /// without it they are untouched. [`None`] when there is no such key.
864    ///
865    /// One `authkey_revoke` leaf is appended when this call changed
866    /// anything — revoked the key, or any device — naming the devices it
867    /// revoked (`build_leaf`'s last argument), rather than one leaf more per
868    /// cascaded device. A call that changes nothing, such as the same
869    /// revoke sent twice, appends nothing. Asking for the devices of a key
870    /// revoked earlier on its own does revoke them, and is recorded.
871    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    /// Removes ephemeral devices last seen (or, never seen, created)
916    /// before `idle_before`, and enrolments that expired before
917    /// `expired_before`. Answers how many of each went.
918    ///
919    /// One transaction, with a `sweep` leaf for each device it removes,
920    /// the server as their actor: the devices chosen and the devices
921    /// deleted are the same rows, read and deleted under one lock, so no
922    /// leaf claims a device the sweep did not remove, and none it removed
923    /// goes without one. Enrolments carry no leaf: an unclaimed pending
924    /// code is not a device or an authkey, the two kinds of row the log
925    /// records.
926    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    /// The audited writes, with a leaf these tests do not look at, under
1001    /// the names the tests read best with. Each still appends its leaf:
1002    /// there is no way to change a device or an authkey without one.
1003    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        // Each from its own address, so only the tests about the address
1078        // cap meet it.
1079        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    /// A database made before the worker scope existed is rebuilt to allow
1103    /// it, keeping every row, and only once.
1104    #[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        // Opening again finds the table already rebuilt.
1141        drop(st);
1142        let st = Store::open(&path).unwrap();
1143        assert_eq!(st.devices().unwrap().len(), 2);
1144        // Something other than a known scope is still refused.
1145        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    /// Every column survives the rebuild, on every kind of row: a revoked
1157    /// device stays revoked, and one an authkey enrolled keeps its key and
1158    /// its ephemerality.
1159    #[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    /// A table with a column the rebuild does not know is left alone, and
1193    /// the store refuses to open, rather than dropping that column in the
1194    /// copy.
1195    #[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    /// Only the worker scope makes a worker: an admin device, which may do
1220    /// everything else, does not put pushes in a queue.
1221    #[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        // Once they expire they no longer count, and their codes are free.
1277        assert_eq!(enroll(&st, "enr_d", "BCDF-GHJK", 1000), Created::Created);
1278    }
1279
1280    /// One address cannot hold the whole waiting list: the cap the review
1281    /// asked for, keyed the way the rate limiter keys it.
1282    #[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        // A decided one no longer counts against its address.
1294        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        // Four seconds after the last poll is within the slack.
1312        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        // Approved in time and collected late is still collected.
1338        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    /// An approval that names a fingerprint approves only that key.
1400    #[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    /// Two live devices may not share a name, in any case; a revoked one's
1419    /// name is free again.
1420    #[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    /// Verification finding N2: names a person reads as one name are one
1446    /// name, whatever characters spell them, in either order.
1447    #[test]
1448    fn names_that_look_alike_are_one_name() {
1449        for (a, b) in [
1450            // A Cyrillic а.
1451            ("laptop", "l\u{0430}ptop"),
1452            // é as one character, and as e and a combining accent.
1453            ("caf\u{00E9}", "cafe\u{0301}"),
1454            ("STRASSE", "Stra\u{00DF}e"),
1455            ("laptop", "LAPTOP"),
1456            ("laptop", "1aptop"),
1457            // A Cyrillic К, which looks like K only as a capital.
1458            ("Kiosk", "\u{041A}iosk"),
1459            // A ligature, and full-width letters.
1460            ("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        // Names that merely share letters are still two names.
1476        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    /// A key's device cap counts the devices it enrolled that are still
1567    /// there and unrevoked, so a leaked key cannot mint them without end,
1568    /// and a legitimate one frees a place each time one goes.
1569    #[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}