Skip to main content

forge_workspace/store/
biometric.rs

1//! Biometric enrolments on the redb `biometric_enrolments` table.
2//!
3//! One row per device, keyed by the enrolment id its client minted. The whole
4//! `Enrolment` is serde-json, blobs included: the bytes are the client's
5//! wrapped secrets, stored and returned unchanged and never decoded here.
6
7use anyhow::Context;
8use redb::{ReadableTable, TableDefinition};
9use uuid::Uuid;
10
11use super::Db;
12use crate::biometric::Enrolment;
13
14const ENROLMENTS: TableDefinition<[u8; 16], &[u8]> = TableDefinition::new("biometric_enrolments");
15
16/// Write one device's enrolment, replacing any prior row with the same id.
17pub(crate) fn put(db: &Db, enrolment: &Enrolment) -> anyhow::Result<()> {
18    let value = serde_json::to_vec(enrolment).context("serialize biometric enrolment")?;
19    let txn = db.database().begin_write()?;
20    {
21        let mut table = txn.open_table(ENROLMENTS)?;
22        table.insert(enrolment.id.as_bytes(), value.as_slice())?;
23    }
24    txn.commit()?;
25    Ok(())
26}
27
28/// The enrolment `id` names, or `None` when no row carries it.
29///
30/// A row that does not decode is an ERROR rather than `None`: a release that
31/// read it as "nothing enrolled" would send the reader to the password path
32/// while the enrolment sits right there, unreadable.
33pub(crate) fn get(db: &Db, id: Uuid) -> anyhow::Result<Option<Enrolment>> {
34    let txn = db.database().begin_read()?;
35    let table = match txn.open_table(ENROLMENTS) {
36        Ok(table) => table,
37        // A fresh database has no table until the first write; an absent
38        // table holds no enrolment.
39        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
40        Err(error) => return Err(error.into()),
41    };
42    let Some(value) = table.get(id.as_bytes())? else { return Ok(None) };
43    let enrolment = serde_json::from_slice(value.value())
44        .with_context(|| format!("decode biometric enrolment {id}"))?;
45    Ok(Some(enrolment))
46}
47
48/// Every stored enrolment.
49///
50/// One undecodable record is skipped with a warn rather than wiping the set:
51/// a corrupt row must not hide the devices that are still readable. The
52/// caller's own reads distinguish an empty set from an unreadable one.
53pub(crate) fn list(db: &Db) -> anyhow::Result<Vec<Enrolment>> {
54    let txn = db.database().begin_read()?;
55    let table = match txn.open_table(ENROLMENTS) {
56        Ok(table) => table,
57        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
58        Err(error) => return Err(error.into()),
59    };
60    let mut out = Vec::new();
61    for entry in table.iter()? {
62        let (id, value) = entry?;
63        match serde_json::from_slice::<Enrolment>(value.value()) {
64            Ok(enrolment) => out.push(enrolment),
65            Err(error) => tracing::warn!(
66                target: "forge_workspace::store::biometric",
67                id = %Uuid::from_bytes(id.value()),
68                %error,
69                "skipping biometric enrolment record that failed to decode",
70            ),
71        }
72    }
73    Ok(out)
74}
75
76/// Remove the enrolment `id` names. Returns whether a row was there.
77pub(crate) fn remove(db: &Db, id: Uuid) -> anyhow::Result<bool> {
78    let txn = db.database().begin_write()?;
79    let existed = match txn.open_table(ENROLMENTS) {
80        Ok(mut table) => table.remove(id.as_bytes())?.is_some(),
81        Err(redb::TableError::TableDoesNotExist(_)) => false,
82        Err(error) => return Err(error.into()),
83    };
84    txn.commit()?;
85    Ok(existed)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::biometric::StoredPurpose;
92    use std::time::{Duration, SystemTime};
93    use tempfile::tempdir;
94
95    fn enrolment(id: Uuid, purposes: &[(&str, &[u8])]) -> Enrolment {
96        let at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_000);
97        Enrolment {
98            id,
99            device: "Ved's MacBook Pro".to_owned(),
100            created_at: at,
101            purposes: purposes
102                .iter()
103                .map(|(purpose, blob)| StoredPurpose {
104                    purpose: (*purpose).to_owned(),
105                    blob: blob.to_vec(),
106                    created_at: at,
107                    last_used_at: None,
108                })
109                .collect(),
110        }
111    }
112
113    #[test]
114    fn an_enrolment_round_trips_through_the_table() {
115        let dir = tempdir().expect("tempdir");
116        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
117        let id = Uuid::new_v4();
118        let written = enrolment(id, &[("vault", b"wrapped")]);
119
120        put(&db, &written).expect("put");
121        let read = get(&db, id).expect("get").expect("the row");
122        assert_eq!(read, written, "the stored row is the row that was written");
123        assert_eq!(list(&db).expect("list"), vec![written]);
124    }
125
126    /// A second purpose is a second entry in the same row with its own blob
127    /// shape; the table has no per-purpose schema to change.
128    #[test]
129    fn one_row_carries_every_purpose_with_its_own_shape() {
130        let dir = tempdir().expect("tempdir");
131        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
132        let id = Uuid::new_v4();
133        let second = [9u8; 512];
134
135        put(&db, &enrolment(id, &[("vault", b"{\"token\":\"t\"}"), ("sso", &second)]))
136            .expect("put");
137
138        let read = get(&db, id).expect("get").expect("the row");
139        assert_eq!(read.purposes[0].blob, b"{\"token\":\"t\"}");
140        assert_eq!(read.purposes[1].blob, second);
141    }
142
143    #[test]
144    fn an_enrolment_survives_reopen() {
145        let dir = tempdir().expect("tempdir");
146        let path = dir.path().join("db.redb");
147        let id = Uuid::new_v4();
148        {
149            let db = Db::open(&path).expect("open db");
150            put(&db, &enrolment(id, &[("vault", b"kept")])).expect("put");
151        }
152        let db = Db::open(&path).expect("reopen db");
153        let read = get(&db, id).expect("get").expect("the row");
154        assert_eq!(read.purposes[0].blob, b"kept");
155    }
156
157    /// A row that does not decode is skipped by the list and is an explicit
158    /// error for the one read that named it, never a silent `None`.
159    #[test]
160    fn a_corrupt_row_is_skipped_by_the_list_and_refused_by_get() {
161        let dir = tempdir().expect("tempdir");
162        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
163        let good = Uuid::new_v4();
164        put(&db, &enrolment(good, &[("vault", b"good")])).expect("put the good row");
165
166        let corrupt = Uuid::new_v4();
167        let txn = db.database().begin_write().expect("begin");
168        {
169            let mut table = txn.open_table(ENROLMENTS).expect("table");
170            table.insert(corrupt.as_bytes(), "not an enrolment".as_bytes()).expect("insert");
171        }
172        txn.commit().expect("commit");
173
174        assert_eq!(list(&db).expect("list tolerates the corrupt row").len(), 1);
175        assert!(get(&db, corrupt).is_err(), "a named corrupt row is an error, not an absence");
176    }
177
178    #[test]
179    fn removing_an_enrolment_reports_whether_it_was_there() {
180        let dir = tempdir().expect("tempdir");
181        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
182        let id = Uuid::new_v4();
183        put(&db, &enrolment(id, &[("vault", b"x")])).expect("put");
184
185        assert!(remove(&db, id).expect("remove"), "an existing row removes to true");
186        assert!(!remove(&db, id).expect("remove again"), "an absent row removes to false");
187        assert!(get(&db, id).expect("get").is_none());
188    }
189}