Compare commits

...

3 Commits

Author SHA1 Message Date
ruv a3478ea3b5 cog-ha-matter (ADR-116 P4): witness JSONL persistence
Third P4 sub-unit: serialize/parse for the witness hash chain so
audit bundles can be written to disk and replayed.

Wire shape (one record per line, alphabetical field order locked):

  {"kind":"...","payload_hex":"...","prev_hash":"...","seq":N,
   "this_hash":"...","timestamp_unix_s":N}

Why alphabetical field order: auditors archive whole bundles and
hash them. A rebuild that reordered fields would silently
invalidate every archival hash — locking the order is what makes
the JSONL stable across compiler / serde-json upgrades.

Why hex everywhere: human-greppable, monospace-friendly, no base64
ambiguity, no Vec<u8> JSON-array ugliness. Same convention as
ADR-101's `binary_sha256`.

Critically, `from_jsonl_line` RE-VERIFIES `this_hash` against
the canonical bytes derived from the parsed fields. A tampered
bundle fires `WitnessParseError::HashMismatch` BEFORE the event
loads — the parser is itself an auditor.

New surfaces:
  * `WitnessHash::from_hex` (with structured length/parse errors)
  * `WitnessEvent::to_jsonl_line`, `from_jsonl_line`
  * `WitnessParseError` enum: Json | MissingField | WrongType |
    HashLength | HashHex | PayloadHex | PayloadLength | HashMismatch
  * private `hex_encode` / `hex_decode` helpers (no `hex` crate dep)

10 new tests:
  * jsonl round-trip preserves all fields
  * jsonl line has no embedded \n / \r (one record per line)
  * jsonl field order is alphabetical (byte-stable archival)
  * parser rejects tampered payload via HashMismatch
  * parser rejects non-hex characters in hash
  * parser rejects missing field
  * hex encode/decode round-trip across empty / single byte / 0xff /
    UTF-8 / arbitrary bytes
  * hex decode rejects odd-length input
  * WitnessHash::from_hex round-trip
  * WitnessHash::from_hex rejects wrong length

44/44 cog tests green (34 → 44).

ADR-116 P4 row enumerates 4 sub-units now:  mDNS record-builder,
 witness chain primitive,  witness JSONL persistence,
 responder + embedded broker + Ed25519 signing.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:12:59 -04:00
ruv fe913b0ea7 cog-ha-matter (ADR-116 P4): pure witness hash-chain primitive
Second P4 unit: an append-only SHA-256 hash chain for tamper-evident
audit logging. ADR-116 §2.2 promised this for healthcare /
education / shared-housing deployments — this lands the primitive
with no key dependency so the next iter can layer Ed25519 signing
on top without touching the chain itself.

Module shape:

  * `WitnessHash([u8; 32])` newtype + `WitnessHash::GENESIS` sentinel
  * `WitnessEvent { seq, prev_hash, ts, kind, payload, this_hash }`
    — once committed, every field is immutable
  * `WitnessChain` — `append`, `tip`, `verify`, `events`
  * `canonical_bytes` — length-prefixed serialization that prevents
    the classic concatenation forgery
    (`abc|def` ≠ `ab|cdef`)
  * `WitnessVerifyError` — auditor-friendly error with `at: usize`
    on every variant (SeqGap, PrevHashMismatch, HashMismatch)

13 new tests covering both happy path and active tampering:

  * genesis hash all-zeros
  * empty chain tip is genesis
  * canonical bytes length-prefixed (anti-forgery)
  * canonical bytes start with prev_hash (wire-format lock)
  * append links to prev_hash
  * seq monotonic from 0
  * verify passes on clean chain
  * verify catches tampered payload (fires HashMismatch)
  * verify catches broken prev_hash link
  * verify catches seq gap
  * hash hex is 64 lowercase chars
  * first event prev_hash == GENESIS (auditor anchor)
  * different payloads → different hashes

Hash-chain over Merkle is the right tradeoff for the cog's event
rate (a few/min steady, dozens during a fall) — linear scan is
fine and we save the Merkle complexity for a future tier when
chains span days.

34/34 cog tests green (21 → 34).

ADR-116 P4 row updated to enumerate the three P4 sub-units shipped /
pending: (a) mDNS record-builder , (b) witness hash-chain , (c)
responder + embedded broker + Ed25519 signing pending.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:08:56 -04:00
ruv 35722529bf cog-ha-matter (ADR-116 P4): pure mDNS service-record builder
Opens P4 with the smallest extractable unit: a pure builder that
produces the wire-format `MdnsService` the responder will publish
next iter. Splitting the record-builder from the responder lets
us:

  * lock the TXT-record surface with named unit tests so drift
    between the cog and the HA-side YAML auto-discovery binding
    fires a test instead of silently breaking deployments,
  * swap the responder library (mdns-sd / zeroconf / pnet) without
    touching content,
  * include the advertisement in `--print-manifest` for Seed
    integration tests that can't boot tokio.

