Skip to main content

zlicenser_protocol/fingerprint/
extractor.rs

1use serde::{Deserialize, Serialize};
2use zeroize::{Zeroize, ZeroizeOnDrop};
3
4use crate::{
5    crypto::{
6        aead::{self, AeadKey, Nonce},
7        hash, shamir,
8    },
9    error::Error,
10    fingerprint::identifier::{HardwareIdentifier, IdentifierKind},
11};
12
13// Domain separation constant so identifier hashes can't be confused with other BLAKE3 hashes.
14const IDENTIFIER_KEY_DOMAIN: &[u8; 32] = b"zlicenser-v1-hw-identifier-key--";
15
16/// The reconstructed 32-byte secret that is stable for matching hardware.
17#[derive(Clone, Zeroize, ZeroizeOnDrop)]
18pub struct MasterSecret([u8; 32]);
19
20impl MasterSecret {
21    pub fn as_bytes(&self) -> &[u8; 32] {
22        &self.0
23    }
24
25    pub fn from_bytes(b: [u8; 32]) -> Self {
26        Self(b)
27    }
28}
29
30impl std::fmt::Debug for MasterSecret {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str("MasterSecret([REDACTED])")
33    }
34}
35
36// real impl is in linux/ (collect-linux feature); tests use MockCollector
37pub trait HardwareCollector {
38    fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>>;
39}
40
41/// Mock collector for tests and platforms without real hardware collection.
42pub struct MockCollector {
43    identifiers: Vec<HardwareIdentifier>,
44}
45
46impl MockCollector {
47    pub fn new(identifiers: Vec<HardwareIdentifier>) -> Self {
48        Self { identifiers }
49    }
50
51    /// Creates a fixture with a realistic spread of identifier kinds.
52    pub fn default_fixture() -> Self {
53        use crate::fingerprint::identifier::IdentifierKind::*;
54        let entries = [
55            (SmbiosBoardUuid, b"mock-board-uuid-0000".as_slice()),
56            (SmbiosSystemSerial, b"mock-system-serial-00"),
57            (CpuVendorAndModel, b"GenuineIntel|Intel(R) Core(TM) i7"),
58            (MachineId, b"aabbccddeeff00112233445566778899"),
59            (DiskSerial { index: 0 }, b"MOCK_DISK_0_SERIAL"),
60            (MacAddress { index: 0 }, b"\x00\x11\x22\x33\x44\x55"),
61            (PciSignature { index: 0 }, b"8086:1234"),
62        ];
63        Self {
64            identifiers: entries
65                .iter()
66                .map(|(kind, val)| HardwareIdentifier::new(kind.clone(), val.to_vec()))
67                .collect(),
68        }
69    }
70}
71
72impl HardwareCollector for MockCollector {
73    fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>> {
74        Ok(self.identifiers.clone())
75    }
76}
77
78/// One AEAD-encrypted Shamir share tied to a specific hardware identifier kind.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct EncryptedShare {
81    pub kind: IdentifierKind,
82    #[serde(with = "crate::wire::bytes::nonce_bytes")]
83    pub nonce: [u8; 24],
84    pub ciphertext: Vec<u8>,
85}
86
87/// Persisted after enrollment alongside the license. Needs the master secret to decrypt.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnrollmentRecord {
90    pub shares: Vec<EncryptedShare>,
91    pub threshold: u8,
92}
93
94/// Stable hash of the given identifiers, sorted by kind so collection order doesn't matter.
95pub fn compute_commitment(identifiers: &[HardwareIdentifier]) -> [u8; 32] {
96    let mut sorted: Vec<&HardwareIdentifier> = identifiers.iter().collect();
97    sorted.sort_by(|a, b| {
98        let ka = format!("{:?}", a.kind);
99        let kb = format!("{:?}", b.kind);
100        ka.cmp(&kb)
101    });
102    let mut combined = Vec::new();
103    for id in &sorted {
104        let key_repr = format!("{:?}", id.kind);
105        combined.extend_from_slice(key_repr.as_bytes());
106        combined.push(b':');
107        combined.extend_from_slice(&id.value);
108        combined.push(b'\n');
109    }
110    *hash::hash(&combined).as_bytes()
111}
112
113pub struct FuzzyExtractor;
114
115impl FuzzyExtractor {
116    /// Enrolls identifiers. Returns the master secret (never stored, keep it!) and the enrollment record.
117    /// threshold must be >= 1 and <= identifiers.len().
118    pub fn enroll(
119        identifiers: &[HardwareIdentifier],
120        threshold: u8,
121    ) -> crate::Result<(MasterSecret, EnrollmentRecord)> {
122        let n = identifiers.len();
123        if n == 0 {
124            return Err(Error::Collection("no hardware identifiers provided".into()));
125        }
126        if threshold as usize > n {
127            return Err(Error::Collection(
128                "threshold exceeds number of identifiers".into(),
129            ));
130        }
131        if threshold == 0 {
132            return Err(Error::Collection("threshold must be at least 1".into()));
133        }
134
135        use rand::RngCore;
136        let mut secret = [0u8; 32];
137        rand::rngs::OsRng.fill_bytes(&mut secret);
138
139        let shares = shamir::split(&secret, threshold, n as u8)?;
140
141        let encrypted: Vec<EncryptedShare> = identifiers
142            .iter()
143            .zip(shares.iter())
144            .map(|(id, share)| {
145                let key = derive_identifier_key(&id.value);
146                let nonce = Nonce::random();
147                let ciphertext = aead::encrypt(&key, &nonce, &share_to_bytes(share), &[]);
148                EncryptedShare {
149                    kind: id.kind.clone(),
150                    nonce: *nonce.as_bytes(),
151                    ciphertext,
152                }
153            })
154            .collect();
155
156        Ok((
157            MasterSecret(secret),
158            EnrollmentRecord {
159                shares: encrypted,
160                threshold,
161            },
162        ))
163    }
164
165    /// Reconstructs the master secret. Needs at least record.threshold identifiers to still match.
166    pub fn reconstruct(
167        identifiers: &[HardwareIdentifier],
168        record: &EnrollmentRecord,
169    ) -> crate::Result<MasterSecret> {
170        let mut recovered = Vec::new();
171
172        for stored in &record.shares {
173            let Some(current) = identifiers.iter().find(|id| id.kind == stored.kind) else {
174                continue;
175            };
176
177            let key = derive_identifier_key(&current.value);
178            let nonce = Nonce::from_bytes(stored.nonce);
179            let Ok(plaintext) = aead::decrypt(&key, &nonce, &stored.ciphertext, &[]) else {
180                continue; // identifier changed, skip this share
181            };
182
183            if let Some(share) = bytes_to_share(&plaintext) {
184                recovered.push(share);
185            }
186        }
187
188        if recovered.len() < record.threshold as usize {
189            return Err(Error::InsufficientIdentifiers {
190                recovered: recovered.len(),
191                threshold: record.threshold as usize,
192            });
193        }
194
195        let secret = shamir::combine(&recovered)?;
196        Ok(MasterSecret(secret))
197    }
198}
199
200fn derive_identifier_key(identifier_value: &[u8]) -> AeadKey {
201    let h = hash::keyed_hash(IDENTIFIER_KEY_DOMAIN, identifier_value);
202    AeadKey::from_bytes(h.as_bytes())
203}
204
205fn share_to_bytes(share: &shamir::Share) -> Vec<u8> {
206    // ciborium for consistency with the rest of the wire format
207    crate::wire::encode(share).expect("share serialisation should never fail")
208}
209
210fn bytes_to_share(bytes: &[u8]) -> Option<shamir::Share> {
211    crate::wire::decode(bytes).ok()
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn fixture_identifiers() -> Vec<HardwareIdentifier> {
219        MockCollector::default_fixture().collect().unwrap()
220    }
221
222    #[test]
223    fn enroll_and_reconstruct_exact_match() {
224        let ids = fixture_identifiers();
225        let (secret, record) = FuzzyExtractor::enroll(&ids, 5).unwrap();
226        let recovered = FuzzyExtractor::reconstruct(&ids, &record).unwrap();
227        assert_eq!(secret.as_bytes(), recovered.as_bytes());
228    }
229
230    #[test]
231    fn reconstruct_tolerates_missing_identifiers() {
232        let ids = fixture_identifiers();
233        let threshold = 4u8;
234        let (secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
235
236        // Drop two identifiers so only 5 of 7 are present (threshold = 4, should still work).
237        let partial: Vec<_> = ids.iter().take(5).cloned().collect();
238        let recovered = FuzzyExtractor::reconstruct(&partial, &record).unwrap();
239        assert_eq!(secret.as_bytes(), recovered.as_bytes());
240    }
241
242    #[test]
243    fn reconstruct_fails_below_threshold() {
244        let ids = fixture_identifiers();
245        let threshold = 5u8;
246        let (_secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
247
248        // Provide only 3 correct identifiers.
249        let partial: Vec<_> = ids.iter().take(3).cloned().collect();
250        let result = FuzzyExtractor::reconstruct(&partial, &record);
251        assert!(matches!(
252            result,
253            Err(Error::InsufficientIdentifiers {
254                recovered: 3,
255                threshold: 5
256            })
257        ));
258    }
259
260    #[test]
261    fn reconstruct_fails_when_identifier_value_changed() {
262        let ids = fixture_identifiers();
263        let (_secret, record) = FuzzyExtractor::enroll(&ids, 7).unwrap();
264
265        // Replace all values with different bytes – none should decrypt.
266        let tampered: Vec<_> = ids
267            .iter()
268            .map(|id| HardwareIdentifier::new(id.kind.clone(), b"wrong-value".to_vec()))
269            .collect();
270        let result = FuzzyExtractor::reconstruct(&tampered, &record);
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn commitment_is_stable() {
276        let ids = fixture_identifiers();
277        assert_eq!(compute_commitment(&ids), compute_commitment(&ids));
278    }
279
280    #[test]
281    fn commitment_changes_when_value_changes() {
282        let ids = fixture_identifiers();
283        let mut modified = ids.clone();
284        modified[0].value = b"different".to_vec();
285        assert_ne!(compute_commitment(&ids), compute_commitment(&modified));
286    }
287
288    #[test]
289    fn enroll_rejects_zero_threshold() {
290        let ids = fixture_identifiers();
291        assert!(FuzzyExtractor::enroll(&ids, 0).is_err());
292    }
293
294    #[test]
295    fn enroll_rejects_threshold_above_count() {
296        let ids = fixture_identifiers();
297        let too_high = ids.len() as u8 + 1;
298        assert!(FuzzyExtractor::enroll(&ids, too_high).is_err());
299    }
300
301    // proptest: any subset >= threshold reconstructs correctly
302    use proptest::prelude::*;
303
304    proptest! {
305        #[test]
306        fn prop_reconstruct_with_any_threshold_subset(
307            drop_count in 0usize..3,
308        ) {
309            let ids = fixture_identifiers(); // 7 identifiers
310            let threshold = (ids.len() - drop_count) as u8;
311            if threshold == 0 { return Ok(()); }
312            let (secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
313
314            // Keep exactly `threshold` identifiers.
315            let subset: Vec<_> = ids.iter().take(threshold as usize).cloned().collect();
316            let recovered = FuzzyExtractor::reconstruct(&subset, &record).unwrap();
317            prop_assert_eq!(secret.as_bytes(), recovered.as_bytes());
318        }
319    }
320}