Skip to main content

zlicenser_protocol/fingerprint/
extractor.rs

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