Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/mqtt_topics.rs
T
rUv a369fbe66e fix(bfld security): close HIGH privacy-bypass in process_to_frame (identity surface leaked despite restrictive class) + JSON-injection (#1075)
* fix(bfld): route process_to_frame payload through PrivacyGate (ADR-141 privacy bypass)

BfldPipeline::process_to_frame stamped the frame header with the active
privacy class but serialized the caller-supplied BfldPayload UNCHANGED via
BfldFrame::from_payload. This let a frame labeled Anonymous(2) or
Restricted(3) carry the full identity-leaky compressed_angle_matrix
(+ amplitude/phase proxies, csi_delta) that PrivacyGate::demote is documented
and tested (privacy_gate_demote.rs) to strip at exactly those classes.

A NetworkSink accepts class >= Derived(1), so such a frame would publish the
beamforming angle matrix — the identity surface — across the node boundary
despite its restrictive class byte. The class byte lied about payload content.

Fix: after building the frame at the active class, apply PrivacyGate::demote to
the same class. demote() strips sections by target-class threshold (independent
of any class transition), so a same-class demote performs no class change but
brings the payload into policy compliance. Research classes (Raw/Derived) keep
the full payload — demote is a no-op there.

Pinned by three fails-on-old tests in pipeline_to_frame.rs:
- process_to_frame_at_anonymous_strips_identity_leaky_sections (FAILED pre-fix)
- process_to_frame_in_privacy_mode_strips_amplitude_and_phase (FAILED pre-fix)
- process_to_frame_at_derived_preserves_full_payload (guards against over-strip)
The pre-existing round-trip test is updated to assert the gated payload.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(bfld): JSON-escape zone_id in MQTT state-topic payload

render_events emitted the zone_activity payload as format!("\"{zone}\"") with no
escaping, while ha_discovery.rs already escapes operator-controlled strings via
push_str_field. A zone name containing a double-quote or backslash therefore
produced malformed / injectable JSON on the state topic that Home Assistant
parses (e.g. zone `a"b` -> payload `"a"b"`).

Fix: add json_string_literal() mirroring ha_discovery's escaping (", \, \n, \r,
\t, control chars) and use it for the zone payload. Value-identical for normal
zone names (living_room etc.).

Pinned by zone_payload_escapes_json_metacharacters (FAILED pre-fix); the
existing zone_payload_is_json_string_with_quotes still passes unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-141): record bfld privacy+security review findings + CHANGELOG

Document the two fixed bugs (process_to_frame privacy-bypass; zone_id JSON
injection) and the dimensions confirmed clean (event-field gating, witness/hash
framing, fail-closed) in ADR-141, plus CHANGELOG [Unreleased] Security/Fixed
entries.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 16:15:42 -04:00

184 lines
6.8 KiB
Rust

