1use 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
26const MAX_BLOB_BYTES: usize = 64 * 1024;
30const MAX_ENCODED_BYTES: usize = MAX_BLOB_BYTES / 3 * 4 + 4;
33
34#[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#[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 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
74fn 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}