zlicenser_protocol/fingerprint/
extractor.rs1use 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
13const IDENTIFIER_KEY_DOMAIN: &[u8; 32] = b"zlicenser-v1-hw-identifier-key--";
15
16#[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
36pub trait HardwareCollector {
38 fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>>;
39}
40
41pub struct MockCollector {
43 identifiers: Vec<HardwareIdentifier>,
44}
45
46impl MockCollector {
47 pub fn new(identifiers: Vec<HardwareIdentifier>) -> Self {
48 Self { identifiers }
49 }
50
51 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnrollmentRecord {
90 pub shares: Vec<EncryptedShare>,
91 pub threshold: u8,
92}
93
94pub 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 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 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(¤t.value);
178 let nonce = Nonce::from_bytes(stored.nonce);
179 let Ok(plaintext) = aead::decrypt(&key, &nonce, &stored.ciphertext, &[]) else {
180 continue; };
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 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 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 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 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 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(); 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 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}