use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::{
crypto::{
aead::{self, AeadKey, Nonce},
hash, shamir,
},
error::Error,
fingerprint::identifier::{HardwareIdentifier, IdentifierKind},
};
const IDENTIFIER_KEY_DOMAIN: &[u8; 32] = b"zlicenser-v1-hw-identifier-key--";
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct MasterSecret([u8; 32]);
impl MasterSecret {
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn from_bytes(b: [u8; 32]) -> Self {
Self(b)
}
}
impl std::fmt::Debug for MasterSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("MasterSecret([REDACTED])")
}
}
pub trait HardwareCollector {
fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>>;
}
pub struct MockCollector {
identifiers: Vec<HardwareIdentifier>,
}
impl MockCollector {
pub fn new(identifiers: Vec<HardwareIdentifier>) -> Self {
Self { identifiers }
}
pub fn default_fixture() -> Self {
use crate::fingerprint::identifier::IdentifierKind::*;
let entries = [
(SmbiosBoardUuid, b"mock-board-uuid-0000".as_slice()),
(SmbiosSystemSerial, b"mock-system-serial-00"),
(CpuVendorAndModel, b"GenuineIntel|Intel(R) Core(TM) i7"),
(MachineId, b"aabbccddeeff00112233445566778899"),
(DiskSerial { index: 0 }, b"MOCK_DISK_0_SERIAL"),
(MacAddress { index: 0 }, b"\x00\x11\x22\x33\x44\x55"),
(PciSignature { index: 0 }, b"8086:1234"),
];
Self {
identifiers: entries
.iter()
.map(|(kind, val)| HardwareIdentifier::new(kind.clone(), val.to_vec()))
.collect(),
}
}
}
impl HardwareCollector for MockCollector {
fn collect(&self) -> crate::Result<Vec<HardwareIdentifier>> {
Ok(self.identifiers.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedShare {
pub kind: IdentifierKind,
#[serde(with = "crate::wire::bytes::nonce_bytes")]
pub nonce: [u8; 24],
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrollmentRecord {
pub shares: Vec<EncryptedShare>,
pub threshold: u8,
}
pub fn compute_commitment(identifiers: &[HardwareIdentifier]) -> [u8; 32] {
let mut sorted: Vec<&HardwareIdentifier> = identifiers.iter().collect();
sorted.sort_by(|a, b| {
let ka = format!("{:?}", a.kind);
let kb = format!("{:?}", b.kind);
ka.cmp(&kb)
});
let mut combined = Vec::new();
for id in &sorted {
let key_repr = format!("{:?}", id.kind);
combined.extend_from_slice(key_repr.as_bytes());
combined.push(b':');
combined.extend_from_slice(&id.value);
combined.push(b'\n');
}
*hash::hash(&combined).as_bytes()
}
pub struct FuzzyExtractor;
impl FuzzyExtractor {
pub fn enroll(
identifiers: &[HardwareIdentifier],
threshold: u8,
) -> crate::Result<(MasterSecret, EnrollmentRecord)> {
let n = identifiers.len();
if n == 0 {
return Err(Error::Collection("no hardware identifiers provided".into()));
}
if threshold as usize > n {
return Err(Error::Collection(
"threshold exceeds number of identifiers".into(),
));
}
if threshold == 0 {
return Err(Error::Collection("threshold must be at least 1".into()));
}
use rand::RngCore;
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
let shares = shamir::split(&secret, threshold, n as u8)?;
let encrypted: Vec<EncryptedShare> = identifiers
.iter()
.zip(shares.iter())
.map(|(id, share)| {
let key = derive_identifier_key(&id.value);
let nonce = Nonce::random();
let ciphertext = aead::encrypt(&key, &nonce, &share_to_bytes(share), &[]);
EncryptedShare {
kind: id.kind.clone(),
nonce: *nonce.as_bytes(),
ciphertext,
}
})
.collect();
Ok((
MasterSecret(secret),
EnrollmentRecord {
shares: encrypted,
threshold,
},
))
}
pub fn reconstruct(
identifiers: &[HardwareIdentifier],
record: &EnrollmentRecord,
) -> crate::Result<MasterSecret> {
let mut recovered = Vec::new();
for stored in &record.shares {
let Some(current) = identifiers.iter().find(|id| id.kind == stored.kind) else {
continue;
};
let key = derive_identifier_key(¤t.value);
let nonce = Nonce::from_bytes(stored.nonce);
let Ok(plaintext) = aead::decrypt(&key, &nonce, &stored.ciphertext, &[]) else {
continue; };
if let Some(share) = bytes_to_share(&plaintext) {
recovered.push(share);
}
}
if recovered.len() < record.threshold as usize {
return Err(Error::InsufficientIdentifiers {
recovered: recovered.len(),
threshold: record.threshold as usize,
});
}
let secret = shamir::combine(&recovered)?;
Ok(MasterSecret(secret))
}
}
fn derive_identifier_key(identifier_value: &[u8]) -> AeadKey {
let h = hash::keyed_hash(IDENTIFIER_KEY_DOMAIN, identifier_value);
AeadKey::from_bytes(h.as_bytes())
}
fn share_to_bytes(share: &shamir::Share) -> Vec<u8> {
crate::wire::encode(share).expect("share serialisation should never fail")
}
fn bytes_to_share(bytes: &[u8]) -> Option<shamir::Share> {
crate::wire::decode(bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_identifiers() -> Vec<HardwareIdentifier> {
MockCollector::default_fixture().collect().unwrap()
}
#[test]
fn enroll_and_reconstruct_exact_match() {
let ids = fixture_identifiers();
let (secret, record) = FuzzyExtractor::enroll(&ids, 5).unwrap();
let recovered = FuzzyExtractor::reconstruct(&ids, &record).unwrap();
assert_eq!(secret.as_bytes(), recovered.as_bytes());
}
#[test]
fn reconstruct_tolerates_missing_identifiers() {
let ids = fixture_identifiers();
let threshold = 4u8;
let (secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
let partial: Vec<_> = ids.iter().take(5).cloned().collect();
let recovered = FuzzyExtractor::reconstruct(&partial, &record).unwrap();
assert_eq!(secret.as_bytes(), recovered.as_bytes());
}
#[test]
fn reconstruct_fails_below_threshold() {
let ids = fixture_identifiers();
let threshold = 5u8;
let (_secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
let partial: Vec<_> = ids.iter().take(3).cloned().collect();
let result = FuzzyExtractor::reconstruct(&partial, &record);
assert!(matches!(
result,
Err(Error::InsufficientIdentifiers {
recovered: 3,
threshold: 5
})
));
}
#[test]
fn reconstruct_fails_when_identifier_value_changed() {
let ids = fixture_identifiers();
let (_secret, record) = FuzzyExtractor::enroll(&ids, 7).unwrap();
let tampered: Vec<_> = ids
.iter()
.map(|id| HardwareIdentifier::new(id.kind.clone(), b"wrong-value".to_vec()))
.collect();
let result = FuzzyExtractor::reconstruct(&tampered, &record);
assert!(result.is_err());
}
#[test]
fn commitment_is_stable() {
let ids = fixture_identifiers();
assert_eq!(compute_commitment(&ids), compute_commitment(&ids));
}
#[test]
fn commitment_changes_when_value_changes() {
let ids = fixture_identifiers();
let mut modified = ids.clone();
modified[0].value = b"different".to_vec();
assert_ne!(compute_commitment(&ids), compute_commitment(&modified));
}
#[test]
fn enroll_rejects_zero_threshold() {
let ids = fixture_identifiers();
assert!(FuzzyExtractor::enroll(&ids, 0).is_err());
}
#[test]
fn enroll_rejects_threshold_above_count() {
let ids = fixture_identifiers();
let too_high = ids.len() as u8 + 1;
assert!(FuzzyExtractor::enroll(&ids, too_high).is_err());
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_reconstruct_with_any_threshold_subset(
drop_count in 0usize..3,
) {
let ids = fixture_identifiers(); let threshold = (ids.len() - drop_count) as u8;
if threshold == 0 { return Ok(()); }
let (secret, record) = FuzzyExtractor::enroll(&ids, threshold).unwrap();
let subset: Vec<_> = ids.iter().take(threshold as usize).cloned().collect();
let recovered = FuzzyExtractor::reconstruct(&subset, &record).unwrap();
prop_assert_eq!(secret.as_bytes(), recovered.as_bytes());
}
}
}