mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34eced880f | |||
| bb154d4e78 | |||
| 1f5b7b48c9 | |||
| a3478ea3b5 | |||
| fe913b0ea7 | |||
| 35722529bf |
@@ -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 record-builder ✅. (b) Witness hash-chain ✅. (c) JSONL line serializer ✅. (d) File persistence + chain-level verify ✅. (e) Ed25519 signing layer ✅. **(f) mDNS ServiceInfo conversion ✅** — `MdnsService::to_service_info(hostname, ipv4)` produces the `mdns_sd::ServiceInfo` the responder daemon consumes; 3 tests verify service-type, port, TXT propagation. `mdns-sd = 0.11` aligned with the workspace's existing pin from `wifi-densepose-desktop`. (g) `ServiceDaemon::register` spawn + embedded rumqttd still pending — the remaining live-I/O pieces before P4 flips ✅. |
|
||||
| **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
+96
@@ -934,8 +934,11 @@ name = "cog-ha-matter"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"mdns-sd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -1073,6 +1076,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
@@ -1366,6 +1375,33 @@ dependencies = [
|
||||
"libloading 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1427,6 +1463,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1642,6 +1679,30 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1772,6 +1833,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -5096,6 +5163,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -6995,6 +7072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.9.1"
|
||||
@@ -7153,6 +7239,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -35,5 +35,16 @@ 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 hash chain + Ed25519 signature
|
||||
# layer for tamper-evident audit logs (ADR-116 §2.2). Same version
|
||||
# already vetted by ruv-neural — keep them aligned.
|
||||
sha2 = { workspace = true }
|
||||
ed25519-dalek = "2.1"
|
||||
|
||||
# mDNS responder (ADR-116 P4 §2.2): pure-Rust zero-conf daemon.
|
||||
# Same version pinned in wifi-densepose-desktop to keep the
|
||||
# workspace lockfile narrow.
|
||||
mdns-sd = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
//! discipline rules (see `docs/ADR-110-BRANCH-STATE.md`).
|
||||
|
||||
pub mod manifest;
|
||||
pub mod mdns;
|
||||
pub mod runtime;
|
||||
pub mod witness;
|
||||
pub mod witness_signing;
|
||||
|
||||
/// Cog identifier used in Seed's app-registry.json + the manifest.
|
||||
pub const COG_ID: &str = "ha-matter";
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! `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 std::collections::HashMap;
|
||||
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// Convert into the `mdns_sd::ServiceInfo` the responder daemon
|
||||
/// consumes. Pure transform — no socket binding, no daemon
|
||||
/// registration. The caller wires the resulting `ServiceInfo`
|
||||
/// into `ServiceDaemon::register` (next iter).
|
||||
///
|
||||
/// `hostname` should end in `.local.` per RFC 6762 — e.g.
|
||||
/// `"cognitum-seed-1.local."`. `ipv4` is the LAN-routable
|
||||
/// address HA's discovery will reach back on.
|
||||
pub fn to_service_info(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<ServiceInfo, mdns_sd::Error> {
|
||||
let mut props: HashMap<String, String> = HashMap::with_capacity(self.txt_records.len());
|
||||
for (k, v) in &self.txt_records {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
ServiceInfo::new(
|
||||
&self.service_type,
|
||||
&self.instance_name,
|
||||
hostname,
|
||||
ipv4,
|
||||
self.control_port,
|
||||
Some(props),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 to_service_info_carries_service_type_and_port() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// mdns-sd may rewrite the type with a trailing dot; allow
|
||||
// both forms.
|
||||
let ty = info.get_type();
|
||||
assert!(
|
||||
ty == "_ruview-ha._tcp" || ty == "_ruview-ha._tcp.",
|
||||
"unexpected service type: {ty}"
|
||||
);
|
||||
assert_eq!(info.get_port(), 9180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_propagates_txt_records() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// Every locked TXT key must reach the wire-format payload.
|
||||
assert_eq!(info.get_property_val_str("cog_id"), Some(crate::COG_ID));
|
||||
assert_eq!(info.get_property_val_str("mqtt_port"), Some("1883"));
|
||||
assert_eq!(info.get_property_val_str("privacy"), Some("1"));
|
||||
assert_eq!(info.get_property_val_str("proto"), Some("ruview-ha/1"));
|
||||
assert!(info.get_property_val_str("node_id").is_some());
|
||||
assert!(info.get_property_val_str("cog_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_does_not_silently_drop_caller_hostname() {
|
||||
// mdns-sd 0.11 accepts bare hostnames (no `.local.`); the
|
||||
// responsibility for the trailing dot lives in our wrapper.
|
||||
// Lock that the caller's hostname survives the conversion
|
||||
// verbatim — a future bump that starts mutating the value
|
||||
// surfaces a named test instead of a silent change.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.unwrap();
|
||||
assert!(info.get_hostname().contains("cognitum-seed-1"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
//! `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 std::io::{self, BufRead, Write};
|
||||
|
||||
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(×tamp_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
|
||||
}
|
||||
|
||||
/// Stream every event to a JSONL sink. Each event becomes one
|
||||
/// line terminated by `\n`. Empty chains write zero bytes.
|
||||
///
|
||||
/// The caller owns the writer — `File`, `BufWriter`, an
|
||||
/// in-memory `Vec<u8>` for tests — so this method never
|
||||
/// allocates beyond per-event line buffers.
|
||||
pub fn write_jsonl<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
for ev in &self.events {
|
||||
w.write_all(ev.to_jsonl_line().as_bytes())?;
|
||||
w.write_all(b"\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a JSONL audit bundle into a fresh `WitnessChain`. Each
|
||||
/// non-empty line is parsed via `WitnessEvent::from_jsonl_line`
|
||||
/// (which re-verifies the stored hash), then the loaded chain
|
||||
/// is end-to-end verified via [`WitnessChain::verify`] to catch
|
||||
/// out-of-order events or replayed prefixes.
|
||||
///
|
||||
/// Bundle errors surface with their `line_no` (1-indexed) so an
|
||||
/// auditor can point at the bad record.
|
||||
pub fn read_jsonl<R: BufRead>(r: R) -> Result<WitnessChain, WitnessReadError> {
|
||||
let mut chain = WitnessChain::new();
|
||||
for (i, line_res) in r.lines().enumerate() {
|
||||
let line_no = i + 1;
|
||||
let line = line_res.map_err(|e| WitnessReadError::Io {
|
||||
line_no,
|
||||
msg: e.to_string(),
|
||||
})?;
|
||||
if line.trim().is_empty() {
|
||||
continue; // tolerate blank lines / trailing \n
|
||||
}
|
||||
let ev = WitnessEvent::from_jsonl_line(&line)
|
||||
.map_err(|source| WitnessReadError::Parse { line_no, source })?;
|
||||
chain.events.push(ev);
|
||||
}
|
||||
chain
|
||||
.verify()
|
||||
.map_err(|source| WitnessReadError::Verify { source })?;
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// 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, thiserror::Error)]
|
||||
pub enum WitnessReadError {
|
||||
#[error("io error at line {line_no}: {msg}")]
|
||||
Io { line_no: usize, msg: String },
|
||||
#[error("parse error at line {line_no}: {source}")]
|
||||
Parse {
|
||||
line_no: usize,
|
||||
#[source]
|
||||
source: WitnessParseError,
|
||||
},
|
||||
#[error("chain-level verify failed: {source}")]
|
||||
Verify {
|
||||
#[source]
|
||||
source: WitnessVerifyError,
|
||||
},
|
||||
}
|
||||
|
||||
#[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 }));
|
||||
}
|
||||
|
||||
// ---- file persistence (write_jsonl / read_jsonl) ----
|
||||
|
||||
#[test]
|
||||
fn write_jsonl_empty_chain_writes_zero_bytes() {
|
||||
let c = WitnessChain::new();
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
assert_eq!(buf, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips_multi_event_chain() {
|
||||
let mut written = WitnessChain::new();
|
||||
written.append("a", b"first", 100);
|
||||
written.append("b", b"second", 101);
|
||||
written.append("c", br#"{"x":1}"#, 102);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
written.write_jsonl(&mut buf).unwrap();
|
||||
|
||||
let read_back = WitnessChain::read_jsonl(buf.as_slice()).unwrap();
|
||||
assert_eq!(read_back.len(), 3);
|
||||
assert_eq!(read_back.events(), written.events());
|
||||
assert_eq!(read_back.tip(), written.tip());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_jsonl_separates_events_with_newline() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
let s = std::str::from_utf8(&buf).unwrap();
|
||||
// Exactly N newlines for N events.
|
||||
assert_eq!(s.matches('\n').count(), 2);
|
||||
assert!(s.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_tolerates_blank_lines() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
// Inject blanks — sometimes happens when files are edited.
|
||||
let with_blanks = format!(
|
||||
"\n{}\n\n",
|
||||
std::str::from_utf8(&buf).unwrap().trim_end()
|
||||
);
|
||||
let read = WitnessChain::read_jsonl(with_blanks.as_bytes()).unwrap();
|
||||
assert_eq!(read.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_surfaces_line_no_on_parse_error() {
|
||||
// Two good events, then one with a flipped payload byte.
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
let mut text = String::from_utf8(buf).unwrap();
|
||||
let forged = c.events()[0].to_jsonl_line().replacen(
|
||||
"payload_hex\":\"31",
|
||||
"payload_hex\":\"32",
|
||||
1,
|
||||
);
|
||||
text.push_str(&forged);
|
||||
text.push('\n');
|
||||
|
||||
let err = WitnessChain::read_jsonl(text.as_bytes()).unwrap_err();
|
||||
match err {
|
||||
WitnessReadError::Parse { line_no, .. } => assert_eq!(line_no, 3),
|
||||
other => panic!("expected Parse error at line 3, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_chain_verify_catches_reordered_events() {
|
||||
// Build a chain, then write it out with the events swapped.
|
||||
// Each individual event still verifies its own hash (because
|
||||
// its prev_hash is internally consistent with what *it*
|
||||
// claimed), but the cross-event chain check fires.
|
||||
let mut original = WitnessChain::new();
|
||||
original.append("a", b"1", 100);
|
||||
original.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
original.write_jsonl(&mut buf).unwrap();
|
||||
let lines: Vec<&[u8]> = buf.split(|&b| b == b'\n').filter(|s| !s.is_empty()).collect();
|
||||
// Reverse order, send through reader.
|
||||
let mut reversed: Vec<u8> = Vec::new();
|
||||
reversed.extend_from_slice(lines[1]);
|
||||
reversed.push(b'\n');
|
||||
reversed.extend_from_slice(lines[0]);
|
||||
reversed.push(b'\n');
|
||||
let err = WitnessChain::read_jsonl(reversed.as_slice()).unwrap_err();
|
||||
assert!(matches!(err, WitnessReadError::Verify { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_no_trailing_newline_still_works() {
|
||||
// BufRead's lines() handles the no-final-newline case; lock
|
||||
// the behavior so a future swap to a different reader can't
|
||||
// silently truncate the last event.
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("only", b"x", 100);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
// Strip the trailing \n.
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
}
|
||||
let read = WitnessChain::read_jsonl(buf.as_slice()).unwrap();
|
||||
assert_eq!(read.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! `witness_signing` — Ed25519 signature layer over the witness chain.
|
||||
//!
|
||||
//! ADR-116 §2.2: every state transition must be signed by the
|
||||
//! Seed so a downstream auditor can prove the chain wasn't
|
||||
//! retroactively assembled. The chain primitive
|
||||
//! (`witness::WitnessChain`) handles hash linkage; this module
|
||||
//! adds the cryptographic attestation.
|
||||
//!
|
||||
//! Kept in a separate module from the chain itself so:
|
||||
//!
|
||||
//! * the hash chain stays usable without `ed25519-dalek` linked
|
||||
//! in (good for the `wasm32-unknown-unknown` cog variant we'll
|
||||
//! ship for browser-side audit verification),
|
||||
//! * key rotation invalidates *signatures* but not the chain —
|
||||
//! the auditor only needs the new public key to re-verify,
|
||||
//! * the signing surface stays small enough to audit in one
|
||||
//! read.
|
||||
//!
|
||||
//! ## What gets signed
|
||||
//!
|
||||
//! `sign_event(event, key)` signs the same canonical byte form
|
||||
//! that `witness::hash_event` hashes. That means:
|
||||
//!
|
||||
//! 1. A signature commits to the entire event (kind, payload,
|
||||
//! timestamp, seq, prev_hash) — no field can be retroactively
|
||||
//! changed without invalidating both the hash AND the
|
||||
//! signature.
|
||||
//! 2. The signature implicitly commits to the *chain position*
|
||||
//! via `prev_hash` — splicing a signed event into a different
|
||||
//! chain breaks verification.
|
||||
//!
|
||||
//! ## Key management
|
||||
//!
|
||||
//! Out of scope for this module. The cog runtime reads the Seed's
|
||||
//! Ed25519 signing key from the Cognitum control plane's secure
|
||||
//! key store (separate concern). Tests use a fixed-bytes seed for
|
||||
//! determinism — never check in real Seed keys here.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
|
||||
use crate::witness::{canonical_bytes, WitnessEvent};
|
||||
|
||||
/// Sign a witness event with the Seed's Ed25519 key. Returns the
|
||||
/// 64-byte Ed25519 signature over the event's canonical bytes —
|
||||
/// the same bytes `witness::hash_event` hashes, so a verifier that
|
||||
/// already trusts the hash chain only needs one extra check.
|
||||
pub fn sign_event(event: &WitnessEvent, key: &SigningKey) -> Signature {
|
||||
let bytes = canonical_bytes(
|
||||
event.prev_hash,
|
||||
event.seq,
|
||||
event.timestamp_unix_s,
|
||||
&event.kind,
|
||||
&event.payload,
|
||||
);
|
||||
key.sign(&bytes)
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature against a witness event using the
|
||||
/// Seed's public key. `Ok(())` iff the signature is valid for the
|
||||
/// event's canonical bytes under this key.
|
||||
pub fn verify_signature(
|
||||
event: &WitnessEvent,
|
||||
signature: &Signature,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<(), SignatureVerifyError> {
|
||||
let bytes = canonical_bytes(
|
||||
event.prev_hash,
|
||||
event.seq,
|
||||
event.timestamp_unix_s,
|
||||
&event.kind,
|
||||
&event.payload,
|
||||
);
|
||||
public_key
|
||||
.verify(&bytes, signature)
|
||||
.map_err(|_| SignatureVerifyError::Invalid)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SignatureVerifyError {
|
||||
#[error("Ed25519 signature does not match event under this public key")]
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Encode a signature as 128 hex chars (no `0x` prefix). Matches the
|
||||
/// hex convention the rest of the witness wire format uses.
|
||||
pub fn signature_to_hex(sig: &Signature) -> String {
|
||||
let bytes = sig.to_bytes();
|
||||
let mut s = String::with_capacity(128);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse a 128-char lowercase-hex string back into a `Signature`.
|
||||
pub fn signature_from_hex(s: &str) -> Result<Signature, SignatureParseError> {
|
||||
if s.len() != 128 {
|
||||
return Err(SignatureParseError::Length { found: s.len() });
|
||||
}
|
||||
let mut bytes = [0u8; 64];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
let lo = i * 2;
|
||||
*byte = u8::from_str_radix(&s[lo..lo + 2], 16)
|
||||
.map_err(|_| SignatureParseError::Hex { at: lo })?;
|
||||
}
|
||||
Ok(Signature::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SignatureParseError {
|
||||
#[error("signature hex must be 128 chars, got {found}")]
|
||||
Length { found: usize },
|
||||
#[error("signature hex parse error at byte offset {at}")]
|
||||
Hex { at: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::witness::{WitnessChain, WitnessHash};
|
||||
|
||||
fn fixed_key() -> SigningKey {
|
||||
// Deterministic test key — DO NOT use in production. The
|
||||
// seed is `b"cog-ha-matter-unit-tests--------"` (32 bytes).
|
||||
SigningKey::from_bytes(b"cog-ha-matter-unit-tests--------")
|
||||
}
|
||||
|
||||
fn fresh_event() -> WitnessEvent {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("fall_risk_elevated", br#"{"node":"kitchen"}"#, 1779512400);
|
||||
c.events()[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_round_trip() {
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
verify_signature(&event, &sig, &public).expect("clean signature verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_signature_under_wrong_key() {
|
||||
let key = fixed_key();
|
||||
let other = SigningKey::from_bytes(b"different-key-different-key-----");
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
// Same event, signature from `key`, but verify under `other`'s
|
||||
// public key — must fail.
|
||||
let err = verify_signature(&event, &sig, &other.verifying_key()).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_tampered_event() {
|
||||
// Sign one event, then mutate the payload and verify the
|
||||
// *mutated* event under the same signature. Must fail.
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let mut event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
event.payload = b"forged-after-sign".to_vec();
|
||||
let err = verify_signature(&event, &sig, &public).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_event_with_wrong_prev_hash() {
|
||||
// Same payload + kind, but the event claims a different
|
||||
// chain position. Cryptographically bound to prev_hash via
|
||||
// canonical bytes.
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let mut event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
event.prev_hash = WitnessHash([0x77; 32]);
|
||||
let err = verify_signature(&event, &sig, &public).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_hex_round_trip() {
|
||||
let key = fixed_key();
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
let hex = signature_to_hex(&sig);
|
||||
assert_eq!(hex.len(), 128);
|
||||
assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
|
||||
let parsed = signature_from_hex(&hex).unwrap();
|
||||
assert_eq!(parsed.to_bytes(), sig.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_from_hex_rejects_wrong_length() {
|
||||
let err = signature_from_hex("abcd").unwrap_err();
|
||||
assert_eq!(err, SignatureParseError::Length { found: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_from_hex_rejects_non_hex() {
|
||||
// 128 chars but non-hex.
|
||||
let bad = "Z".repeat(128);
|
||||
let err = signature_from_hex(&bad).unwrap_err();
|
||||
assert!(matches!(err, SignatureParseError::Hex { at: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_is_deterministic_for_same_event_and_key() {
|
||||
// Ed25519 is deterministic; locking this means a future
|
||||
// accidental switch to a randomized scheme (RustCrypto's
|
||||
// optional rand-based API) fires a named test.
|
||||
let key = fixed_key();
|
||||
let event = fresh_event();
|
||||
let sig1 = sign_event(&event, &key);
|
||||
let sig2 = sign_event(&event, &key);
|
||||
assert_eq!(sig1.to_bytes(), sig2.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_events_produce_different_signatures() {
|
||||
let key = fixed_key();
|
||||
let mut a = fresh_event();
|
||||
let mut b = fresh_event();
|
||||
a.payload = b"a".to_vec();
|
||||
b.payload = b"b".to_vec();
|
||||
let sig_a = sign_event(&a, &key);
|
||||
let sig_b = sign_event(&b, &key);
|
||||
assert_ne!(sig_a.to_bytes(), sig_b.to_bytes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user