Skip to main content

forge_primitives/
biometric.rs

1//! The biometric proxy's shapes: one device's enrolment, the purposes it
2//! carries, and the blob a release hands back.
3//!
4//! **No wrapped blob rides the enrolment list.** A view draws which purposes a
5//! device enrolled and when each was last released; the bytes stay behind the
6//! release command, which answers the client that unwraps them. The list is
7//! read by every client of the server, so a blob there would be every device's
8//! ciphertext handed to every viewer for nothing.
9//!
10//! A purpose is the consumer's own name (`"vault"` in v1) and is opaque to the
11//! core: the blob's shape is the consumer's business, and a second consumer is
12//! a second purpose string rather than a change to any of these types.
13
14use std::time::SystemTime;
15
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19/// One purpose's enrolment as a view draws it: the consumer's own name and
20/// when it was enrolled and last released.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct BiometricPurpose {
23    /// The consumer that owns the blob (`"vault"` in v1).
24    pub purpose: String,
25    /// When this purpose was enrolled on the device.
26    pub created_at: SystemTime,
27    /// When the blob was last released, or `None` until it is.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub last_used_at: Option<SystemTime>,
30}
31
32/// One device's enrolment: the id its client minted, the device's own name,
33/// and the purposes it registered.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct BiometricEnrolment {
36    /// The id the enrolling client minted for this device, which every later
37    /// release and revoke names.
38    pub id: Uuid,
39    /// The device's own name for itself, as the settings page draws it.
40    pub device: String,
41    /// When the device first enrolled.
42    pub created_at: SystemTime,
43    pub purposes: Vec<BiometricPurpose>,
44}
45
46/// The blob a release answers with: exactly the bytes the client enrolled,
47/// base64 on the wire and opaque to the core.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct BiometricBlob {
50    /// The wrapped secret, base64.
51    pub blob: String,
52}
53
54impl BiometricBlob {
55    /// Wrap the stored bytes for the wire.
56    pub fn encode(blob: &[u8]) -> Self {
57        use base64::Engine as _;
58        Self { blob: base64::engine::general_purpose::STANDARD.encode(blob) }
59    }
60
61    /// The stored bytes back. A string that is not base64 is refused rather
62    /// than read as some other blob.
63    pub fn decode(&self) -> Result<Vec<u8>, String> {
64        use base64::Engine as _;
65        base64::engine::general_purpose::STANDARD
66            .decode(self.blob.as_bytes())
67            .map_err(|error| format!("the wrapped blob is not base64: {error}"))
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::time::Duration;
75
76    fn an_enrolment() -> BiometricEnrolment {
77        BiometricEnrolment {
78            id: Uuid::nil(),
79            device: "Ved's MacBook Pro".to_owned(),
80            created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_000),
81            purposes: vec![BiometricPurpose {
82                purpose: "vault".to_owned(),
83                created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_000),
84                last_used_at: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_100)),
85            }],
86        }
87    }
88
89    /// The list crosses the socket and comes back whole, which is what the
90    /// home read and its update both rely on.
91    #[test]
92    fn an_enrolment_round_trips() {
93        let enrolment = an_enrolment();
94        let json = serde_json::to_string(&enrolment).expect("an enrolment serialises");
95        let back: BiometricEnrolment = serde_json::from_str(&json).expect("an enrolment decodes");
96        assert_eq!(enrolment, back);
97    }
98
99    /// The list a view draws carries the facts and no blob. A blob field here
100    /// would fail this rather than reach every client that reads the home.
101    #[test]
102    fn the_view_carries_no_blob() {
103        let value = serde_json::to_value(an_enrolment()).expect("an enrolment serialises");
104        let keys = |value: &serde_json::Value| {
105            let mut keys: Vec<String> =
106                value.as_object().expect("an enrolment is an object").keys().cloned().collect();
107            keys.sort_unstable();
108            keys
109        };
110        assert_eq!(keys(&value), ["created_at", "device", "id", "purposes"]);
111        assert_eq!(keys(&value["purposes"][0]), ["created_at", "last_used_at", "purpose"]);
112        assert!(
113            !value.to_string().contains("blob"),
114            "the enrolment list names no blob at all: {value}",
115        );
116    }
117
118    /// The wire form of a blob is the same bytes back, which is what makes the
119    /// release able to hand the client something it can unwrap.
120    #[test]
121    fn a_blob_encodes_and_decodes_to_the_same_bytes() {
122        let bytes = vec![0u8, 1, 2, 250, 255];
123        let wire = BiometricBlob::encode(&bytes);
124        assert_eq!(wire.decode().expect("a base64 blob decodes"), bytes);
125    }
126
127    /// A blob the client sent that is not base64 is refused at enrol rather
128    /// than stored as some other bytes.
129    #[test]
130    fn a_blob_that_is_not_base64_is_refused() {
131        let wire = BiometricBlob { blob: "not base64!".to_owned() };
132        assert!(wire.decode().is_err());
133    }
134}