docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-05-24 |
| Deciders | ruv |
| Parent | ADR-118 |
| Relates to | ADR-027 (MERIDIAN no-cross-site), ADR-032 (mesh security), ADR-106 (primitive isolation), ADR-115 (privacy mode) |
| Tracking issue | TBD |
ADR-118 declares three structural invariants for BFLD:
I1/I2 are enforced by sink typing and module visibility (ADR-119 §2.3). I3 requires a hash-rotation scheme that makes the same physical person produce different rf_signature_hash values across sites and across day boundaries, without any out-of-band coordination between sites.
The existing HA-PRIVACY mode in ADR-115 already toggles between "full" and "anonymous" surfaces, but at a per-event granularity — not at a per-byte-field granularity. BFLD requires the latter because the BfldFrame payload mixes sensing data (publishable) and identity-derived data (non-publishable) in the same struct.
The BFId paper (KIT, ACM CCS 2025) demonstrates that even a few minutes of BFI capture across the same site is sufficient to build a persistent biometric. The mitigation must be structural, not policy-dependent.
A single privacy_class: u8 byte in the BfldFrame header (ADR-119 §2.1) selects one of four classes. The crate enforces field availability statically through marker types.
| Class | Name | Use case | Available fields |
|---|---|---|---|
| 0 | raw | Local-only research, never networked | All fields, full-precision BFI matrix, identity embedding |
| 1 | derived | Operator-acknowledged research over LAN | Downsampled angle matrix, full features, identity_risk_score, identity_embedding |
| 2 | anonymous (default) | Production deployment | Aggregate sensing only: presence, motion, person_count, zone_id, confidence |
| 3 | restricted | Care-home / regulated deployment | Class 2 minus identity_risk_score and rf_signature_hash |
Default for new RuView nodes is class 2. Operators must explicitly opt-down to class 1 via the existing --research-mode flag (ADR-115 §7); class 0 is reserved for cargo test and is unreachable from wifi-densepose-sensing-server.
pub trait Sink {}
pub trait LocalSink: Sink {} // Allowed: classes 0,1,2,3
pub trait NetworkSink: Sink {} // Allowed: classes 1,2,3 (NOT class 0)
pub trait MatterSink: NetworkSink {} // Allowed: class 2,3 + cluster-filter (ADR-122)
impl Emitter {
pub fn publish<S: NetworkSink>(&self, sink: &S, frame: BfldFrame)
-> Result<(), BfldError>
{
if frame.header.privacy_class == 0 {
return Err(BfldError::PrivacyViolation {
reason: "class 0 to NetworkSink",
});
}
// ... serialize and write
}
}
The compiler refuses to call publish on a sink that doesn't impl NetworkSink with a class-0 frame because the runtime check is paired with a sink-marker check. Cross-sink frame routing requires an explicit class transition (see §2.4).
rf_signature_hashThe signature hash is computed as:
pub fn rf_signature_hash(
site_salt: &[u8; 32], // generated on first boot, persisted in TPM/KMS
day_epoch: u32, // floor(unix_time_utc / 86400)
features: &IdentityFeatures,
) -> Hash {
let mut hasher = blake3::Hasher::new_keyed(site_salt);
hasher.update(&day_epoch.to_le_bytes());
hasher.update(&features.canonical_bytes());
hasher.finalize()
}
Structural cross-site isolation: because site_salt is a 256-bit random secret unique to each node and never transmitted, two sites observing the same physical person produce uncorrelated hashes. There is no key the operator (or an attacker who compromises one node) can use to bridge sites. This is stronger than a policy-based "do not share" rule because the bridge cannot be computed.
Daily rotation: day_epoch flipping at UTC midnight forces the hash of the same person to change once per day. Multi-day correlation requires re-acquiring the biometric, which the rotation actively breaks.
The only way a high-class frame becomes a lower-class frame is through PrivacyGate::demote(frame, target_class). This function:
subtle::Zeroize.payload_crc32.There is no promote operation — a class-2 frame cannot be turned back into a class-1 frame, because the dropped fields were not retained anywhere reachable from the gate.
identity_embedding lifecycleThe embedding (output of the AETHER encoder, ADR-024) is held in a subtle::Zeroizing<[f32; 128]> ring buffer of 64 entries (≈30 KB). Entries are:
identity_risk_score computation (ADR-121).Serialize impl on the type.A compile-time #[forbid(serde::Serialize)] lint on IdentityEmbedding ensures a future PR cannot accidentally add a Serialize derive.
Every new field added to BfldFrame or BfldEvent must be tagged with #[must_classify] (a custom attribute macro). The macro fails compilation if the field is not listed in the per-class allow-list table. This forces future contributors to make an explicit privacy decision on every new field.
#[must_classify] prevents the common pattern of "a new field shipped, then six months later we noticed it was identity-leaky".identity_embedding cannot be serialized by accident — the type system carries the constraint.site_salt storage requires either a TPM (ADR-095/096 rvCSI platform feature gap) or a secrets file with strict mode. Loss of site_salt makes historical witness comparisons impossible — by design, but a documentation hazard.#[must_classify] is a custom proc-macro; another moving part in the build.cargo test-only. Some CI runners may need an explicit feature flag to compile class-0 paths.privacy_mode flag (status quo from ADR-115)Rejected: insufficient granularity. The frame mixes publishable sensing with non-publishable identity, so the gate must operate at field-level, not event-level.
Rejected: BLAKE3 keyed-hash mode is ~5× faster on the ESP32-S3 / Cortex-M cores and the security margin is equivalent for this use case. SHA-256 has no keyed-hash mode (HMAC-SHA256 is the alternative; works but is slower).
Rejected: hourly rotation breaks legitimate "person was here in the morning, came back in the afternoon" use-cases that operators may want. Day boundary is the compromise.
Rejected: per-event nonces would force the consumer to track which events came from the same person within a session, which leaks identity information by structure. The day epoch preserves a coarse temporal grouping without leaking finer-grained identity.
Emitter::publish with a privacy_class = 0 frame on a NetworkSink returns BfldError::PrivacyViolation.site_salt values observing the same simulated person produce rf_signature_hash values whose Hamming distance is ≥ 120 bits over 100 trials (statistical isolation test).privacy_class = 3 has both identity_risk_score and rf_signature_hash absent from the serialized payload.PrivacyGate::demote(class_1_frame, target=0) fails to compile (compile-fail test).BfldEvent without #[must_classify] fails the build.IdentityEmbedding has no Serialize impl reachable from any public function.IdentityEmbedding value zeroizes its memory (verified by a debugger-readable test under cargo test --features zeroize-validation).privacy_class byte location)subtle::Zeroize for memory hygiene