use std::time::Duration;
use anyhow::Result;
use recall_wire::signature::{fingerprint, parse_public_key};
use recall_wire::{Authkey, Device};
use rusqlite::{Connection, OptionalExtension, Row};
use time::OffsetDateTime;
use unicode_normalization::UnicodeNormalization;
use super::Store;
use crate::{format_timestamp, parse_timestamp};
pub(super) const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
-- Always the one owner. Reserved now because adding it later would
-- be a migration: see Part 3 of docs/design/handshake.md.
owner_id TEXT NOT NULL DEFAULT 'owner',
name TEXT NOT NULL,
public_key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('sync', 'admin')),
agent TEXT NOT NULL DEFAULT '',
ephemeral INTEGER NOT NULL DEFAULT 0,
authkey_id TEXT,
created_at TEXT NOT NULL,
last_seen TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS device_enrollments (
enrollment_id TEXT PRIMARY KEY,
user_code TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
agent TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
-- The device approving it made; NULL while it waits.
device_id TEXT,
denied INTEGER NOT NULL DEFAULT 0,
last_poll_at TEXT,
-- The address it came from, as the rate limiter keys it: what caps
-- how many one address may have waiting.
client_ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS device_enrollments_user_code
ON device_enrollments (user_code);
CREATE TABLE IF NOT EXISTS authkeys (
id TEXT PRIMARY KEY,
-- The key itself is never stored: it is shown once, and a copy of
-- the database is not a copy of it.
key_sha256 TEXT NOT NULL UNIQUE,
tag TEXT NOT NULL DEFAULT '',
ephemeral INTEGER NOT NULL DEFAULT 0,
-- The most unrevoked devices it may have enrolled at once. Always
-- stored; NULL would be read as the default, not as no limit.
max_devices INTEGER,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
";
const DEVICE_COLUMNS: &str = "id, name, scope, ephemeral, agent, public_key, authkey_id, \
created_at, last_seen, revoked_at";
const AUTHKEY_COLUMNS: &str = "id, tag, ephemeral, max_devices, created_at, expires_at, revoked_at";
fn device_from(r: &Row<'_>) -> rusqlite::Result<Device> {
let public_key: String = r.get(5)?;
Ok(Device {
id: r.get(0)?,
name: r.get(1)?,
scope: r.get(2)?,
ephemeral: r.get::<_, i64>(3)? != 0,
agent: r.get(4)?,
fingerprint: parse_public_key(&public_key)
.map(|k| fingerprint(&k))
.unwrap_or_default(),
public_key,
authkey_id: r.get(6)?,
created_at: r.get(7)?,
last_seen: r.get(8)?,
revoked_at: r.get(9)?,
})
}
fn authkey_from(r: &Row<'_>) -> rusqlite::Result<Authkey> {
Ok(Authkey {
id: r.get(0)?,
tag: r.get(1)?,
ephemeral: r.get::<_, i64>(2)? != 0,
max_devices: r.get(3)?,
created_at: r.get(4)?,
expires_at: r.get(5)?,
revoked_at: r.get(6)?,
})
}
pub fn plain_name(name: &str) -> String {
name.trim().nfc().collect()
}
fn name_keys(name: &str) -> [String; 2] {
let plain: String = name.trim().nfkc().collect();
let skeleton = |s: String| unicode_security::skeleton(&s).collect::<String>();
[
skeleton(plain.to_lowercase()),
skeleton(plain.to_uppercase()),
]
}
fn name_taken(conn: &Connection, name: &str) -> Result<bool> {
let [lower, upper] = name_keys(name);
let mut stmt = conn.prepare("SELECT name FROM devices WHERE revoked_at IS NULL")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let [their_lower, their_upper] = name_keys(&row.get::<_, String>(0)?);
if lower == their_lower || upper == their_upper {
return Ok(true);
}
}
Ok(false)
}
fn get_device(conn: &Connection, id: &str) -> Result<Option<Device>> {
Ok(conn
.query_row(
&format!("SELECT {DEVICE_COLUMNS} FROM devices WHERE id = ?1"),
(id,),
device_from,
)
.optional()?)
}
fn get_authkey(conn: &Connection, column: &str, value: &str) -> Result<Option<Authkey>> {
Ok(conn
.query_row(
&format!("SELECT {AUTHKEY_COLUMNS} FROM authkeys WHERE {column} = ?1"),
(value,),
authkey_from,
)
.optional()?)
}
#[derive(Debug, Clone)]
pub struct NewDevice<'a> {
pub id: &'a str,
pub name: &'a str,
pub public_key: &'a str,
pub scope: &'a str,
pub agent: &'a str,
pub ephemeral: bool,
pub authkey_id: Option<&'a str>,
pub created_at: &'a str,
}
#[derive(Debug, Clone)]
pub struct NewEnrollment<'a> {
pub enrollment_id: &'a str,
pub user_code: &'a str,
pub name: &'a str,
pub public_key: &'a str,
pub agent: &'a str,
pub created_at: &'a str,
pub expires_at: &'a str,
pub client_ip: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Created {
Created,
CodeTaken,
Full,
AddressFull,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Inserted {
Done(Box<Device>),
NameTaken,
KeyFull,
}
#[derive(Debug, Clone)]
pub struct NewAuthkey<'a> {
pub id: &'a str,
pub key_sha256: &'a str,
pub tag: &'a str,
pub ephemeral: bool,
pub max_devices: Option<u32>,
pub created_at: &'a str,
pub expires_at: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Poll {
Approved {
device_id: String,
scope: String,
},
Pending,
SlowDown,
Expired,
Denied,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision<T> {
Done(T),
NotFound,
Expired,
AlreadyDecided,
KeyMismatch,
NameTaken(String),
}
type Pending = (String, String, String, String, String, bool);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Waiting {
pub name: String,
pub public_key: String,
pub agent: String,
pub expires_at: String,
}
fn pending_by_code(conn: &Connection, user_code: &str) -> Result<Option<Pending>> {
Ok(conn
.query_row(
"SELECT enrollment_id, name, public_key, agent, expires_at,
device_id IS NOT NULL OR denied != 0
FROM device_enrollments WHERE user_code = ?1
ORDER BY created_at DESC LIMIT 1",
(user_code,),
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get::<_, bool>(5)?,
))
},
)
.optional()?)
}
impl Store {
pub fn create_enrollment(
&self,
e: &NewEnrollment<'_>,
max_pending: usize,
max_per_address: usize,
) -> Result<Created> {
let conn = self.lock();
let waiting = "expires_at > ?1 AND device_id IS NULL AND denied = 0";
let count: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting}"),
(e.created_at,),
|r| r.get(0),
)?;
if count as usize >= max_pending {
return Ok(Created::Full);
}
let from_here: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND client_ip = ?2"),
(e.created_at, e.client_ip),
|r| r.get(0),
)?;
if from_here as usize >= max_per_address {
return Ok(Created::AddressFull);
}
let taken: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM device_enrollments WHERE {waiting} AND user_code = ?2"),
(e.created_at, e.user_code),
|r| r.get(0),
)?;
if taken > 0 {
return Ok(Created::CodeTaken);
}
conn.execute(
"INSERT INTO device_enrollments
(enrollment_id, user_code, name, public_key, agent, created_at, expires_at,
client_ip)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(
e.enrollment_id,
e.user_code,
e.name,
e.public_key,
e.agent,
e.created_at,
e.expires_at,
e.client_ip,
),
)?;
Ok(Created::Created)
}
pub fn poll_enrollment(
&self,
enrollment_id: &str,
now: OffsetDateTime,
interval: Duration,
) -> Result<Poll> {
let conn = self.lock();
let row = conn
.query_row(
"SELECT e.expires_at, e.denied, e.last_poll_at, e.device_id, d.scope, d.revoked_at
FROM device_enrollments e LEFT JOIN devices d ON d.id = e.device_id
WHERE e.enrollment_id = ?1",
(enrollment_id,),
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)? != 0,
r.get::<_, Option<String>>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<String>>(5)?,
))
},
)
.optional()?;
let Some((expires_at, denied, last_poll_at, device_id, scope, revoked_at)) = row else {
return Ok(Poll::Unknown);
};
if denied {
return Ok(Poll::Denied);
}
if let Some(device_id) = device_id {
return Ok(match (scope, revoked_at) {
(Some(scope), None) => Poll::Approved { device_id, scope },
_ => Poll::Denied,
});
}
let now_text = format_timestamp(now);
if expires_at <= now_text {
return Ok(Poll::Expired);
}
let too_soon = last_poll_at
.as_deref()
.and_then(parse_timestamp)
.is_some_and(|last| {
let min = interval.saturating_sub(Duration::from_secs(1));
now - last < min
});
conn.execute(
"UPDATE device_enrollments SET last_poll_at = ?1 WHERE enrollment_id = ?2",
(&now_text, enrollment_id),
)?;
Ok(if too_soon {
Poll::SlowDown
} else {
Poll::Pending
})
}
pub fn approve_enrollment(
&self,
user_code: &str,
device_id: &str,
scope: &str,
now: &str,
expected_fingerprint: Option<&str>,
) -> Result<Decision<Device>> {
let mut conn = self.lock();
let Some((enrollment_id, name, public_key, agent, expires_at, decided)) =
pending_by_code(&conn, user_code)?
else {
return Ok(Decision::NotFound);
};
if decided {
return Ok(Decision::AlreadyDecided);
}
if expires_at.as_str() <= now {
return Ok(Decision::Expired);
}
if let Some(expected) = expected_fingerprint {
let actual = parse_public_key(&public_key)
.map(|k| fingerprint(&k))
.unwrap_or_default();
if expected.trim() != actual {
return Ok(Decision::KeyMismatch);
}
}
if name_taken(&conn, &name)? {
return Ok(Decision::NameTaken(name));
}
let tx = conn.transaction()?;
insert_device(
&tx,
&NewDevice {
id: device_id,
name: &name,
public_key: &public_key,
scope,
agent: &agent,
ephemeral: false,
authkey_id: None,
created_at: now,
},
)?;
tx.execute(
"UPDATE device_enrollments SET device_id = ?1 WHERE enrollment_id = ?2",
(device_id, &enrollment_id),
)?;
tx.commit()?;
let device = get_device(&conn, device_id)?.expect("inserted above");
Ok(Decision::Done(device))
}
pub fn deny_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<String>> {
let conn = self.lock();
let Some((enrollment_id, name, _, _, expires_at, decided)) =
pending_by_code(&conn, user_code)?
else {
return Ok(Decision::NotFound);
};
if decided {
return Ok(Decision::AlreadyDecided);
}
if expires_at.as_str() <= now {
return Ok(Decision::Expired);
}
conn.execute(
"UPDATE device_enrollments SET denied = 1 WHERE enrollment_id = ?1",
(&enrollment_id,),
)?;
Ok(Decision::Done(name))
}
pub fn pending_enrollment(&self, user_code: &str, now: &str) -> Result<Decision<Waiting>> {
let conn = self.lock();
let Some((_, name, public_key, agent, expires_at, decided)) =
pending_by_code(&conn, user_code)?
else {
return Ok(Decision::NotFound);
};
if decided {
return Ok(Decision::AlreadyDecided);
}
if expires_at.as_str() <= now {
return Ok(Decision::Expired);
}
Ok(Decision::Done(Waiting {
name,
public_key,
agent,
expires_at,
}))
}
pub fn insert_device(&self, d: &NewDevice<'_>, max_for_key: Option<u32>) -> Result<Inserted> {
let conn = self.lock();
if name_taken(&conn, d.name)? {
return Ok(Inserted::NameTaken);
}
if let (Some(max), Some(key)) = (max_for_key, d.authkey_id) {
let live: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE authkey_id = ?1 AND revoked_at IS NULL",
(key,),
|r| r.get(0),
)?;
if live >= i64::from(max) {
return Ok(Inserted::KeyFull);
}
}
insert_device(&conn, d)?;
Ok(Inserted::Done(Box::new(
get_device(&conn, d.id)?.expect("inserted above"),
)))
}
pub fn device(&self, id: &str) -> Result<Option<Device>> {
get_device(&self.lock(), id)
}
pub fn devices(&self) -> Result<Vec<Device>> {
let conn = self.lock();
let mut stmt = conn.prepare(&format!(
"SELECT {DEVICE_COLUMNS} FROM devices ORDER BY created_at DESC, id"
))?;
let rows = stmt.query_map([], device_from)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn revoke_device(&self, id: &str, now: &str) -> Result<Option<Device>> {
let conn = self.lock();
conn.execute(
"UPDATE devices SET revoked_at = COALESCE(revoked_at, ?1) WHERE id = ?2",
(now, id),
)?;
get_device(&conn, id)
}
pub fn touch_device(&self, id: &str, now: &str) -> Result<()> {
self.lock()
.execute("UPDATE devices SET last_seen = ?1 WHERE id = ?2", (now, id))?;
Ok(())
}
pub fn insert_authkey(&self, k: &NewAuthkey<'_>) -> Result<Authkey> {
let conn = self.lock();
conn.execute(
"INSERT INTO authkeys
(id, key_sha256, tag, ephemeral, max_devices, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(
k.id,
k.key_sha256,
k.tag,
k.ephemeral as i64,
k.max_devices,
k.created_at,
k.expires_at,
),
)?;
Ok(get_authkey(&conn, "id", k.id)?.expect("inserted above"))
}
pub fn authkey_by_hash(&self, key_sha256: &str) -> Result<Option<Authkey>> {
get_authkey(&self.lock(), "key_sha256", key_sha256)
}
pub fn authkeys(&self) -> Result<Vec<Authkey>> {
let conn = self.lock();
let mut stmt = conn.prepare(&format!(
"SELECT {AUTHKEY_COLUMNS} FROM authkeys ORDER BY created_at DESC, id"
))?;
let rows = stmt.query_map([], authkey_from)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
pub fn revoke_authkey(&self, id: &str, now: &str, devices: bool) -> Result<Option<Authkey>> {
let mut conn = self.lock();
let tx = conn.transaction()?;
tx.execute(
"UPDATE authkeys SET revoked_at = COALESCE(revoked_at, ?1) WHERE id = ?2",
(now, id),
)?;
if devices {
tx.execute(
"UPDATE devices SET revoked_at = COALESCE(revoked_at, ?1) WHERE authkey_id = ?2",
(now, id),
)?;
}
tx.commit()?;
get_authkey(&conn, "id", id)
}
pub fn sweep_devices(&self, idle_before: &str, expired_before: &str) -> Result<(usize, usize)> {
let conn = self.lock();
let devices = conn.execute(
"DELETE FROM devices WHERE ephemeral = 1 AND COALESCE(last_seen, created_at) < ?1",
(idle_before,),
)?;
let enrollments = conn.execute(
"DELETE FROM device_enrollments WHERE expires_at < ?1",
(expired_before,),
)?;
Ok((devices, enrollments))
}
}
fn insert_device(conn: &Connection, d: &NewDevice<'_>) -> Result<()> {
conn.execute(
"INSERT INTO devices
(id, name, public_key, scope, agent, ephemeral, authkey_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(
d.id,
d.name,
d.public_key,
d.scope,
d.agent,
d.ephemeral as i64,
d.authkey_id,
d.created_at,
),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
fn at(secs: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(1_790_000_000 + secs).unwrap()
}
fn ts(secs: i64) -> String {
format_timestamp(at(secs))
}
fn enroll_from(st: &Store, id: &str, code: &str, created: i64, ip: &str) -> Created {
st.create_enrollment(
&NewEnrollment {
enrollment_id: id,
user_code: code,
name: "laptop",
public_key: KEY,
agent: "recall/0.4.1",
created_at: &ts(created),
expires_at: &ts(created + 900),
client_ip: ip,
},
3,
2,
)
.unwrap()
}
fn enroll(st: &Store, id: &str, code: &str, created: i64) -> Created {
enroll_from(st, id, code, created, id)
}
fn device<'a>(id: &'a str, name: &'a str, key: Option<&'a str>) -> NewDevice<'a> {
NewDevice {
id,
name,
public_key: KEY,
scope: "sync",
agent: "",
ephemeral: false,
authkey_id: key,
created_at: "2026-09-23T00:00:00.000Z",
}
}
fn inserted(st: &Store, d: &NewDevice<'_>) -> Device {
match st.insert_device(d, None).unwrap() {
Inserted::Done(device) => *device,
other => panic!("not inserted: {other:?}"),
}
}
#[test]
fn the_tables_are_created_once_and_reopening_keeps_them() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("r.db");
{
let st = Store::open(&path).unwrap();
assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
}
let st = Store::open(&path).unwrap();
assert_eq!(
st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
.unwrap(),
Poll::Pending
);
}
#[test]
fn a_code_in_use_is_refused_and_the_waiting_list_is_capped() {
let st = Store::open_in_memory().unwrap();
assert_eq!(enroll(&st, "enr_a", "BCDF-GHJK", 0), Created::Created);
assert_eq!(enroll(&st, "enr_b", "BCDF-GHJK", 1), Created::CodeTaken);
assert_eq!(enroll(&st, "enr_b", "BCDF-GHJL", 1), Created::Created);
assert_eq!(enroll(&st, "enr_c", "BCDF-GHJM", 2), Created::Created);
assert_eq!(enroll(&st, "enr_d", "BCDF-GHJN", 3), Created::Full);
assert_eq!(enroll(&st, "enr_d", "BCDF-GHJK", 1000), Created::Created);
}
#[test]
fn one_address_may_have_only_so_many_waiting() {
let st = Store::open_in_memory().unwrap();
let from = |id, code, ip| enroll_from(&st, id, code, 0, ip);
assert_eq!(from("enr_a", "BCDF-GHJK", "198.51.100.4"), Created::Created);
assert_eq!(from("enr_b", "BCDF-GHJL", "198.51.100.4"), Created::Created);
assert_eq!(
from("enr_c", "BCDF-GHJM", "198.51.100.4"),
Created::AddressFull
);
assert_eq!(from("enr_c", "BCDF-GHJM", "198.51.100.5"), Created::Created);
st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap();
assert_eq!(from("enr_d", "BCDF-GHJN", "198.51.100.4"), Created::Created);
}
#[test]
fn a_poll_follows_rfc8628() {
let st = Store::open_in_memory().unwrap();
let every = Duration::from_secs(5);
enroll(&st, "enr_a", "BCDF-GHJK", 0);
assert_eq!(
st.poll_enrollment("enr_a", at(1), every).unwrap(),
Poll::Pending
);
assert_eq!(
st.poll_enrollment("enr_a", at(3), every).unwrap(),
Poll::SlowDown
);
assert_eq!(
st.poll_enrollment("enr_a", at(7), every).unwrap(),
Poll::Pending
);
assert_eq!(
st.poll_enrollment("enr_x", at(8), every).unwrap(),
Poll::Unknown
);
assert_eq!(
st.poll_enrollment("enr_a", at(900), every).unwrap(),
Poll::Expired
);
enroll(&st, "enr_b", "BCDF-GHJL", 0);
let Decision::Done(device) = st
.approve_enrollment("BCDF-GHJL", "dev_1", "admin", &ts(10), None)
.unwrap()
else {
panic!("not approved");
};
assert_eq!(
(device.name.as_str(), device.scope.as_str()),
("laptop", "admin")
);
assert!(!device.fingerprint.is_empty());
assert_eq!(
st.poll_enrollment("enr_b", at(2000), every).unwrap(),
Poll::Approved {
device_id: "dev_1".into(),
scope: "admin".into()
}
);
st.revoke_device("dev_1", &ts(20)).unwrap();
assert_eq!(
st.poll_enrollment("enr_b", at(30), every).unwrap(),
Poll::Denied
);
}
#[test]
fn a_code_is_decided_once() {
let st = Store::open_in_memory().unwrap();
enroll(&st, "enr_a", "BCDF-GHJK", 0);
let Decision::Done(waiting) = st.pending_enrollment("BCDF-GHJK", &ts(1)).unwrap() else {
panic!("not waiting");
};
assert_eq!(
(waiting.name.as_str(), waiting.expires_at),
("laptop", ts(900))
);
assert_eq!(
st.pending_enrollment("BCDF-GHJK", &ts(900)).unwrap(),
Decision::Expired
);
assert_eq!(
st.pending_enrollment("ZZZZ-ZZZZ", &ts(1)).unwrap(),
Decision::NotFound
);
assert_eq!(
st.deny_enrollment("BCDF-GHJK", &ts(1)).unwrap(),
Decision::Done("laptop".into())
);
assert_eq!(
st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), None)
.unwrap(),
Decision::AlreadyDecided
);
assert_eq!(
st.poll_enrollment("enr_a", at(10), Duration::from_secs(5))
.unwrap(),
Poll::Denied
);
assert_eq!(
st.approve_enrollment("ZZZZ-ZZZZ", "dev_1", "sync", &ts(2), None)
.unwrap(),
Decision::NotFound
);
enroll(&st, "enr_b", "BCDF-GHJL", 0);
assert_eq!(
st.approve_enrollment("BCDF-GHJL", "dev_1", "sync", &ts(901), None)
.unwrap(),
Decision::Expired
);
assert!(st.devices().unwrap().is_empty(), "nothing was approved");
}
#[test]
fn an_approval_is_bound_to_the_fingerprint_it_names() {
let st = Store::open_in_memory().unwrap();
enroll(&st, "enr_a", "BCDF-GHJK", 0);
let right = fingerprint(&parse_public_key(KEY).unwrap());
assert_eq!(
st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(1), Some("SHA256:other"))
.unwrap(),
Decision::KeyMismatch
);
assert!(st.devices().unwrap().is_empty(), "nothing was approved");
assert!(matches!(
st.approve_enrollment("BCDF-GHJK", "dev_1", "sync", &ts(2), Some(&right))
.unwrap(),
Decision::Done(_)
));
}
#[test]
fn names_are_unique_among_devices_not_revoked() {
let st = Store::open_in_memory().unwrap();
inserted(&st, &device("dev_1", "laptop", None));
assert_eq!(
st.insert_device(&device("dev_2", "Laptop", None), None)
.unwrap(),
Inserted::NameTaken
);
enroll(&st, "enr_a", "BCDF-GHJK", 0);
assert_eq!(
st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(1), None)
.unwrap(),
Decision::NameTaken("laptop".into())
);
st.revoke_device("dev_1", &ts(2)).unwrap();
assert!(matches!(
st.approve_enrollment("BCDF-GHJK", "dev_2", "sync", &ts(3), None)
.unwrap(),
Decision::Done(_)
));
}
#[test]
fn names_that_look_alike_are_one_name() {
for (a, b) in [
("laptop", "l\u{0430}ptop"),
("caf\u{00E9}", "cafe\u{0301}"),
("STRASSE", "Stra\u{00DF}e"),
("laptop", "LAPTOP"),
("laptop", "1aptop"),
("Kiosk", "\u{041A}iosk"),
("file", "\u{FB01}le"),
("desk", "\u{FF44}\u{FF45}\u{FF53}\u{FF4B}"),
] {
for (taken, wanted) in [(a, b), (b, a)] {
let st = Store::open_in_memory().unwrap();
inserted(&st, &device("dev_1", taken, None));
assert_eq!(
st.insert_device(&device("dev_2", wanted, None), None)
.unwrap(),
Inserted::NameTaken,
"{wanted:?} beside {taken:?}"
);
}
}
let st = Store::open_in_memory().unwrap();
for (i, name) in ["laptop", "laptops", "lapdog", "desk", "desk-2"]
.into_iter()
.enumerate()
{
inserted(&st, &device(&format!("dev_{i}"), name, None));
}
}
#[test]
fn a_name_is_stored_composed_and_trimmed() {
assert_eq!(plain_name(" cafe\u{0301} "), "caf\u{00E9}");
assert_eq!(plain_name("laptop"), "laptop");
}
#[test]
fn revoking_keeps_the_first_time_and_the_row() {
let st = Store::open_in_memory().unwrap();
inserted(&st, &device("dev_1", "laptop", None));
let first = st.revoke_device("dev_1", &ts(1)).unwrap().unwrap();
let again = st.revoke_device("dev_1", &ts(2)).unwrap().unwrap();
assert_eq!(first.revoked_at, Some(ts(1)));
assert_eq!(again.revoked_at, Some(ts(1)));
assert!(st.revoke_device("dev_none", &ts(3)).unwrap().is_none());
assert_eq!(st.devices().unwrap().len(), 1);
}
#[test]
fn only_idle_ephemeral_devices_and_long_expired_enrolments_are_swept() {
let st = Store::open_in_memory().unwrap();
for (id, ephemeral) in [("dev_kept", false), ("dev_idle", true), ("dev_busy", true)] {
inserted(
&st,
&NewDevice {
ephemeral,
created_at: &ts(0),
..device(id, id, None)
},
);
}
st.touch_device("dev_busy", &ts(5000)).unwrap();
enroll(&st, "enr_old", "BCDF-GHJK", 0);
enroll(&st, "enr_new", "BCDF-GHJL", 4000);
let (devices, enrollments) = st.sweep_devices(&ts(3600), &ts(3600)).unwrap();
assert_eq!((devices, enrollments), (1, 1));
let left: Vec<String> = st.devices().unwrap().into_iter().map(|d| d.id).collect();
assert!(left.contains(&"dev_kept".to_string()) && left.contains(&"dev_busy".to_string()));
assert_eq!(
st.poll_enrollment("enr_old", at(3700), Duration::from_secs(5))
.unwrap(),
Poll::Unknown
);
}
fn key(st: &Store, max_devices: Option<u32>) {
st.insert_authkey(&NewAuthkey {
id: "ak_1",
key_sha256: "abc",
tag: "cloud",
ephemeral: true,
max_devices,
created_at: &ts(0),
expires_at: &ts(86400),
})
.unwrap();
}
#[test]
fn authkeys_are_found_by_hash_and_revoked_once() {
let st = Store::open_in_memory().unwrap();
key(&st, None);
let found = st.authkey_by_hash("abc").unwrap().unwrap();
assert_eq!(
(found.id.as_str(), found.ephemeral, found.max_devices),
("ak_1", true, None)
);
assert!(st.authkey_by_hash("abd").unwrap().is_none());
let revoked = st.revoke_authkey("ak_1", &ts(1), false).unwrap().unwrap();
assert_eq!(revoked.revoked_at, Some(ts(1)));
assert_eq!(
st.revoke_authkey("ak_1", &ts(2), false)
.unwrap()
.unwrap()
.revoked_at,
Some(ts(1))
);
assert_eq!(st.authkeys().unwrap().len(), 1);
}
#[test]
fn a_key_enrols_no_more_than_its_cap() {
let st = Store::open_in_memory().unwrap();
key(&st, Some(2));
inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
assert!(matches!(
st.insert_device(&device("dev_2", "cloud-2", Some("ak_1")), Some(2))
.unwrap(),
Inserted::Done(_)
));
assert_eq!(
st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
.unwrap(),
Inserted::KeyFull
);
st.revoke_device("dev_1", &ts(1)).unwrap();
assert!(matches!(
st.insert_device(&device("dev_3", "cloud-3", Some("ak_1")), Some(2))
.unwrap(),
Inserted::Done(_)
));
}
#[test]
fn revoking_a_key_can_revoke_what_it_enrolled() {
let st = Store::open_in_memory().unwrap();
key(&st, None);
inserted(&st, &device("dev_1", "cloud-1", Some("ak_1")));
inserted(&st, &device("dev_2", "laptop", None));
st.revoke_authkey("ak_1", &ts(1), true).unwrap();
let revoked: Vec<(String, bool)> = st
.devices()
.unwrap()
.into_iter()
.map(|d| (d.id, d.revoked_at.is_some()))
.collect();
assert!(revoked.contains(&("dev_1".into(), true)));
assert!(
revoked.contains(&("dev_2".into(), false)),
"only the key's own devices"
);
}
}