//! MQTT topic router. ADR-122 §2.2.
//!
//! Pure-function module that maps a [`BfldEvent`] into a list of per-entity
//! MQTT topic + payload pairs. No broker dependency lives here — the actual
//! `publish` call is a thin wrapper around `Client::publish(topic, payload)`
//! once a broker integration lands (deferred to a follow-up iter).
//!
//! Topic shape (ADR-122 §2.2):
//!
//! ```text
//! ruview/<node_id>/bfld/presence/state # class >= 2
//! ruview/<node_id>/bfld/motion/state # class >= 2
//! ruview/<node_id>/bfld/person_count/state # class >= 2
//! ruview/<node_id>/bfld/zone_activity/state # class >= 2 (when zone_id set)
//! ruview/<node_id>/bfld/confidence/state # class >= 2
//! ruview/<node_id>/bfld/identity_risk/state # class == 2 only
//! ```
//!
//! `raw` (class-1) and `availability` topics are intentionally not yet emitted
//! by this router; they belong to the broker-connection lifecycle, not to the
//! per-event publish loop.
#![cfg(feature = "std")]
use crate::{BfldEvent, PrivacyClass};
/// Per-topic MQTT message ready to feed into `Client::publish(topic, payload)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicMessage {
/// Full MQTT topic, e.g. `ruview/seed-01/bfld/presence/state`.
pub topic: String,
/// UTF-8 payload bytes — single JSON scalar (`true`, `0.72`, `"living_room"`)
/// or a compact JSON object for diagnostics.
pub payload: String,
}
impl TopicMessage {
/// Build a topic of the form `ruview/<node_id>/bfld/<suffix>/state`.
#[must_use]
pub fn ruview_topic(node_id: &str, entity: &str) -> String {
let mut s = String::with_capacity(7 + node_id.len() + 6 + entity.len() + 6);
s.push_str("ruview/");
s.push_str(node_id);
s.push_str("/bfld/");
s.push_str(entity);
s.push_str("/state");
s
}
}
/// Abstract MQTT publisher boundary. The crate ships only the trait + a
/// capture-impl for tests; the production rumqttc-backed impl lands in a
/// follow-up iter behind a `mqtt` feature gate.
///
/// `publish` is synchronous so callers can hold a `&mut self` without an
/// async runtime; the rumqttc wrapper drives a tokio task internally.
pub trait Publish {
/// Error type — typically the broker's transport error.
type Error;
/// Publish a single rendered message. Implementations may buffer.
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error>;
}
/// Capture-impl for unit tests. Stores every published message in order.
#[derive(Debug, Default)]
pub struct CapturePublisher {
/// Every `publish()` call appends to this vec.
pub published: Vec<TopicMessage>,
}
impl Publish for CapturePublisher {
type Error = core::convert::Infallible;
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
self.published.push(msg.clone());
Ok(())
}
}
/// Forward `Publish` through a shared `Arc<Mutex<P>>` so a publisher owned by
/// a worker thread can still be inspected by the test or operator after the
/// fact. Lock-poisoning is treated as a panic — there is no recovery story.
impl<P: Publish> Publish for std::sync::Arc<std::sync::Mutex<P>> {
type Error = P::Error;
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
self.lock()
.expect("BFLD publish: inner publisher Mutex poisoned")
.publish(msg)
}
}
/// Publish every topic message rendered from `event`. Returns the number of
/// messages actually published (zero for Raw / Derived class events). Errors
/// short-circuit — the publisher state at error time may have partial output.
pub fn publish_event<P: Publish>(
publisher: &mut P,
event: &BfldEvent,
) -> Result<usize, P::Error> {
let mut count = 0;
for msg in render_events(event) {
publisher.publish(&msg)?;
count += 1;
}
Ok(count)
}
/// Render an event into the per-entity MQTT messages it should publish. Returns
/// an empty vec for events that fail the class gate (e.g., raw class 0).
#[must_use]
pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
let class_byte = event.privacy_class.as_u8();
if class_byte < PrivacyClass::Anonymous.as_u8() {
// Raw + Derived stay local — never published on the public topic tree.
return Vec::new();
}
let mut out = Vec::with_capacity(6);
let node = &event.node_id;
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "presence"),
payload: if event.presence { "true".into() } else { "false".into() },
});
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "motion"),
payload: format!("{:.6}", event.motion),
});
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "person_count"),
payload: format!("{}", event.person_count),
});
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "confidence"),
payload: format!("{:.6}", event.confidence),
});
if let Some(zone) = &event.zone_id {
// Emit a JSON string so consumers can distinguish "no zone" (omitted)
// from "single-zone deployment" (always the same zone string). The zone
// name is operator-controlled; escape JSON metacharacters so a name
// containing a quote or backslash cannot produce malformed/injected
// JSON. Mirrors ha_discovery.rs::push_str_field's escaping.
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "zone_activity"),
payload: json_string_literal(zone),
});
}
// Identity risk is only published at exactly class 2 (Anonymous). Class 3
// (Restricted) computes the score internally but never emits it.
if class_byte == PrivacyClass::Anonymous.as_u8() {
if let Some(score) = event.identity_risk_score {
out.push(TopicMessage {
topic: TopicMessage::ruview_topic(node, "identity_risk"),
payload: format!("{score:.6}"),
});
}
}
out
}
/// Wrap `value` in JSON double-quote delimiters, escaping the metacharacters
/// that would otherwise break out of the string literal (`"`, `\`, control
/// chars, and the bare `\n`/`\r`/`\t` whitespace). Kept in lockstep with
/// `ha_discovery::push_str_field` so state-topic and discovery payloads escape
/// identically.
fn json_string_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}