From 29f23cb97e68b3b7b00874379cd9d400e4d09490 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 16:08:29 -0400 Subject: [PATCH] feat(adr-118/p4.4): rf_signature_hash JSON as "blake3:" (128/128 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 17. Lands the BFLD JSON wire spec format for rf_signature_hash — a "blake3:" prefix followed by 64 lowercase hex chars. Replaces the default serde array-of-integers encoding which was unusable for downstream consumers (HA, Matter, MQTT). Added (in src/event.rs): - ser_rf_signature_hash(hash: &Option<[u8;32]>, s) custom serializer - Field attribute on BfldEvent.rf_signature_hash now uses serialize_with = "ser_rf_signature_hash" alongside skip_serializing_if - nibble_to_hex(u8) -> char private const fn (no `hex` crate dep needed for 32 bytes; lowercase hex is trivial) - Output format: "blake3:deadbeef..." exactly 71 ASCII chars tests/json_hash_format.rs (5 named tests, all green): rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex (expected hex built programmatically via format!("{b:02x}")) hex_string_is_always_64_chars_when_present (parses the JSON, isolates the hash substring, asserts exact 64 chars and lowercase-only — catches case-folding regressions) hash_field_omitted_entirely_when_none end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash *** Cross-iter integration test: BfldEmitter::with_signature_hasher → SensingInputs.rf_signature_hash = None → emit derives via BLAKE3 → BfldEvent::to_json → contains "blake3:" prefix. Spans iters 13, 14, 15, 16, 17 in a single assertion. *** end_to_end_restricted_class_omits_hash_even_with_hasher_set (class 3: even with hasher installed, JSON omits the hash) ACs progressed: - BFLD wire spec §6 — rf_signature_hash JSON shape now matches the documented format ("blake3:..."); HA / Matter consumers can parse it without custom byte-array decoding. - ADR-118 §1 invariant I3 — visibility: the JSON wire form now cryptographically tags the hash with its algorithm prefix, so consumers can verify they're not parsing a different (weaker) hash that a future PR might accidentally substitute. Test config: - cargo test --no-default-features → 72 passed (json_hash_format cfg-out) - cargo test → 128 passed (123 + 5) Out of scope (next iter target): - IdentityFeatures typed encoder so callers feeding BfldEmitter don't need to know that embedding bytes serve as hasher input. - Replace the manual hex push with `hex::encode` if/when the workspace takes on the `hex` crate dep for other reasons; current path saves the dep without sacrificing correctness. Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-bfld/src/event.rs | 36 ++++- .../tests/json_hash_format.rs | 138 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-bfld/tests/json_hash_format.rs diff --git a/v2/crates/wifi-densepose-bfld/src/event.rs b/v2/crates/wifi-densepose-bfld/src/event.rs index a3de0fc8..e8766c96 100644 --- a/v2/crates/wifi-densepose-bfld/src/event.rs +++ b/v2/crates/wifi-densepose-bfld/src/event.rs @@ -64,7 +64,11 @@ pub struct BfldEvent { pub identity_risk_score: Option, /// 256-bit BLAKE3 keyed hash of the current cluster. Class 2 only; `None` at class 3. - #[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))] + /// Serializes as the JSON string `"blake3:<64-hex>"` per the BFLD wire spec. + #[cfg_attr( + feature = "serde-json", + serde(skip_serializing_if = "Option::is_none", serialize_with = "ser_rf_signature_hash") + )] pub rf_signature_hash: Option<[u8; 32]>, } @@ -134,3 +138,33 @@ fn ser_privacy_class( }; s.serialize_str(name) } + +/// Encode an `Option<[u8; 32]>` as the JSON string `"blake3:<64 lowercase hex chars>"`. +/// Used for `rf_signature_hash` so consumers don't have to decode a 32-element JSON +/// array of integers. Called only when the value is `Some(_)` because +/// `skip_serializing_if = "Option::is_none"` short-circuits the `None` case. +#[cfg(feature = "serde-json")] +fn ser_rf_signature_hash( + hash: &Option<[u8; 32]>, + s: S, +) -> Result { + // The unwrap is safe: skip_serializing_if guarantees we only run with Some. + let bytes = hash.as_ref().expect("ser_rf_signature_hash called with None"); + let mut out = String::with_capacity(7 + 64); // "blake3:" + 32*2 hex chars + out.push_str("blake3:"); + for b in bytes { + // Manual lowercase-hex push — avoids pulling in the `hex` crate for 32 bytes. + out.push(nibble_to_hex(b >> 4)); + out.push(nibble_to_hex(b & 0x0F)); + } + s.serialize_str(&out) +} + +#[cfg(feature = "serde-json")] +const fn nibble_to_hex(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'a' + (n - 10)) as char, + _ => '?', // unreachable: input is masked with 0x0F + } +} diff --git a/v2/crates/wifi-densepose-bfld/tests/json_hash_format.rs b/v2/crates/wifi-densepose-bfld/tests/json_hash_format.rs new file mode 100644 index 00000000..c9770456 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/json_hash_format.rs @@ -0,0 +1,138 @@ +//! Acceptance tests for the BFLD JSON wire spec `rf_signature_hash` format +//! (`"blake3:<64-hex>"`) and the end-to-end emitter → hasher → event → JSON path. + +#![cfg(all(feature = "std", feature = "serde-json"))] + +use wifi_densepose_bfld::{ + BfldEmitter, BfldEvent, IdentityEmbedding, PrivacyClass, SensingInputs, SignatureHasher, + EMBEDDING_DIM, SITE_SALT_LEN, +}; + +fn manual_event(hash: Option<[u8; 32]>) -> BfldEvent { + BfldEvent::with_privacy_gating( + "seed-01".into(), + 1_700_000_000_000_000_000, + true, + 0.5, + 1, + 0.9, + None, + PrivacyClass::Anonymous, + Some(0.3), + hash, + ) +} + +#[test] +fn rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex() { + let hash = [ + 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33, + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, + 0xCC, 0xDD, 0xEE, 0xFF, 0x12, 0x34, 0x56, 0x78, + 0x9A, 0xBC, 0xDE, 0xF0, 0x0F, 0xED, 0xCB, 0xA9, + ]; + // Build expected hex programmatically — manual typing is error-prone. + let mut expected_hex = String::from("blake3:"); + for b in &hash { + expected_hex.push_str(&format!("{b:02x}")); + } + let json = manual_event(Some(hash)).to_json().unwrap(); + let needle = format!("\"rf_signature_hash\":\"{expected_hex}\""); + assert!( + json.contains(&needle), + "JSON: {json}\nexpected substring: {needle}", + ); +} + +#[test] +fn hex_string_is_always_64_chars_when_present() { + let json = manual_event(Some([0x00; 32])).to_json().unwrap(); + // Find the substring after "blake3:" inside the rf_signature_hash field. + let key = "\"rf_signature_hash\":\"blake3:"; + let start = json.find(key).expect("hash field present") + key.len(); + let end = json[start..].find('"').expect("closing quote") + start; + let hex = &json[start..end]; + assert_eq!(hex.len(), 64, "hash hex must be exactly 64 chars, got {}", hex.len()); + assert!( + hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()), + "hash hex must be lowercase only, got {hex}", + ); +} + +#[test] +fn hash_field_omitted_entirely_when_none() { + let json = manual_event(None).to_json().unwrap(); + assert!( + !json.contains("rf_signature_hash"), + "None hash must be omitted entirely, got: {json}", + ); +} + +// --- Cross-iter integration test ---------------------------------------- + +fn salt() -> [u8; SITE_SALT_LEN] { + let mut s = [0u8; SITE_SALT_LEN]; + for (i, b) in s.iter_mut().enumerate() { + *b = i as u8; + } + s +} + +fn embedding() -> IdentityEmbedding { + let mut a = [0.0f32; EMBEDDING_DIM]; + for (i, v) in a.iter_mut().enumerate() { + *v = (i as f32) * 0.01; + } + IdentityEmbedding::from_raw(a) +} + +fn inputs() -> SensingInputs { + SensingInputs { + timestamp_ns: 1_700_000_000_000_000_000, + presence: true, + motion: 0.42, + person_count: 1, + sensing_confidence: 0.91, + sep: 0.2, + stab: 0.2, + consist: 0.2, + risk_conf: 0.2, + rf_signature_hash: None, // hasher will derive + } +} + +#[test] +fn end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash() { + let mut e = BfldEmitter::new("seed-01") + .with_signature_hasher(SignatureHasher::new(salt())); + let event = e + .emit(inputs(), Some(embedding())) + .expect("low-risk emit must succeed"); + let json = event.to_json().expect("JSON serialization"); + assert!( + json.contains("\"rf_signature_hash\":\"blake3:"), + "end-to-end JSON missing derived hash: {json}", + ); + assert!(json.contains("\"type\":\"bfld_update\"")); + assert!(json.contains("\"node_id\":\"seed-01\"")); + assert!(json.contains("\"privacy_class\":\"anonymous\"")); +} + +#[test] +fn end_to_end_restricted_class_omits_hash_even_with_hasher_set() { + let mut e = BfldEmitter::new("seed-01") + .with_privacy_class(PrivacyClass::Restricted) + .with_signature_hasher(SignatureHasher::new(salt())); + let event = e + .emit(inputs(), Some(embedding())) + .expect("low-risk emit must succeed"); + let json = event.to_json().expect("JSON serialization"); + assert!( + !json.contains("rf_signature_hash"), + "Restricted class must strip rf_signature_hash from JSON, got: {json}", + ); + assert!( + !json.contains("identity_risk_score"), + "Restricted class must also strip identity_risk_score, got: {json}", + ); +}