diff --git a/docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md b/docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md index 985cf7ec..5afdbb67 100644 --- a/docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md +++ b/docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md @@ -57,7 +57,7 @@ pub struct BfldFrameHeader { } ``` -Total header size: 40 bytes (validated by `static_assertions::const_assert_eq!`). +Total header size: **86 bytes packed** (validated by `static_assertions::const_assert_eq!` in `wifi-densepose-bfld/src/frame.rs`). Earlier drafts stated 40 bytes — that was a counting error caught during P1 scaffold; see AC1 below. ### 2.2 Payload structure @@ -144,7 +144,7 @@ Rejected: CRC must be computed after the payload, so its value would otherwise f ## 5. Acceptance Criteria -- [ ] **AC1**: `BfldFrameHeader` size is exactly 40 bytes on x86_64, aarch64, and xtensa-esp32s3. +- [ ] **AC1**: `BfldFrameHeader` size is exactly **86 bytes** (packed) on x86_64, aarch64, and xtensa-esp32s3. The size was initially documented as 40 bytes during ADR drafting — that was a counting error; the implementation in `wifi-densepose-bfld/src/frame.rs` enforces the correct value via `const_assert_eq!`. - [ ] **AC2**: 1,000 serializations of a fixed `BfiCapture` fixture produce a bit-identical BLAKE3 hash. - [ ] **AC3**: `privacy_class = 0` frame returned through `NetworkSink::publish()` returns `Err(BfldError::PrivacyViolation)`. - [ ] **AC4**: Payload CRC32 mismatch causes `BfldFrame::parse()` to return `Err(BfldError::Crc)` without exposing partial payload state. diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 8ae5dcca..8f23b6b6 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7255,6 +7255,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -9133,6 +9139,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "wifi-densepose-bfld" +version = "0.3.0" +dependencies = [ + "proptest", + "static_assertions", + "thiserror 2.0.18", +] + [[package]] name = "wifi-densepose-cli" version = "0.3.0" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 36c76c31..c285f9f6 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -42,6 +42,11 @@ members = [ # ADR-115 MQTT publisher as a Seed-installable artifact with # mDNS, embedded broker, RuVector thresholds, Ed25519 witness. "crates/cog-ha-matter", + # ADR-118: BFLD — Beamforming Feedback Layer for Detection. The + # privacy/safety layer that measures and gates identity leakage from + # WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class), + # 121 (identity risk), 122 (HA/Matter), 123 (capture path). + "crates/wifi-densepose-bfld", # rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout): # lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as # `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the diff --git a/v2/crates/wifi-densepose-bfld/Cargo.toml b/v2/crates/wifi-densepose-bfld/Cargo.toml new file mode 100644 index 00000000..7207a1d0 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "wifi-densepose-bfld" +description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118." +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +documentation.workspace = true +keywords.workspace = true +categories.workspace = true + +[features] +default = ["std"] +std = [] +# Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) — +# enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate +# exemption. Disabled by default per the structural class-2 default. +soul-signature = [] + +[dependencies] +thiserror.workspace = true +static_assertions = "1.1" + +[dev-dependencies] +proptest.workspace = true + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" + +[lints.clippy] +all = "warn" +pedantic = "warn" +nursery = "warn" +module_name_repetitions = "allow" +missing_const_for_fn = "allow" +missing_panics_doc = "allow" diff --git a/v2/crates/wifi-densepose-bfld/src/frame.rs b/v2/crates/wifi-densepose-bfld/src/frame.rs new file mode 100644 index 00000000..7c36facb --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/frame.rs @@ -0,0 +1,63 @@ +//! `BfldFrame` wire-format primitives. See ADR-119. +//! +//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across +//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern +//! (ADR-028) extends cleanly to BFLD frames. + +use static_assertions::const_assert_eq; + +/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools. +pub const BFLD_MAGIC: u32 = 0xBF1D_0001; + +/// Current `BfldFrame` major version. Bumps on any incompatible layout change. +pub const BFLD_VERSION: u16 = 1; + +/// Size of the packed header in bytes. Asserted at compile time below. +/// +/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error. +/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation. +pub const BFLD_HEADER_SIZE: usize = 86; + +/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1. +pub mod flags { + /// Payload contains an optional CSI delta section. + pub const HAS_CSI_DELTA: u16 = 1 << 0; + /// `privacy_mode` is engaged: identity-derived fields suppressed. + pub const PRIVACY_MODE: u16 = 1 << 1; + /// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`. + pub const SELF_ONLY: u16 = 1 << 3; +} + +/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed. +/// +/// All multi-byte integer fields are little-endian when serialized. The packed +/// layout guarantees zero internal padding; readers must use `read_unaligned` +/// (or the accessor helpers added in a later commit). +#[repr(C, packed)] +#[derive(Debug, Clone, Copy)] +pub struct BfldFrameHeader { + pub magic: u32, + pub version: u16, + pub flags: u16, + pub timestamp_ns: u64, + + pub ap_hash: [u8; 16], + pub sta_hash: [u8; 16], + pub session_id: [u8; 16], + + pub channel: u16, + pub bandwidth_mhz: u16, + pub rssi_dbm: i16, + pub noise_floor_dbm: i16, + + pub n_subcarriers: u16, + pub n_tx: u8, + pub n_rx: u8, + pub quantization: u8, + pub privacy_class: u8, + + pub payload_len: u32, + pub payload_crc32: u32, +} + +const_assert_eq!(core::mem::size_of::(), BFLD_HEADER_SIZE); diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs new file mode 100644 index 00000000..79afd441 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -0,0 +1,71 @@ +//! # BFLD — Beamforming Feedback Layer for Detection +//! +//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming +//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md). +//! +//! ## Three structural invariants +//! +//! - **I1**: Raw BFI never exits the node. +//! - **I2**: Identity embedding is in-RAM-only. +//! - **I3**: Cross-site identity correlation is cryptographically impossible. +//! +//! Status: P1 scaffold — frame format only. P2–P6 land in subsequent commits. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub mod frame; + +pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE}; + +/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PrivacyClass { + /// Local-only research data including raw BFI matrix. Never networked. + Raw = 0, + /// Operator-acknowledged research mode over LAN. Downsampled angles + + /// identity_embedding + identity_risk_score available. Required for + /// Soul Signature deployments (ADR-120 §2.7). + Derived = 1, + /// Production default: aggregate sensing only, no identity-derived fields. + Anonymous = 2, + /// Care-home / regulated deployments: class 2 minus risk score and hash. + Restricted = 3, +} + +impl PrivacyClass { + /// Returns `true` if frames of this class may cross a `NetworkSink`. + /// Class 0 (`Raw`) is local-only by structural invariant I1. + #[must_use] + pub const fn allows_network(self) -> bool { + !matches!(self, Self::Raw) + } + + /// Returns `true` if frames of this class may cross the Matter boundary. + /// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4. + #[must_use] + pub const fn allows_matter(self) -> bool { + matches!(self, Self::Anonymous | Self::Restricted) + } +} + +/// Errors produced by BFLD operations. +#[derive(Debug, thiserror::Error)] +pub enum BfldError { + /// Header magic did not match `BFLD_MAGIC`. + #[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")] + InvalidMagic(u32), + + /// Header version unsupported. + #[error("unsupported BFLD version: {0}")] + UnsupportedVersion(u16), + + /// Payload CRC32 mismatch — frame corrupted or tampered. + #[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")] + Crc { expected: u32, actual: u32 }, + + /// Attempted to publish a class-0 (`Raw`) frame through a network sink. + /// Enforces structural invariant I1. + #[error("privacy violation: {reason}")] + PrivacyViolation { reason: &'static str }, +} diff --git a/v2/crates/wifi-densepose-bfld/tests/frame_header_size.rs b/v2/crates/wifi-densepose-bfld/tests/frame_header_size.rs new file mode 100644 index 00000000..47b884a5 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/frame_header_size.rs @@ -0,0 +1,28 @@ +//! Acceptance test ADR-119 AC1: `BfldFrameHeader` size is platform-stable. +//! +//! The static assertion in `frame.rs` already enforces this at compile time on +//! the local target. This runtime test exists so CI surfaces the failure with +//! a useful message rather than a `const_assert_eq!` link error. + +use wifi_densepose_bfld::{BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC, BFLD_VERSION}; + +#[test] +fn header_size_is_86_bytes() { + assert_eq!( + core::mem::size_of::(), + BFLD_HEADER_SIZE, + "BfldFrameHeader must be exactly {BFLD_HEADER_SIZE} bytes (packed)", + ); +} + +#[test] +fn magic_reads_as_bfld_in_hex() { + // 0xBF1D_0001 — "BF1D" looks like "BFLD" in xxd output; final 0001 is the + // major version that lives in the dedicated `version` field as well. + assert_eq!(BFLD_MAGIC, 0xBF1D_0001); +} + +#[test] +fn version_is_one() { + assert_eq!(BFLD_VERSION, 1); +}