feat(adr-118/p3.5): SignatureHasher (BLAKE3-keyed) — 117/117 GREEN
Iter 15. Lands ADR-120 §2.3 — the cryptographic foundation of invariant
I3 ("cross-site identity correlation is impossible"). rf_signature_hash
is now derived from a per-site secret and a daily epoch, so two nodes
observing the same physical person produce uncorrelated 256-bit digests.
Added (no_std-compatible):
- blake3 = "1.5", default-features = false (no_std, no SIMD by default)
- src/signature_hasher.rs:
* Constants SECONDS_PER_DAY (86_400), SITE_SALT_LEN (32), RF_SIGNATURE_LEN (32)
* SignatureHasher { site_salt: [u8; 32] } with new(salt) const ctor
* compute(day_epoch, &features) -> [u8; 32] (BLAKE3 keyed mode)
* compute_at(unix_secs, &features) -> [u8; 32] convenience
* day_epoch_from_unix_secs(unix_secs) -> u32 helper (floor(t / 86400))
- pub use SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN from lib.rs
tests/signature_hasher.rs (8 named tests, all green):
deterministic_under_identical_inputs
different_site_salts_produce_different_hashes
different_day_epochs_rotate_the_hash
different_features_produce_different_hashes
output_length_is_32_bytes
day_epoch_from_unix_secs_matches_floor_division
(covers 0, 86_399, 86_400, and the 1.7e9 modern timestamp)
compute_at_matches_compute_with_derived_day
cross_site_hamming_distance_is_statistically_high
*** ADR-120 §2.7 AC2 acceptance test ***
Runs 100 trials with distinct (salt_a, salt_b) pairs observing
identical features, computes per-trial Hamming distance, asserts
mean >= 120 bits and min >= 80 bits. Empirically lands at ~128 bits
mean (the expected value for two independent 256-bit hashes), with
no trial below 80 bits — i.e., zero suspicious near-collisions.
ACs progressed:
- ADR-120 §2.7 AC2 — structurally enforced cross-site isolation, now
proven empirically by the Hamming-distance test. This is the
cryptographic half of invariant I3 in code, not just docs.
- ADR-118 invariant I3 — first runtime witness that two sites with
independent site_salts cannot correlate the same person's signature.
Test config:
- cargo test --no-default-features → 72 passed (64 + 8; signature_hasher is no_std)
- cargo test → 117 passed (109 + 8)
Out of scope (next iter target):
- Wire SignatureHasher into BfldEmitter: replace caller-supplied
rf_signature_hash with hasher.compute_at(ts, &features) so the
pipeline produces correct hashes end-to-end.
- IdentityFeatures canonical-bytes encoder so callers don't need to
hand-serialize per-feature representations.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
9c518f6e36
commit
0ca8a38cbc
|
|
@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -198,6 +198,12 @@ dependencies = [
|
|||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
|
|
@ -456,6 +462,20 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq 0.4.2",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -1088,6 +1108,12 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
|
|
@ -1173,6 +1199,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
|
|
@ -1397,7 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
|
|
@ -7015,7 +7050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -7026,7 +7061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -9158,6 +9193,7 @@ checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
|||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc",
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -10412,7 +10448,7 @@ dependencies = [
|
|||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"constant_time_eq 0.1.5",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ soul-signature = []
|
|||
thiserror.workspace = true
|
||||
static_assertions = "1.1"
|
||||
crc = "3"
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"], optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub mod identity_risk;
|
|||
pub mod payload;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod privacy_gate;
|
||||
pub mod signature_hasher;
|
||||
pub mod sink;
|
||||
|
||||
pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle};
|
||||
|
|
@ -43,6 +44,7 @@ pub use frame::BfldFrame;
|
|||
pub use payload::BfldPayload;
|
||||
#[cfg(feature = "std")]
|
||||
pub use privacy_gate::PrivacyGate;
|
||||
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||||
|
||||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
//! `SignatureHasher` — BLAKE3 keyed-hash for `rf_signature_hash`. ADR-120 §2.3.
|
||||
//!
|
||||
//! Computes a per-site, per-day, identity-features digest that **structurally
|
||||
//! prevents** cross-site identity correlation (BFLD invariant I3):
|
||||
//!
|
||||
//! ```text
|
||||
//! rf_signature_hash = BLAKE3-keyed(site_salt, day_epoch || features)
|
||||
//! ```
|
||||
//!
|
||||
//! - **Site isolation**: `site_salt` is a 256-bit secret unique to each node
|
||||
//! and never transmitted. Two nodes observing the same physical person
|
||||
//! produce uncorrelated hashes — there is no key an operator (or an
|
||||
//! attacker who compromises one node) can use to bridge sites.
|
||||
//! - **Daily rotation**: `day_epoch = floor(unix_time_utc / 86_400)` flips at
|
||||
//! UTC midnight, so the same person's hash changes once per day.
|
||||
//!
|
||||
//! See ADR-120 §2.7 AC2 for the cross-site Hamming-distance acceptance
|
||||
//! criterion. `tests/signature_hasher.rs` exercises it directly.
|
||||
|
||||
use blake3::Hasher;
|
||||
|
||||
/// Number of seconds in a UTC day; the daily-rotation modulus.
|
||||
pub const SECONDS_PER_DAY: u64 = 86_400;
|
||||
|
||||
/// Length of the keyed `site_salt`, fixed by BLAKE3 keyed mode at 32 bytes.
|
||||
pub const SITE_SALT_LEN: usize = 32;
|
||||
|
||||
/// Output length — always 32 bytes (BLAKE3 default).
|
||||
pub const RF_SIGNATURE_LEN: usize = 32;
|
||||
|
||||
/// Per-node hasher carrying the secret `site_salt`. Construct once at boot
|
||||
/// from the persistent secret store (TPM, KMS, or strict-mode file).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignatureHasher {
|
||||
site_salt: [u8; SITE_SALT_LEN],
|
||||
}
|
||||
|
||||
impl SignatureHasher {
|
||||
/// Build a hasher from an existing `site_salt`. The salt is **never
|
||||
/// transmitted** from this point on; callers must keep it in secure storage.
|
||||
#[must_use]
|
||||
pub const fn new(site_salt: [u8; SITE_SALT_LEN]) -> Self {
|
||||
Self { site_salt }
|
||||
}
|
||||
|
||||
/// Compute the daily epoch from a UTC unix-seconds timestamp.
|
||||
#[must_use]
|
||||
pub const fn day_epoch_from_unix_secs(unix_secs: u64) -> u32 {
|
||||
(unix_secs / SECONDS_PER_DAY) as u32
|
||||
}
|
||||
|
||||
/// Compute the `rf_signature_hash` for the supplied (day, features) pair.
|
||||
/// `features` is the canonical-bytes representation of the current
|
||||
/// identity-features tuple — the caller is responsible for deterministic
|
||||
/// serialization (e.g., `bincode` with sorted keys, or a hand-rolled
|
||||
/// fixed-order byte layout).
|
||||
#[must_use]
|
||||
pub fn compute(&self, day_epoch: u32, features: &[u8]) -> [u8; RF_SIGNATURE_LEN] {
|
||||
let mut hasher = Hasher::new_keyed(&self.site_salt);
|
||||
hasher.update(&day_epoch.to_le_bytes());
|
||||
hasher.update(features);
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// Convenience: compute from a unix-seconds timestamp instead of an
|
||||
/// explicit `day_epoch`.
|
||||
#[must_use]
|
||||
pub fn compute_at(
|
||||
&self,
|
||||
unix_secs: u64,
|
||||
features: &[u8],
|
||||
) -> [u8; RF_SIGNATURE_LEN] {
|
||||
self.compute(Self::day_epoch_from_unix_secs(unix_secs), features)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
//! Acceptance tests for ADR-120 §2.3 / §2.7 — `SignatureHasher` cross-site
|
||||
//! isolation and daily rotation.
|
||||
|
||||
use wifi_densepose_bfld::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||||
|
||||
fn salt(seed: u8) -> [u8; SITE_SALT_LEN] {
|
||||
let mut s = [0u8; SITE_SALT_LEN];
|
||||
for (i, b) in s.iter_mut().enumerate() {
|
||||
*b = seed.wrapping_add(i as u8);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn features(seed: u8) -> Vec<u8> {
|
||||
(0..64u8).map(|i| i.wrapping_add(seed)).collect()
|
||||
}
|
||||
|
||||
fn hamming_distance(a: &[u8; RF_SIGNATURE_LEN], b: &[u8; RF_SIGNATURE_LEN]) -> u32 {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| (x ^ y).count_ones())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_under_identical_inputs() {
|
||||
let h = SignatureHasher::new(salt(7));
|
||||
let a = h.compute(42, &features(0));
|
||||
let b = h.compute(42, &features(0));
|
||||
assert_eq!(a, b, "identical inputs must produce identical hashes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_site_salts_produce_different_hashes() {
|
||||
let a = SignatureHasher::new(salt(1)).compute(42, &features(0));
|
||||
let b = SignatureHasher::new(salt(2)).compute(42, &features(0));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_day_epochs_rotate_the_hash() {
|
||||
let h = SignatureHasher::new(salt(7));
|
||||
let day0 = h.compute(0, &features(0));
|
||||
let day1 = h.compute(1, &features(0));
|
||||
assert_ne!(day0, day1, "day rotation must change the hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_features_produce_different_hashes() {
|
||||
let h = SignatureHasher::new(salt(7));
|
||||
let a = h.compute(42, &features(0));
|
||||
let b = h.compute(42, &features(1));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_length_is_32_bytes() {
|
||||
let h = SignatureHasher::new(salt(0));
|
||||
let out = h.compute(0, b"");
|
||||
assert_eq!(out.len(), RF_SIGNATURE_LEN);
|
||||
assert_eq!(RF_SIGNATURE_LEN, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_epoch_from_unix_secs_matches_floor_division() {
|
||||
assert_eq!(SignatureHasher::day_epoch_from_unix_secs(0), 0);
|
||||
assert_eq!(SignatureHasher::day_epoch_from_unix_secs(86_399), 0);
|
||||
assert_eq!(SignatureHasher::day_epoch_from_unix_secs(86_400), 1);
|
||||
// Unix epoch ≈ 1.7e9 sec on date in 2024-ish; just check the math:
|
||||
assert_eq!(
|
||||
SignatureHasher::day_epoch_from_unix_secs(1_700_000_000),
|
||||
(1_700_000_000u64 / 86_400) as u32,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_at_matches_compute_with_derived_day() {
|
||||
let h = SignatureHasher::new(salt(3));
|
||||
let unix_secs: u64 = 1_700_000_000;
|
||||
let day = SignatureHasher::day_epoch_from_unix_secs(unix_secs);
|
||||
let a = h.compute(day, &features(0));
|
||||
let b = h.compute_at(unix_secs, &features(0));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// ADR-120 §2.7 AC2 — structural cross-site isolation.
|
||||
///
|
||||
/// Two BFLD nodes with different `site_salt` values observing the same
|
||||
/// (simulated) person produce `rf_signature_hash` values whose Hamming
|
||||
/// distance is statistically high (≈ 128 bits expected for two independent
|
||||
/// 256-bit outputs; ADR threshold is ≥ 120 over 100 trials).
|
||||
#[test]
|
||||
fn cross_site_hamming_distance_is_statistically_high() {
|
||||
let n_trials: usize = 100;
|
||||
let mut total: u32 = 0;
|
||||
let mut min_observed: u32 = u32::MAX;
|
||||
|
||||
for trial in 0..n_trials {
|
||||
let site_a = SignatureHasher::new(salt(trial as u8));
|
||||
let site_b = SignatureHasher::new(salt((trial as u8).wrapping_add(0xA5)));
|
||||
let day = trial as u32;
|
||||
let feats = features(trial as u8);
|
||||
let h_a = site_a.compute(day, &feats);
|
||||
let h_b = site_b.compute(day, &feats);
|
||||
let d = hamming_distance(&h_a, &h_b);
|
||||
total += d;
|
||||
min_observed = min_observed.min(d);
|
||||
}
|
||||
|
||||
let mean = total as f32 / n_trials as f32;
|
||||
// Expectation for two independent 256-bit hashes is 128 bits; require ≥ 120
|
||||
// per ADR-120 §2.7 AC2.
|
||||
assert!(
|
||||
mean >= 120.0,
|
||||
"mean Hamming distance must be >= 120, got {mean}",
|
||||
);
|
||||
// Minimum observed should also be far above 0 (no collisions).
|
||||
assert!(
|
||||
min_observed >= 80,
|
||||
"min Hamming distance suspiciously low: {min_observed}",
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue