Skip to main content

forge_workspace/
biometric.rs

1//! The biometric proxy's server half: each device's enrolment, each purpose's
2//! opaque blob, and revocation.
3//!
4//! **What the core stores it cannot read.** A blob is wrapped by a client's
5//! platform authenticator; this module keeps the bytes a client sent and hands
6//! the same bytes back on a release, and nothing here decodes one. The core
7//! still holds the vault's unlocked keys in memory while the vault is open,
8//! exactly as it did before this feature - the blob store does not make it
9//! hands-off, and the design says so plainly.
10//!
11//! **A purpose is a registration, not a code path.** A blob is keyed by the
12//! purpose string it arrived with and is otherwise opaque, so a second
13//! consumer is a new string plus its own blob shape in a client, and nothing
14//! in this module changes. `a_second_consumer_needs_only_a_purpose` is the
15//! guard on that.
16
17use std::time::SystemTime;
18
19use uuid::Uuid;
20
21use forge_primitives::biometric::{BiometricBlob, BiometricEnrolment, BiometricPurpose};
22
23use crate::protocol::SessionUpdate;
24use crate::workspace::Workspace;
25
26/// The largest wrapped blob a client may enrol. The material is a refresh
27/// token and a user key, a few hundred bytes; the cap exists because the write
28/// is a client command and nothing else bounds it.
29const MAX_BLOB_BYTES: usize = 64 * 1024;
30/// The most base64 a wrapped blob may be before its decode is refused: the
31/// byte cap in base64, rounded up for the final group's padding.
32const MAX_ENCODED_BYTES: usize = MAX_BLOB_BYTES / 3 * 4 + 4;
33
34/// One device's enrolment as the store holds it: the facts a view draws plus
35/// each purpose's wrapped blob.
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub(crate) struct Enrolment {
38    pub id: Uuid,
39    pub device: String,
40    pub created_at: SystemTime,
41    pub purposes: Vec<StoredPurpose>,
42}
43
44/// One purpose's stored half: the wrapped blob and its stamps. The bytes are
45/// opaque; nothing in the core decodes them.
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub(crate) struct StoredPurpose {
48    pub purpose: String,
49    pub blob: Vec<u8>,
50    pub created_at: SystemTime,
51    pub last_used_at: Option<SystemTime>,
52}
53
54impl Enrolment {
55    /// The facts a view draws, with every blob left behind.
56    fn view(&self) -> BiometricEnrolment {
57        BiometricEnrolment {
58            id: self.id,
59            device: self.device.clone(),
60            created_at: self.created_at,
61            purposes: self
62                .purposes
63                .iter()
64                .map(|held| BiometricPurpose {
65                    purpose: held.purpose.clone(),
66                    created_at: held.created_at,
67                    last_used_at: held.last_used_at,
68                })
69                .collect(),
70        }
71    }
72}
73
74/// The bytes a wrapped blob names, refused before the decode can allocate
75/// past the cap.
76fn validated_blob(blob: &str) -> Result<Vec<u8>, String> {
77    if blob.len() > MAX_ENCODED_BYTES {
78        return Err(format!("the wrapped blob is longer than the {MAX_BLOB_BYTES} byte cap"));
79    }
80    let decoded = BiometricBlob { blob: blob.to_owned() }.decode()?;
81    if decoded.is_empty() {
82        return Err("the wrapped blob is empty".to_owned());
83    }
84    if decoded.len() > MAX_BLOB_BYTES {
85        return Err(format!(
86            "the wrapped blob is {} bytes, past the {MAX_BLOB_BYTES} byte cap",
87            decoded.len(),
88        ));
89    }
90    Ok(decoded)
91}
92
93impl Workspace {
94    /// Every device's enrolment, read from the machine-local store.
95    ///
96    /// A store that cannot be read is an error rather than an empty list:
97    /// "no device is enrolled" and "the enrolments could not be read" send a
98    /// reader looking in different places.
99    pub fn biometric_enrolments(&self) -> Result<Vec<BiometricEnrolment>, String> {
100        let db = self.db.lock();
101        let Some(db) = db.as_ref() else {
102            return Err("the machine-local store is not open".to_owned());
103        };
104        crate::store::biometric::list(db)
105            .map(|rows| rows.iter().map(Enrolment::view).collect())
106            .map_err(|error| error.to_string())
107    }
108
109    /// Register one purpose's wrapped blob under `id`, creating the device's
110    /// enrolment when the id is new and replacing that purpose's bytes when it
111    /// is not. **The person's press is this command's only caller**; a
112    /// rotation rewrite goes through [`Self::rewrite_biometric`], which cannot
113    /// create.
114    pub(crate) fn enrol_biometric(
115        &self,
116        id: Uuid,
117        device: &str,
118        purpose: &str,
119        blob: &str,
120    ) -> Result<(), String> {
121        let device = device.trim();
122        let purpose = purpose.trim();
123        if device.is_empty() {
124            return Err("an enrolment needs the device's own name".to_owned());
125        }
126        if purpose.is_empty() {
127            return Err("an enrolment names no purpose".to_owned());
128        }
129        let blob = validated_blob(blob)?;
130        let now = SystemTime::now();
131        {
132            let db = self.db.lock();
133            let Some(db) = db.as_ref() else {
134                return Err("the machine-local store is not open".to_owned());
135            };
136            let mut enrolment = crate::store::biometric::get(db, id)
137                .map_err(|error| error.to_string())?
138                .unwrap_or(Enrolment {
139                    id,
140                    device: device.to_owned(),
141                    created_at: now,
142                    purposes: Vec::new(),
143                });
144            device.clone_into(&mut enrolment.device);
145            match enrolment.purposes.iter_mut().find(|held| held.purpose == purpose) {
146                // A re-enrol is a rotation rewrite: the bytes move and the
147                // purpose keeps the stamp it was first enrolled at.
148                Some(held) => held.blob = blob,
149                None => enrolment.purposes.push(StoredPurpose {
150                    purpose: purpose.to_owned(),
151                    blob,
152                    created_at: now,
153                    last_used_at: None,
154                }),
155            }
156            crate::store::biometric::put(db, &enrolment).map_err(|error| error.to_string())?;
157        }
158        self.announce_biometric_enrolments();
159        Ok(())
160    }
161
162    /// Replace an existing purpose's blob, refusing to create.
163    ///
164    /// **The person's press is what creates a row**; this is the rotation
165    /// rewrite, which may only update one. A device the person revoked has no
166    /// row, so its rewrite answers `false` and the client locks rather than
167    /// re-registering itself.
168    pub(crate) fn rewrite_biometric(
169        &self,
170        id: Uuid,
171        purpose: &str,
172        blob: &str,
173    ) -> Result<bool, String> {
174        let purpose = purpose.trim();
175        if purpose.is_empty() {
176            return Err("a rewrite names no purpose".to_owned());
177        }
178        let blob = validated_blob(blob)?;
179        {
180            let db = self.db.lock();
181            let Some(db) = db.as_ref() else {
182                return Err("the machine-local store is not open".to_owned());
183            };
184            let Some(mut enrolment) =
185                crate::store::biometric::get(db, id).map_err(|error| error.to_string())?
186            else {
187                return Ok(false);
188            };
189            let Some(held) = enrolment.purposes.iter_mut().find(|held| held.purpose == purpose)
190            else {
191                return Ok(false);
192            };
193            held.blob = blob;
194            crate::store::biometric::put(db, &enrolment).map_err(|error| error.to_string())?;
195        }
196        self.announce_biometric_enrolments();
197        Ok(true)
198    }
199
200    /// The blob enrolled for `purpose` under `id`, stamped as used.
201    ///
202    /// `None` is "this device has nothing enrolled for that purpose", which
203    /// is the answer a client falls back to the password on. A store failure
204    /// is an error, because a client that cannot tell the two apart would
205    /// silently skip the password path.
206    pub(crate) fn release_biometric(
207        &self,
208        id: Uuid,
209        purpose: &str,
210    ) -> Result<Option<BiometricBlob>, String> {
211        let now = SystemTime::now();
212        let answer = {
213            let db = self.db.lock();
214            let Some(db) = db.as_ref() else {
215                return Err("the machine-local store is not open".to_owned());
216            };
217            let Some(mut enrolment) =
218                crate::store::biometric::get(db, id).map_err(|error| error.to_string())?
219            else {
220                return Ok(None);
221            };
222            let Some(index) = enrolment.purposes.iter().position(|held| held.purpose == purpose)
223            else {
224                return Ok(None);
225            };
226            enrolment.purposes[index].last_used_at = Some(now);
227            let answer = BiometricBlob::encode(&enrolment.purposes[index].blob);
228            crate::store::biometric::put(db, &enrolment).map_err(|error| error.to_string())?;
229            answer
230        };
231        self.announce_biometric_enrolments();
232        Ok(Some(answer))
233    }
234
235    /// Drop one purpose's blob, or `None` for the device's whole enrolment.
236    /// Revoking the last purpose drops the enrolment with it: a device holding
237    /// nothing is a row with no meaning.
238    pub(crate) fn revoke_biometric(&self, id: Uuid, purpose: Option<&str>) -> Result<(), String> {
239        {
240            let db = self.db.lock();
241            let Some(db) = db.as_ref() else {
242                return Err("the machine-local store is not open".to_owned());
243            };
244            match purpose {
245                Some(purpose) => {
246                    let Some(mut enrolment) =
247                        crate::store::biometric::get(db, id).map_err(|error| error.to_string())?
248                    else {
249                        return Ok(());
250                    };
251                    enrolment.purposes.retain(|held| held.purpose != purpose);
252                    if enrolment.purposes.is_empty() {
253                        crate::store::biometric::remove(db, id)
254                            .map_err(|error| error.to_string())?;
255                    } else {
256                        crate::store::biometric::put(db, &enrolment)
257                            .map_err(|error| error.to_string())?;
258                    }
259                }
260                None => {
261                    crate::store::biometric::remove(db, id).map_err(|error| error.to_string())?;
262                }
263            }
264        }
265        self.announce_biometric_enrolments();
266        Ok(())
267    }
268
269    /// Lock the shared vault now, on the reader's own press rather than the
270    /// idle clock. The process holds one vault session, so any seat's lock is
271    /// every seat's; parked cards are re-stamped so a dock that already drew
272    /// one asks for the password again.
273    ///
274    /// `false` when no vault is configured, which is the only state a view
275    /// would not have drawn the action in.
276    pub(crate) fn lock_vault(&self) -> bool {
277        let session = self.vault.read().clone();
278        let Some(session) = session else {
279            tracing::debug!(
280                event_name = "vault_lock_without_session",
281                "a lock arrived with no [vault] configured",
282            );
283            return false;
284        };
285        session.lock();
286        self.rebase_vault_asks(true);
287        tracing::info!(event_name = "vault_locked", "the vault was locked at the reader's press");
288        true
289    }
290
291    /// Tell every view the enrolment set moved, as the whole set the core
292    /// holds. A write that cannot be read back announces nothing and says so;
293    /// the next read still answers the truth.
294    fn announce_biometric_enrolments(&self) {
295        match self.biometric_enrolments() {
296            Ok(enrolments) => {
297                let _ =
298                    self.update_tx.send(SessionUpdate::BiometricEnrolmentsChanged { enrolments });
299            }
300            Err(error) => tracing::warn!(
301                event_name = "biometric_enrolments_unreadable",
302                %error,
303                "an enrolment write landed but the set could not be read back for the announcement",
304            ),
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use std::sync::Arc;
312
313    use super::*;
314    use crate::SessionSlot;
315    use crate::store::Db;
316
317    /// A workspace with a real machine-local store under a temp dir, the way a
318    /// boot opens one.
319    fn workspace() -> (Arc<Workspace>, tempfile::TempDir) {
320        let dir = tempfile::tempdir().expect("tempdir");
321        let (workspace, _updates) =
322            Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
323        let db = Db::open(&dir.path().join("store.redb")).expect("open store");
324        workspace.install_db_for_test(db);
325        (workspace, dir)
326    }
327
328    fn blob(bytes: &[u8]) -> String {
329        BiometricBlob::encode(bytes).blob
330    }
331
332    /// Enrol, release and revoke round-trip through the store the way the
333    /// commands will.
334    #[test]
335    fn an_enrolment_releases_what_it_enrolled() {
336        let (workspace, _dir) = workspace();
337        let id = Uuid::new_v4();
338
339        workspace
340            .enrol_biometric(id, "Ved's MacBook Pro", "vault", &blob(b"wrapped-vault"))
341            .expect("enrol");
342
343        let enrolments = workspace.biometric_enrolments().expect("read");
344        assert_eq!(enrolments.len(), 1);
345        assert_eq!(enrolments[0].id, id);
346        assert_eq!(enrolments[0].device, "Ved's MacBook Pro");
347        assert_eq!(enrolment_purposes(&enrolments[0]), vec!["vault".to_owned()]);
348
349        let released = workspace
350            .release_biometric(id, "vault")
351            .expect("release")
352            .expect("the purpose is enrolled");
353        assert_eq!(released.decode().expect("base64"), b"wrapped-vault");
354
355        workspace.revoke_biometric(id, Some("vault")).expect("revoke");
356        assert!(
357            workspace.biometric_enrolments().expect("read").is_empty(),
358            "the empty device is gone"
359        );
360        assert!(
361            workspace.release_biometric(id, "vault").expect("release after revoke").is_none(),
362            "a revoked purpose releases nothing",
363        );
364    }
365
366    /// The brief's own acceptance criterion: a second consumer is a purpose
367    /// and a blob shape, not a store change, a table, or code in the first
368    /// consumer's path. The two blobs here are deliberately different shapes
369    /// and lengths, and each release answers only its own bytes.
370    #[test]
371    fn a_second_consumer_needs_only_a_purpose() {
372        let (workspace, _dir) = workspace();
373        let id = Uuid::new_v4();
374        let second_shape = [7u8; 300];
375
376        workspace
377            .enrol_biometric(id, "phone", "vault", &blob(b"{\"refresh\":\"r\"}"))
378            .expect("enrol the vault");
379        workspace
380            .enrol_biometric(id, "phone", "sso", &blob(&second_shape))
381            .expect("enrol a second purpose");
382
383        let enrolments = workspace.biometric_enrolments().expect("read");
384        let mut purposes = enrolment_purposes(&enrolments[0]);
385        purposes.sort_unstable();
386        assert_eq!(purposes, ["sso".to_owned(), "vault".to_owned()]);
387
388        assert_eq!(
389            workspace.release_biometric(id, "vault").expect("release").expect("enrolled").decode(),
390            Ok(b"{\"refresh\":\"r\"}".to_vec()),
391        );
392        assert_eq!(
393            workspace.release_biometric(id, "sso").expect("release").unwrap().decode(),
394            Ok(second_shape.to_vec()),
395            "the second consumer's own shape comes back whole",
396        );
397
398        // Revoking one purpose leaves the other's bytes exactly as enrolled.
399        workspace.revoke_biometric(id, Some("sso")).expect("revoke the second");
400        let enrolments = workspace.biometric_enrolments().expect("read");
401        assert_eq!(enrolment_purposes(&enrolments[0]), vec!["vault".to_owned()]);
402        assert_eq!(
403            workspace.release_biometric(id, "vault").expect("release").unwrap().decode(),
404            Ok(b"{\"refresh\":\"r\"}".to_vec()),
405            "the first consumer's blob survives the second's revocation",
406        );
407    }
408
409    /// **The revoke is a command the page sends**, so its dispatch arm is
410    /// pinned: a revoke through `dispatch` drops that purpose's row.
411    #[test]
412    fn the_revoke_command_drops_the_purpose() {
413        let (workspace, _dir) = workspace();
414        let id = Uuid::new_v4();
415        workspace.enrol_biometric(id, "phone", "vault", &blob(b"wrapped")).expect("enrol");
416
417        workspace
418            .dispatch(crate::protocol::Command::BiometricRevoke {
419                enrolment: id,
420                purpose: Some("vault".to_owned()),
421            })
422            .expect("the revoke routes");
423
424        assert!(workspace.biometric_enrolments().expect("read").is_empty(), "the row is gone");
425    }
426
427    /// **A rewrite cannot create.** The rotation path is update-only, so a
428    /// device the person revoked answers `false` and stays gone - the server
429    /// half of the revoke that sticks.
430    #[test]
431    fn a_rewrite_never_creates_a_revoked_enrolment() {
432        let (workspace, _dir) = workspace();
433        let id = Uuid::new_v4();
434        workspace.enrol_biometric(id, "phone", "vault", &blob(b"stale")).expect("enrol");
435        assert!(
436            workspace.rewrite_biometric(id, "vault", &blob(b"fresh")).expect("rewrite"),
437            "an existing purpose is updated",
438        );
439        workspace.revoke_biometric(id, Some("vault")).expect("revoke");
440
441        assert!(
442            !workspace.rewrite_biometric(id, "vault", &blob(b"next")).expect("the rewrite answers"),
443            "a revoked purpose refuses",
444        );
445        assert!(
446            workspace.biometric_enrolments().expect("read").is_empty(),
447            "and nothing is recreated",
448        );
449        assert!(
450            !workspace
451                .rewrite_biometric(Uuid::new_v4(), "vault", &blob(b"x"))
452                .expect("an unknown id answers"),
453            "an unknown id refuses too",
454        );
455    }
456
457    /// **A re-enrol replaces rather than appends.** A rotation rewrite sends
458    /// the same purpose with fresh bytes; a duplicate would leave the stale
459    /// blob first and a release would hand back a token the account has
460    /// already spent.
461    #[test]
462    fn a_re_enrol_replaces_the_purpose_rather_than_appending() {
463        let (workspace, _dir) = workspace();
464        let id = Uuid::new_v4();
465        workspace.enrol_biometric(id, "phone", "vault", &blob(b"stale")).expect("first");
466        workspace.enrol_biometric(id, "phone", "vault", &blob(b"fresh")).expect("re-enrol");
467
468        let enrolments = workspace.biometric_enrolments().expect("read");
469        assert_eq!(enrolments.len(), 1);
470        assert_eq!(
471            enrolment_purposes(&enrolments[0]),
472            vec!["vault".to_owned()],
473            "one purpose, not two",
474        );
475        assert_eq!(
476            workspace.release_biometric(id, "vault").expect("release").expect("enrolled").decode(),
477            Ok(b"fresh".to_vec()),
478            "the fresh bytes are what a release hands back",
479        );
480    }
481
482    /// The store keeps the exact bytes the client sent: a release is a byte
483    /// copy, never a decode and re-encode by the core.
484    #[test]
485    fn the_stored_blob_is_the_byte_copy_of_what_was_enrolled() {
486        let (workspace, dir) = workspace();
487        let id = Uuid::new_v4();
488        let bytes: Vec<u8> = (0u8..=255).collect();
489
490        workspace.enrol_biometric(id, "device", "vault", &blob(&bytes)).expect("enrol");
491        let released = workspace.release_biometric(id, "vault").expect("release");
492
493        assert_eq!(released.expect("enrolled").decode(), Ok(bytes));
494        drop(workspace);
495
496        // The row on disk is the same bytes, read back by a fresh handle the
497        // way the next boot reads it.
498        let db = Db::open(&dir.path().join("store.redb")).expect("reopen");
499        let stored = crate::store::biometric::get(&db, id).expect("read").expect("the row");
500        assert_eq!(stored.purposes[0].blob, (0u8..=255).collect::<Vec<u8>>());
501    }
502
503    /// The enrolment set survives a restart: the store is the only place a
504    /// recruitment lives, and a reboot with no enrolment would be a password
505    /// typed for nothing.
506    #[test]
507    fn enrolments_survive_a_store_reopen() {
508        let (workspace, dir) = workspace();
509        let id = Uuid::new_v4();
510        workspace.enrol_biometric(id, "MacBook Pro", "vault", &blob(b"kept")).expect("enrol");
511        drop(workspace);
512
513        let (workspace, _updates) =
514            Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
515        let db = Db::open(&dir.path().join("store.redb")).expect("reopen store");
516        workspace.install_db_for_test(db);
517
518        let enrolments = workspace.biometric_enrolments().expect("read after reopen");
519        assert_eq!(enrolments.len(), 1);
520        assert_eq!(enrolments[0].id, id);
521        assert_eq!(
522            workspace.release_biometric(id, "vault").expect("release").unwrap().decode(),
523            Ok(b"kept".to_vec()),
524        );
525    }
526
527    /// Revoking one device does not touch another's enrolment.
528    #[test]
529    fn revocation_drops_one_device_and_says_so() {
530        let (workspace, _dir) = workspace();
531        let mine = Uuid::new_v4();
532        let theirs = Uuid::new_v4();
533        workspace.enrol_biometric(mine, "desktop", "vault", &blob(b"a")).expect("enrol one");
534        workspace.enrol_biometric(theirs, "phone", "vault", &blob(b"b")).expect("enrol two");
535
536        workspace.revoke_biometric(mine, None).expect("revoke the whole device");
537
538        let enrolments = workspace.biometric_enrolments().expect("read");
539        assert_eq!(enrolments.len(), 1);
540        assert_eq!(enrolments[0].id, theirs);
541    }
542
543    /// Every write and release announces the set a second client redraws from;
544    /// a silent write is a settings page drawing a list that is no longer
545    /// true.
546    #[test]
547    fn every_change_announces_the_set() {
548        let dir = tempfile::tempdir().expect("tempdir");
549        let (workspace, mut updates) =
550            Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
551        let db = Db::open(&dir.path().join("store.redb")).expect("open store");
552        workspace.install_db_for_test(db);
553        let id = Uuid::new_v4();
554
555        let mut announced = Vec::new();
556        workspace.enrol_biometric(id, "device", "vault", &blob(b"one")).expect("enrol");
557        while let Ok(update) = updates.try_recv() {
558            if let SessionUpdate::BiometricEnrolmentsChanged { enrolments } =
559                std::sync::Arc::unwrap_or_clone(update.update)
560            {
561                announced.push(enrolments);
562            }
563        }
564        assert_eq!(announced.len(), 1, "the enrol announced the set");
565        assert_eq!(announced[0].len(), 1);
566
567        workspace.release_biometric(id, "vault").expect("release");
568        let mut released_announcements = 0;
569        while let Ok(update) = updates.try_recv() {
570            if let SessionUpdate::BiometricEnrolmentsChanged { enrolments } =
571                std::sync::Arc::unwrap_or_clone(update.update)
572            {
573                assert!(
574                    enrolments[0].purposes[0].last_used_at.is_some(),
575                    "the release's stamp is in the announcement",
576                );
577                released_announcements += 1;
578            }
579        }
580        assert_eq!(released_announcements, 1, "the release announced the new stamp");
581
582        workspace.revoke_biometric(id, Some("vault")).expect("revoke");
583        let mut revoked = Vec::new();
584        while let Ok(update) = updates.try_recv() {
585            if let SessionUpdate::BiometricEnrolmentsChanged { enrolments } =
586                std::sync::Arc::unwrap_or_clone(update.update)
587            {
588                revoked.push(enrolments);
589            }
590        }
591        assert_eq!(revoked, vec![Vec::new()], "the revoke announced the empty set");
592    }
593
594    /// The enrolment material answered through the command is the session's,
595    /// and a locked or absent session answers `null`.
596    #[test]
597    fn the_enrolment_material_command_answers_the_session_or_nothing() {
598        let dir = tempfile::tempdir().expect("tempdir");
599        let (workspace, _updates) =
600            Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
601
602        let (sender, mut receiver) = tokio::sync::oneshot::channel();
603        workspace
604            .dispatch(crate::protocol::Command::VaultEnrolmentMaterial { respond: Some(sender) })
605            .expect("the command routes");
606        assert!(receiver.try_recv().expect("answered").is_none(), "no [vault] means no material");
607
608        let config = crate::config::VaultConfig {
609            url: "http://127.0.0.1:1".to_owned(),
610            email: "a@b.c".to_owned(),
611            idle_minutes: None,
612        };
613        let session =
614            Arc::new(crate::vault::test_session(&config, None, dir.path()).expect("a session"));
615        crate::vault::test_install_unlocked(
616            &session,
617            forge_connectors::vault::crypto::Unlocked {
618                user_key: forge_connectors::vault::crypto::make_symmetric_key(),
619                private_key: None,
620                org_keys: std::collections::HashMap::new(),
621                failed_orgs: Vec::new(),
622                user_key_fingerprint: String::new(),
623            },
624        );
625        *workspace.vault.write() = Some(session);
626
627        let (sender, mut receiver) = tokio::sync::oneshot::channel();
628        workspace
629            .dispatch(crate::protocol::Command::VaultEnrolmentMaterial { respond: Some(sender) })
630            .expect("the command routes");
631        let material = receiver.try_recv().expect("answered").expect("the material");
632        assert_eq!(material.refresh_token, "test-refresh");
633        assert!(!material.user_key.is_empty(), "the user key rides with it");
634    }
635
636    /// A blob that is not base64, an empty one, an oversized one and a
637    /// nameless purpose are all refused before anything is stored.
638    #[test]
639    fn a_blob_that_cannot_be_stored_is_refused() {
640        let (workspace, _dir) = workspace();
641        let id = Uuid::new_v4();
642
643        assert!(workspace.enrol_biometric(id, "device", "vault", "not base64!").is_err());
644        assert!(workspace.enrol_biometric(id, "device", "vault", &blob(b"")).is_err());
645        assert!(
646            workspace
647                .enrol_biometric(id, "device", "vault", &blob(&vec![0u8; MAX_BLOB_BYTES + 1]))
648                .is_err(),
649            "a blob past the cap is refused",
650        );
651        assert!(workspace.enrol_biometric(id, "device", " ", &blob(b"x")).is_err());
652        assert!(workspace.enrol_biometric(id, "  ", "vault", &blob(b"x")).is_err());
653        assert!(
654            workspace.biometric_enrolments().expect("read").is_empty(),
655            "nothing a refusal touched was stored",
656        );
657    }
658
659    /// The lock drops the session's keys and re-stamps a parked card so a dock
660    /// that already drew one asks for the password again.
661    #[test]
662    fn locking_the_vault_locks_the_session_and_rebases_the_cards() {
663        use crate::config::VaultConfig;
664        use crate::vault::{test_install_unlocked, test_session};
665        use forge_connectors::vault::crypto;
666        use forge_primitives::vault::{VaultDraft, VaultTool};
667
668        let dir = tempfile::tempdir().expect("tempdir");
669        let (workspace, _updates) =
670            Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
671        let config = VaultConfig {
672            url: "http://127.0.0.1:1".to_owned(),
673            email: "a@b.c".to_owned(),
674            idle_minutes: None,
675        };
676        let session = Arc::new(test_session(&config, None, dir.path()).expect("a session"));
677        test_install_unlocked(
678            &session,
679            crypto::Unlocked {
680                user_key: crypto::make_symmetric_key(),
681                private_key: None,
682                org_keys: std::collections::HashMap::new(),
683                failed_orgs: Vec::new(),
684                user_key_fingerprint: String::new(),
685            },
686        );
687        *workspace.vault.write() = Some(Arc::clone(&session));
688        let caller = SessionSlot::lead("Default", "forge");
689        let draft = VaultDraft::new(VaultTool::Get, "read the password for 'bank'");
690        workspace.register_vault_ask(&caller, draft, false);
691        assert!(!session.is_locked());
692
693        assert!(workspace.lock_vault());
694
695        assert!(session.is_locked(), "the lock drops the keys");
696        let asks = workspace.vault_asks(&caller, true);
697        assert!(
698            asks.iter().any(|ask| matches!(
699                ask,
700                crate::protocol::PendingAsk::Vault(draft) if draft.needs_unlock
701            )),
702            "the parked card asks for the password again",
703        );
704    }
705
706    /// A lock with no `[vault]` configured is a no-op that answers false,
707    /// rather than a refusal a view has no card to draw.
708    #[test]
709    fn a_lock_with_no_vault_answers_false() {
710        let (workspace, _dir) = workspace();
711        assert!(!workspace.lock_vault());
712    }
713
714    fn enrolment_purposes(enrolment: &BiometricEnrolment) -> Vec<String> {
715        enrolment.purposes.iter().map(|held| held.purpose.clone()).collect()
716    }
717}