From 0ca8a38cbcba67c96fb10c08237ecda5778558da Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 15:47:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-118/p3.5):=20SignatureHasher=20(BLAKE3?= =?UTF-8?q?-keyed)=20=E2=80=94=20117/117=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 46 ++++++- v2/crates/wifi-densepose-bfld/Cargo.toml | 1 + v2/crates/wifi-densepose-bfld/src/lib.rs | 2 + .../src/signature_hasher.rs | 75 +++++++++++ .../tests/signature_hasher.rs | 122 ++++++++++++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 v2/crates/wifi-densepose-bfld/src/signature_hasher.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/signature_hasher.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 5bec7ef4..39d4194e 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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", diff --git a/v2/crates/wifi-densepose-bfld/Cargo.toml b/v2/crates/wifi-densepose-bfld/Cargo.toml index 725aefbc..4e2b460f 100644 --- a/v2/crates/wifi-densepose-bfld/Cargo.toml +++ b/v2/crates/wifi-densepose-bfld/Cargo.toml @@ -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 } diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 2a7e6162..609768f7 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -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. diff --git a/v2/crates/wifi-densepose-bfld/src/signature_hasher.rs b/v2/crates/wifi-densepose-bfld/src/signature_hasher.rs new file mode 100644 index 00000000..e7529e4c --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/signature_hasher.rs @@ -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) + } +} diff --git a/v2/crates/wifi-densepose-bfld/tests/signature_hasher.rs b/v2/crates/wifi-densepose-bfld/tests/signature_hasher.rs new file mode 100644 index 00000000..f3e83b48 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/signature_hasher.rs @@ -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 { + (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}", + ); +}