zlicenser_protocol/fingerprint/
extractor.rs1use 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
14const IDENTIFIER_KEY_DOMAIN: &[u8; 32] = b"zlicenser-v1-hw-identifier-key--";
16
17#[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
37pub trait HardwareCollector {
39 fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>>;
40}
41
42pub struct MockCollector {
44 identifiers: Vec<HardwareIdentifier>,
45}
46
47impl MockCollector {
48 pub fn new(identifiers: Vec<HardwareIdentifier>) -> Self {
49 Self { identifiers }
50 }
51
52 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct EnrollmentRecord {
94 pub shares: Vec<EncryptedShare>,
95 pub threshold: u8,
96}
97
98pub 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 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 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(¤t.value);
183 let nonce = Nonce::from_bytes(stored.nonce);
184 let Ok(plaintext) = aead::decrypt(&key, &nonce, &stored.ciphertext, &[]) else {
185 continue; };
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 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 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 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 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 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(); 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 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}