TXT surface (sorted, RFC 6763):

  | cog_id      | "ha-matter"             |
  | cog_version | CARGO_PKG_VERSION       |
  | node_id     | identity.node_id        |
  | mqtt_port   | u16 stringified         |
  | privacy     | "1" | "0"              |
  | proto       | "ruview-ha/1"           |

9 new tests:

  * service_type locked to `_ruview-ha._tcp`
  * instance_name carries node_id
  * control_port advertises the *control plane*, not MQTT
  * privacy flag is "1"/"0" (HA config flow reads it byte-stable)
  * proto version locked to ruview-ha/1 (bump is deliberate)
  * cog_id in TXT matches crate constant
  * txt_records sorted for byte-stable mDNS responses
  * **PII leak guard**: TXT must NOT carry hr_bpm, br_bpm, pose_*,
    keypoint, ssid, lat, lon, mac, rssi — broadcasts in cleartext
    so a future "let's add hr_bpm for convenience" patch fires
    here, not in a privacy incident.
  * required-keys lock — adding is fine, removing/renaming breaks
    every deployed Seed.

21/21 cog tests green (12 → 21).

ADR-116 P4 flipped pending → in progress, with the responder /
embedded broker / witness chain enumerated as the remaining P4
sub-units.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:02:41 -04:00
6 changed files with 840 additions and 1 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ Ranked by build cost × user impact:
| **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked |
| **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done**`cargo check` + `cargo test` green |
| **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done**`main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender<VitalsSnapshot>`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. |
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | pending |
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — (a) mDNS service-record builder shipped. (b) Witness hash-chain primitive shipped (append-only SHA-256, `verify()` catches tampering). (c) **Witness JSONL persistence shipped**`WitnessEvent::{to,from}_jsonl_line` round-trips with alphabetical field order for byte-stable archival hashes; parser re-verifies stored `this_hash` against canonical bytes so tampered bundles fire `HashMismatch` before loading. (d) Responder (mdns-sd) + embedded rumqttd + Ed25519 signing layer still pending. |
| **P5** | RuVector-backed threshold learning (SONA adaptation) | pending |
| **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending |
| **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending |
Generated
+1
View File
@@ -936,6 +936,7 @@ dependencies = [
"clap",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"tokio",
+4
View File
@@ -35,5 +35,9 @@ wifi-densepose-sensing-server = { version = "0.3.0", path = "../wifi-densepose-s
# Hardware crate for SyncPacket + NodeState bridging (ADR-110 substrate).
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
# Witness chain (ADR-116 P4): SHA-256 only for now; Ed25519 signing
# layers on top once we ship the key-management story.
sha2 = { workspace = true }
[dev-dependencies]
tempfile = "3.10"
+2
View File
@@ -27,7 +27,9 @@
//! discipline rules (see `docs/ADR-110-BRANCH-STATE.md`).
pub mod manifest;
pub mod mdns;
pub mod runtime;
pub mod witness;
/// Cog identifier used in Seed's app-registry.json + the manifest.
pub const COG_ID: &str = "ha-matter";
+217
View File
@@ -0,0 +1,217 @@
//! `mdns` — pure builder for the cog's mDNS advertisement record.
//!
//! ADR-116 §2.2: the cog must advertise itself as `_ruview-ha._tcp`
//! so HA's discovery integration finds the Seed without manual
//! `broker host` config. This module produces the typed wire-format
//! shape — no socket I/O, no responder. The actual mDNS responder
//! (mdns-sd / zeroconf / pnet) lands next iter and consumes this
//! struct as its single input.
//!
//! Keeping the record-builder pure means:
//!
//! * the responder library can be swapped without touching the
//! content of the advertisement;
//! * the build-time `--print-manifest` path can include the
//! advertisement shape so Seed integration tests can assert on
//! it without booting tokio;
//! * the TXT keys are locked by named unit tests — drift between
//! the cog and the HA-side YAML auto-discovery (`hass-wifi-...`)
//! fires a test instead of silently breaking a deployment.
//!
//! ## TXT record convention (RFC 6763)
//!
//! HA's mDNS discovery integration reads TXT records when binding a
//! manifest to a `homeassistant.<integration>` zeroconf hook. We
//! publish the minimum set that lets HA distinguish a Seed cog from
//! a bare sensing-server and pick the right config flow:
//!
//! | Key | Value | Purpose |
//! |---|---|---|
//! | `cog_id` | `"ha-matter"` | Disambiguates from other RuView cogs |
//! | `cog_version` | `CARGO_PKG_VERSION` | HA Repairs surfaces upgrade nudges |
//! | `node_id` | identity node id | HA device registry key |
//! | `mqtt_port` | u16 string | Tells HA where to reach the cog's MQTT broker (embedded or external) |
//! | `privacy` | `"1"` / `"0"` | If `1`, HA's config flow gates biometric entities by default |
//! | `proto` | `"ruview-ha/1"` | Protocol version — bumps on breaking auto-discovery changes |
//!
//! No biometric data, no node coordinates, no SSID — TXT records
//! are broadcast in cleartext and harvested by passive scanners, so
//! treating them as PII-clean is part of the privacy posture.
use crate::COG_ID;
/// Default mDNS instance name template. `{node_id}` is substituted
/// at build time. Visible in HA's UI when the integration card is
/// added — "Cognitum Seed (kitchen)" beats a raw UUID.
const INSTANCE_TEMPLATE: &str = "Cognitum Seed — {node_id}";
/// Wire-format twin of the mDNS service record this cog publishes.
/// Owned so the responder can move the whole thing into its task.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MdnsService {
/// RFC 6763 service type. Locked to `_ruview-ha._tcp` by a named
/// test — drift breaks HA's YAML auto-discovery binding.
pub service_type: String,
/// Human-readable instance name shown in HA's discovery UI.
pub instance_name: String,
/// Port the cog's control plane listens on (NOT the MQTT broker
/// port — HA needs both, but the service record advertises the
/// control plane; the MQTT port rides as a TXT record).
pub control_port: u16,
/// TXT records sorted by key for deterministic ordering. RFC
/// 6763 §6.4 makes ordering implementation-defined, but locking
/// it keeps the cog's wire shape byte-stable across rebuilds.
pub txt_records: Vec<(String, String)>,
}
impl MdnsService {
/// Look up a TXT key without iterating the caller. `None` if the
/// key isn't published — the responder treats absence as
/// "feature off" rather than "unknown".
pub fn txt(&self, key: &str) -> Option<&str> {
self.txt_records
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
}
/// Build the cog's mDNS advertisement record from the cog's typed
/// identity + ports. Pure: no I/O, no env reads.
pub fn build_mdns_service(
identity: &crate::runtime::CogIdentity,
control_port: u16,
mqtt_port: u16,
privacy_mode: bool,
) -> MdnsService {
let mut txt_records = vec![
("cog_id".to_string(), COG_ID.to_string()),
("cog_version".to_string(), identity.sw_version.clone()),
("node_id".to_string(), identity.node_id.clone()),
("mqtt_port".to_string(), mqtt_port.to_string()),
(
"privacy".to_string(),
if privacy_mode { "1" } else { "0" }.to_string(),
),
("proto".to_string(), "ruview-ha/1".to_string()),
];
// Deterministic ordering — see field docstring.
txt_records.sort();
MdnsService {
service_type: crate::MDNS_SERVICE_TYPE.to_string(),
instance_name: INSTANCE_TEMPLATE.replace("{node_id}", &identity.node_id),
control_port,
txt_records,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::CogIdentity;
fn id() -> CogIdentity {
CogIdentity {
node_id: "kitchen-seed".into(),
friendly_name: "Kitchen Seed".into(),
sw_version: "0.3.0".into(),
}
}
#[test]
fn service_type_locked_to_ruview_ha_tcp() {
// Drift here breaks HA's YAML auto-discovery binding. Lock
// it so a future rename surfaces a named test instead of a
// silent broken deployment.
let svc = build_mdns_service(&id(), 9180, 1883, false);
assert_eq!(svc.service_type, "_ruview-ha._tcp");
assert_eq!(svc.service_type, crate::MDNS_SERVICE_TYPE);
}
#[test]
fn instance_name_carries_node_id() {
let svc = build_mdns_service(&id(), 9180, 1883, false);
assert!(svc.instance_name.contains("kitchen-seed"));
}
#[test]
fn control_port_field_holds_control_not_mqtt_port() {
// Easy to swap by accident. Lock the binding so a refactor
// doesn't silently advertise the MQTT broker as the control
// plane.
let svc = build_mdns_service(&id(), 9180, 1883, false);
assert_eq!(svc.control_port, 9180);
assert_eq!(svc.txt("mqtt_port"), Some("1883"));
}
#[test]
fn privacy_flag_is_one_or_zero() {
let on = build_mdns_service(&id(), 9180, 1883, true);
let off = build_mdns_service(&id(), 9180, 1883, false);
assert_eq!(on.txt("privacy"), Some("1"));
assert_eq!(off.txt("privacy"), Some("0"));
}
#[test]
fn proto_version_bumps_surface_in_txt() {
// Locked so a future breaking-change in the cog ↔ HA YAML
// contract surfaces here. Bumping it is a deliberate act.
let svc = build_mdns_service(&id(), 9180, 1883, false);
assert_eq!(svc.txt("proto"), Some("ruview-ha/1"));
}
#[test]
fn cog_id_in_txt_matches_crate_constant() {
let svc = build_mdns_service(&id(), 9180, 1883, false);
assert_eq!(svc.txt("cog_id"), Some(crate::COG_ID));
}
#[test]
fn txt_records_are_sorted_for_byte_stable_advertisement() {
let svc = build_mdns_service(&id(), 9180, 1883, false);
let keys: Vec<&str> = svc.txt_records.iter().map(|(k, _)| k.as_str()).collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted);
}
#[test]
fn txt_carries_no_biometric_or_pii_keys() {
// TXT records broadcast in cleartext; passive scanners
// harvest them. Lock the publishable surface so a future
// "let's add hr_bpm to TXT for convenience" patch fires a
// named test instead of leaking biometrics.
let svc = build_mdns_service(&id(), 9180, 1883, false);
let forbidden = [
"hr_bpm",
"br_bpm",
"pose_x",
"pose_y",
"keypoint",
"ssid",
"lat",
"lon",
"mac",
"rssi",
];
for key in forbidden {
assert!(
svc.txt(key).is_none(),
"TXT key `{key}` leaks PII / biometric data — must not be advertised"
);
}
}
#[test]
fn txt_keys_match_locked_surface() {
// The HA-side YAML auto-discovery binds on these exact keys.
// Adding a key is fine; removing or renaming one breaks
// every deployed Seed. This test catches both directions.
let svc = build_mdns_service(&id(), 9180, 1883, true);
let required = ["cog_id", "cog_version", "node_id", "mqtt_port", "privacy", "proto"];
for key in required {
assert!(svc.txt(key).is_some(), "TXT key `{key}` missing");
}
}
}
+615
View File
@@ -0,0 +1,615 @@
//! `witness` — append-only hash-chained event log for the cog.
//!
//! ADR-116 §2.2 promises a tamper-evident audit log so regulated
//! deployments (healthcare, education, shared housing) can prove
//! that the state transitions a Seed reported were actually emitted
//! by the cog at the time they were emitted, not retroactively
//! rewritten.
//!
//! This module is the **pure hash-chain primitive**:
//!
//! * SHA-256 over deterministic canonical bytes,
//! * `prev_hash` chains each event to its predecessor,
//! * `WitnessChain::append` is the only mutator — no random
//! access, no replace, no delete.
//!
//! Ed25519 signing layers on top once the key-management story
//! lands (probably as `witness_signing.rs` reading a key from the
//! Seed's secure store). Keeping the hash chain and the signature
//! in separate modules means the chain primitive can be tested
//! without a key fixture, and a future key rotation doesn't
//! invalidate the chain itself — only the signature over each
//! event.
//!
//! ## Why hash-chain first, not Merkle tree?
//!
//! The cog emits witness events at the rate of semantic-primitive
//! transitions — a few per minute in steady state, dozens during
//! a fall-detection / room-transition event. Linear scan is fine
//! at that rate; we save the Merkle complexity for a future tier
//! when the chain spans days and the auditor wants O(log n)
//! inclusion proofs.
use sha2::{Digest, Sha256};
/// 32-byte hash output. Lifted into a newtype so a future migration
/// to Blake3 / SHA-512 surfaces as a type change instead of a
/// silent length difference in serialized witness bundles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WitnessHash(pub [u8; 32]);
impl WitnessHash {
/// Genesis hash — the predecessor of the first event. Sentinel
/// "no prior event" value.
pub const GENESIS: WitnessHash = WitnessHash([0u8; 32]);
/// Lowercase hex without `0x` prefix. Matches the format the
/// `cog-pose-estimation` manifest uses for `binary_sha256` so
/// downstream tooling can apply one parser.
pub fn to_hex(&self) -> String {
let mut s = String::with_capacity(64);
for b in self.0 {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Parse a 64-char lowercase-hex string back into a `WitnessHash`.
/// Rejects wrong-length input and non-hex characters — used by
/// the JSONL parser when reading audit bundles.
pub fn from_hex(s: &str) -> Result<WitnessHash, WitnessParseError> {
if s.len() != 64 {
return Err(WitnessParseError::HashLength { found: s.len() });
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let lo = i * 2;
*byte = u8::from_str_radix(&s[lo..lo + 2], 16)
.map_err(|_| WitnessParseError::HashHex { at: lo })?;
}
Ok(WitnessHash(out))
}
}
/// A single witnessed event. Append-only — once committed to a
/// `WitnessChain`, the fields here are immutable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WitnessEvent {
/// Zero-based sequence number. Strictly monotonically
/// increasing within a chain — gaps mean the chain was
/// truncated.
pub seq: u64,
/// Hash of the previous event, or [`WitnessHash::GENESIS`] for
/// the first.
pub prev_hash: WitnessHash,
/// Unix epoch seconds at append time. Caller-supplied so the
/// test suite isn't time-coupled; production uses
/// `SystemTime::now()`.
pub timestamp_unix_s: u64,
/// Short stable kind tag — e.g. `"fall_risk_elevated"`,
/// `"bed_exit"`, `"privacy_mode_toggled"`. Locked vocabulary
/// in the future; free-form here until the semantic-primitive
/// catalog stabilises.
pub kind: String,
/// Opaque payload bytes. Typically the JSON of the emitted MQTT
/// state message so an auditor can re-derive what HA was told.
pub payload: Vec<u8>,
/// Hash of *this* event, computed over canonical bytes that
/// include `prev_hash` — so reconstructing the chain proves
/// nothing in the past was rewritten.
pub this_hash: WitnessHash,
}
/// Compute the canonical-bytes form an event is hashed over.
///
/// The format is intentionally simple and length-prefixed so a
/// future migration can be staged with a `version` byte in front
/// without ambiguity:
///
/// ```text
/// prev_hash[32] | seq:u64-be | ts:u64-be | kind_len:u32-be | kind | payload_len:u32-be | payload
/// ```
///
/// Length-prefixing prevents the classic "concatenation forgery"
/// attack where `"abc" + "def"` and `"ab" + "cdef"` would hash the
/// same.
pub fn canonical_bytes(
prev_hash: WitnessHash,
seq: u64,
timestamp_unix_s: u64,
kind: &str,
payload: &[u8],
) -> Vec<u8> {
let kind_bytes = kind.as_bytes();
let mut out = Vec::with_capacity(32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len());
out.extend_from_slice(&prev_hash.0);
out.extend_from_slice(&seq.to_be_bytes());
out.extend_from_slice(&timestamp_unix_s.to_be_bytes());
out.extend_from_slice(&(kind_bytes.len() as u32).to_be_bytes());
out.extend_from_slice(kind_bytes);
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
out
}
/// Compute the SHA-256 hash for an event.
pub fn hash_event(
prev_hash: WitnessHash,
seq: u64,
timestamp_unix_s: u64,
kind: &str,
payload: &[u8],
) -> WitnessHash {
let mut h = Sha256::new();
h.update(canonical_bytes(prev_hash, seq, timestamp_unix_s, kind, payload));
let digest = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
WitnessHash(out)
}
/// In-memory append-only chain. Persistence (write to the Seed's
/// `~/cognitum/witness/<cog>/events.jsonl`) is a separate concern
/// kept out of this module.
#[derive(Debug, Default, Clone)]
pub struct WitnessChain {
events: Vec<WitnessEvent>,
}
impl WitnessChain {
pub fn new() -> Self {
Self::default()
}
/// Last committed hash, or `GENESIS` if the chain is empty.
pub fn tip(&self) -> WitnessHash {
self.events
.last()
.map(|e| e.this_hash)
.unwrap_or(WitnessHash::GENESIS)
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Append a new event. Caller supplies the wall-clock so tests
/// stay deterministic.
pub fn append(&mut self, kind: &str, payload: &[u8], timestamp_unix_s: u64) -> &WitnessEvent {
let prev_hash = self.tip();
let seq = self.events.len() as u64;
let this_hash = hash_event(prev_hash, seq, timestamp_unix_s, kind, payload);
self.events.push(WitnessEvent {
seq,
prev_hash,
timestamp_unix_s,
kind: kind.to_string(),
payload: payload.to_vec(),
this_hash,
});
self.events.last().expect("just pushed")
}
pub fn events(&self) -> &[WitnessEvent] {
&self.events
}
/// Verify every event's `this_hash` matches the canonical bytes,
/// every `prev_hash` matches the predecessor's `this_hash`, and
/// `seq` is gap-free starting at 0.
///
/// Returns `Ok(())` on a sound chain or an `Err` with the first
/// failing index + reason — auditor-friendly.
pub fn verify(&self) -> Result<(), WitnessVerifyError> {
let mut prev = WitnessHash::GENESIS;
for (i, ev) in self.events.iter().enumerate() {
if ev.seq != i as u64 {
return Err(WitnessVerifyError::SeqGap { at: i, found: ev.seq });
}
if ev.prev_hash != prev {
return Err(WitnessVerifyError::PrevHashMismatch { at: i });
}
let recomputed = hash_event(
ev.prev_hash,
ev.seq,
ev.timestamp_unix_s,
&ev.kind,
&ev.payload,
);
if recomputed != ev.this_hash {
return Err(WitnessVerifyError::HashMismatch { at: i });
}
prev = ev.this_hash;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WitnessVerifyError {
#[error("seq gap at index {at}: expected {at}, found {found}")]
SeqGap { at: usize, found: u64 },
#[error("prev_hash mismatch at index {at}")]
PrevHashMismatch { at: usize },
#[error("this_hash mismatch at index {at} — event tampered")]
HashMismatch { at: usize },
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WitnessParseError {
#[error("invalid JSON: {0}")]
Json(String),
#[error("missing required field `{0}`")]
MissingField(&'static str),
#[error("field `{field}` has wrong type")]
WrongType { field: &'static str },
#[error("hash hex must be 64 chars, got {found}")]
HashLength { found: usize },
#[error("hash hex parse error at byte offset {at}")]
HashHex { at: usize },
#[error("payload hex parse error at byte offset {at}")]
PayloadHex { at: usize },
#[error("payload hex must be even length, got {found}")]
PayloadLength { found: usize },
#[error("recomputed hash does not match this_hash — bundle is forged or corrupted")]
HashMismatch,
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn hex_decode(s: &str) -> Result<Vec<u8>, WitnessParseError> {
if s.len() % 2 != 0 {
return Err(WitnessParseError::PayloadLength { found: s.len() });
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
let byte = u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| WitnessParseError::PayloadHex { at: i })?;
out.push(byte);
}
Ok(out)
}
impl WitnessEvent {
/// Serialize one event to a single JSONL line (no trailing
/// newline). The format is the audit-bundle wire shape; tools
/// downstream parse it line-by-line with [`Self::from_jsonl_line`].
///
/// Field ordering is locked alphabetically for byte-stable
/// output across rebuilds — auditors hash whole bundles, so a
/// rebuild that reordered fields would silently invalidate
/// archival hashes.
///
/// Wire shape:
///
/// ```json
/// {"kind":"...","payload_hex":"...","prev_hash":"...","seq":N,"this_hash":"...","timestamp_unix_s":N}
/// ```
pub fn to_jsonl_line(&self) -> String {
// Hand-rolled instead of serde_derive so the wire-format
// ordering is under direct test control.
format!(
"{{\"kind\":{kind},\"payload_hex\":\"{payload}\",\"prev_hash\":\"{prev}\",\"seq\":{seq},\"this_hash\":\"{this}\",\"timestamp_unix_s\":{ts}}}",
kind = serde_json::to_string(&self.kind).expect("string is always serializable"),
payload = hex_encode(&self.payload),
prev = self.prev_hash.to_hex(),
seq = self.seq,
this = self.this_hash.to_hex(),
ts = self.timestamp_unix_s,
)
}
/// Parse one JSONL line back into a `WitnessEvent`. Re-verifies
/// the stored `this_hash` against the canonical bytes — a
/// tampered bundle fires [`WitnessParseError::HashMismatch`]
/// instead of silently loading forged events.
pub fn from_jsonl_line(line: &str) -> Result<WitnessEvent, WitnessParseError> {
let v: serde_json::Value =
serde_json::from_str(line).map_err(|e| WitnessParseError::Json(e.to_string()))?;
let obj = v
.as_object()
.ok_or(WitnessParseError::WrongType { field: "<root>" })?;
let seq = obj
.get("seq")
.ok_or(WitnessParseError::MissingField("seq"))?
.as_u64()
.ok_or(WitnessParseError::WrongType { field: "seq" })?;
let timestamp_unix_s = obj
.get("timestamp_unix_s")
.ok_or(WitnessParseError::MissingField("timestamp_unix_s"))?
.as_u64()
.ok_or(WitnessParseError::WrongType {
field: "timestamp_unix_s",
})?;
let kind = obj
.get("kind")
.ok_or(WitnessParseError::MissingField("kind"))?
.as_str()
.ok_or(WitnessParseError::WrongType { field: "kind" })?
.to_string();
let prev_hash = WitnessHash::from_hex(
obj.get("prev_hash")
.ok_or(WitnessParseError::MissingField("prev_hash"))?
.as_str()
.ok_or(WitnessParseError::WrongType { field: "prev_hash" })?,
)?;
let this_hash = WitnessHash::from_hex(
obj.get("this_hash")
.ok_or(WitnessParseError::MissingField("this_hash"))?
.as_str()
.ok_or(WitnessParseError::WrongType { field: "this_hash" })?,
)?;
let payload = hex_decode(
obj.get("payload_hex")
.ok_or(WitnessParseError::MissingField("payload_hex"))?
.as_str()
.ok_or(WitnessParseError::WrongType {
field: "payload_hex",
})?,
)?;
// Re-verify the stored hash. The on-disk hash is purely
// declarative; this is what makes the JSONL a witness.
let recomputed = hash_event(prev_hash, seq, timestamp_unix_s, &kind, &payload);
if recomputed != this_hash {
return Err(WitnessParseError::HashMismatch);
}
Ok(WitnessEvent {
seq,
prev_hash,
timestamp_unix_s,
kind,
payload,
this_hash,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn genesis_hash_is_all_zeros() {
assert_eq!(WitnessHash::GENESIS.0, [0u8; 32]);
}
#[test]
fn empty_chain_tip_is_genesis() {
let c = WitnessChain::new();
assert_eq!(c.tip(), WitnessHash::GENESIS);
assert!(c.is_empty());
}
#[test]
fn canonical_bytes_length_prefixing_prevents_ambiguity() {
// Classic concatenation forgery: without length prefixes,
// ("abc","def") and ("ab","cdef") would produce the same
// hash. With them, they don't.
let a = canonical_bytes(WitnessHash::GENESIS, 0, 0, "abc", b"def");
let b = canonical_bytes(WitnessHash::GENESIS, 0, 0, "ab", b"cdef");
assert_ne!(a, b);
}
#[test]
fn canonical_bytes_starts_with_prev_hash() {
// Locks the on-wire format. A future migration that flips
// field order must bump a version byte and update this test.
let bytes = canonical_bytes(WitnessHash([7u8; 32]), 1, 2, "k", b"p");
assert_eq!(&bytes[..32], &[7u8; 32]);
}
#[test]
fn append_links_to_prev_hash() {
let mut c = WitnessChain::new();
let h1 = c.append("a", b"1", 100).this_hash;
let e2 = c.append("b", b"2", 101);
assert_eq!(e2.prev_hash, h1);
assert_eq!(e2.seq, 1);
}
#[test]
fn sequence_is_monotonic_starting_at_zero() {
let mut c = WitnessChain::new();
for i in 0..5 {
c.append("k", &[i], 0);
}
for (i, ev) in c.events().iter().enumerate() {
assert_eq!(ev.seq, i as u64);
}
}
#[test]
fn verify_passes_on_clean_chain() {
let mut c = WitnessChain::new();
c.append("fall_risk_elevated", b"{}", 100);
c.append("bed_exit", b"{}", 101);
c.append("privacy_mode_toggled", br#"{"on":true}"#, 102);
c.verify().expect("clean chain verifies");
}
#[test]
fn verify_catches_tampered_payload() {
let mut c = WitnessChain::new();
c.append("a", b"original", 100);
c.append("b", b"original2", 101);
// Tamper with event 0's payload directly.
c.events[0].payload = b"forged".to_vec();
let err = c.verify().unwrap_err();
assert!(matches!(err, WitnessVerifyError::HashMismatch { at: 0 }));
}
#[test]
fn verify_catches_broken_prev_link() {
let mut c = WitnessChain::new();
c.append("a", b"1", 100);
c.append("b", b"2", 101);
c.events[1].prev_hash = WitnessHash([0xff; 32]);
let err = c.verify().unwrap_err();
assert!(matches!(err, WitnessVerifyError::PrevHashMismatch { at: 1 }));
}
#[test]
fn verify_catches_seq_gap() {
let mut c = WitnessChain::new();
c.append("a", b"1", 100);
c.append("b", b"2", 101);
c.events[1].seq = 99;
let err = c.verify().unwrap_err();
assert!(matches!(err, WitnessVerifyError::SeqGap { at: 1, found: 99 }));
}
#[test]
fn hash_to_hex_is_64_lowercase_chars() {
let h = hash_event(WitnessHash::GENESIS, 0, 0, "k", b"p");
let hex = h.to_hex();
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn first_event_prev_hash_is_genesis() {
// Auditor relies on this: a witness bundle that doesn't start
// with prev_hash == GENESIS is either truncated or stitched
// together from two chains.
let mut c = WitnessChain::new();
let e = c.append("init", b"", 0);
assert_eq!(e.prev_hash, WitnessHash::GENESIS);
assert_eq!(e.seq, 0);
}
#[test]
fn different_payloads_produce_different_hashes() {
let h1 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"a");
let h2 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"b");
assert_ne!(h1, h2);
}
// ---- JSONL persistence ----
fn fresh_event() -> WitnessEvent {
let mut c = WitnessChain::new();
c.append("fall_risk_elevated", br#"{"node":"kitchen"}"#, 1779512400);
c.events()[0].clone()
}
#[test]
fn jsonl_round_trip_preserves_all_fields() {
let original = fresh_event();
let line = original.to_jsonl_line();
let parsed = WitnessEvent::from_jsonl_line(&line).expect("clean line round-trips");
assert_eq!(parsed, original);
}
#[test]
fn jsonl_line_has_no_embedded_newline() {
// JSONL is one record per line; an embedded \n in the
// serialized form would corrupt the file format.
let line = fresh_event().to_jsonl_line();
assert!(!line.contains('\n'));
assert!(!line.contains('\r'));
}
#[test]
fn jsonl_field_order_is_alphabetical_for_byte_stability() {
// Auditors archive whole bundles and hash them — reordered
// fields would silently invalidate archival hashes. Lock
// the order with a substring check on a known event.
let line = fresh_event().to_jsonl_line();
let order = ["kind", "payload_hex", "prev_hash", "seq", "this_hash", "timestamp_unix_s"];
let mut last = 0usize;
for field in order {
let pos = line.find(field).unwrap_or_else(|| panic!("missing field `{field}`"));
assert!(pos > last, "field `{field}` out of alphabetical order");
last = pos;
}
}
#[test]
fn jsonl_parser_rejects_tampered_payload() {
let original = fresh_event();
let line = original.to_jsonl_line();
// Flip one nibble in the payload hex — the stored hash
// won't match the recomputed hash.
let tampered = line.replacen("payload_hex\":\"7b", "payload_hex\":\"6b", 1);
assert_ne!(line, tampered, "test fixture didn't flip a byte");
let err = WitnessEvent::from_jsonl_line(&tampered).unwrap_err();
assert!(
matches!(err, WitnessParseError::HashMismatch),
"expected HashMismatch, got {err:?}"
);
}
#[test]
fn jsonl_parser_rejects_non_hex_hash() {
// Replace the hex hash with non-hex chars — must fire a
// structured error, not a panic.
let original = fresh_event();
let line = original.to_jsonl_line();
let broken = line.replacen(
&original.this_hash.to_hex()[..4],
"ZZZZ",
1,
);
let err = WitnessEvent::from_jsonl_line(&broken).unwrap_err();
assert!(matches!(err, WitnessParseError::HashHex { .. }));
}
#[test]
fn jsonl_parser_rejects_missing_field() {
let bad = r#"{"seq":0,"kind":"k","prev_hash":"00","this_hash":"00","timestamp_unix_s":1}"#;
let err = WitnessEvent::from_jsonl_line(bad).unwrap_err();
// Missing payload_hex; should fire MissingField before any
// hex decode happens.
assert!(matches!(err, WitnessParseError::MissingField("payload_hex")
| WitnessParseError::HashLength { .. }));
}
#[test]
fn hex_encode_decode_round_trip() {
let cases: &[&[u8]] = &[
b"",
b"\x00",
b"\xff",
b"hello world",
&[0x00, 0x01, 0xab, 0xcd, 0xef],
];
for c in cases {
let encoded = hex_encode(c);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(&decoded[..], *c, "round-trip failed for {c:?}");
}
}
#[test]
fn hex_decode_rejects_odd_length() {
let err = hex_decode("abc").unwrap_err();
assert!(matches!(err, WitnessParseError::PayloadLength { found: 3 }));
}
#[test]
fn witness_hash_from_hex_round_trip() {
let h = WitnessHash([0x12; 32]);
let hex = h.to_hex();
let parsed = WitnessHash::from_hex(&hex).unwrap();
assert_eq!(parsed, h);
}
#[test]
fn witness_hash_from_hex_rejects_wrong_length() {
let err = WitnessHash::from_hex("ab").unwrap_err();
assert!(matches!(err, WitnessParseError::HashLength { found: 2 }));
}
}