diff --git a/docs/adr/ADR-116-cog-ha-matter-seed.md b/docs/adr/ADR-116-cog-ha-matter-seed.md index 4e071bcc..7216697b 100644 --- a/docs/adr/ADR-116-cog-ha-matter-seed.md +++ b/docs/adr/ADR-116-cog-ha-matter-seed.md @@ -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`, 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) | in progress — (a) mDNS service-record builder. (b) Witness hash-chain primitive. (c) Witness JSONL line serializer. (d) **Witness file persistence shipped** — `WitnessChain::{write_jsonl, read_jsonl}` accept any `Write`/`BufRead`, tolerate blank lines, surface `line_no` on parse error, run chain-level `verify()` on load to catch reordered/replayed events. 7 new tests including reorder-detection. (e) Responder (mdns-sd) + embedded rumqttd + Ed25519 signing layer still 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 ✅** — `witness_signing::{sign_event, verify_signature, signature_to_hex, signature_from_hex}` signs the same canonical bytes the hash chain commits to, so a single attestation covers `kind + payload + ts + seq + prev_hash`. Tests cover wrong-key, tampered-event, wrong-prev_hash, hex round-trip, determinism. (f) Responder (mdns-sd binding) + embedded rumqttd still pending — these are the remaining I/O-side 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 | diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 393dc1bd..ed485ebb 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -934,6 +934,7 @@ name = "cog-ha-matter" version = "0.3.0" dependencies = [ "clap", + "ed25519-dalek", "serde", "serde_json", "sha2", @@ -1074,6 +1075,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" @@ -1367,6 +1374,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" @@ -1428,6 +1462,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ + "const-oid", "pem-rfc7468", "zeroize", ] @@ -1643,6 +1678,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" @@ -1773,6 +1832,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" @@ -5097,6 +5162,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" @@ -6996,6 +7071,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" @@ -7154,6 +7238,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" diff --git a/v2/crates/cog-ha-matter/Cargo.toml b/v2/crates/cog-ha-matter/Cargo.toml index 89627c71..6d7cac08 100644 --- a/v2/crates/cog-ha-matter/Cargo.toml +++ b/v2/crates/cog-ha-matter/Cargo.toml @@ -35,9 +35,11 @@ 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. +# 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" [dev-dependencies] tempfile = "3.10" diff --git a/v2/crates/cog-ha-matter/src/lib.rs b/v2/crates/cog-ha-matter/src/lib.rs index bd81d4a1..1bb83a3d 100644 --- a/v2/crates/cog-ha-matter/src/lib.rs +++ b/v2/crates/cog-ha-matter/src/lib.rs @@ -30,6 +30,7 @@ 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"; diff --git a/v2/crates/cog-ha-matter/src/witness_signing.rs b/v2/crates/cog-ha-matter/src/witness_signing.rs new file mode 100644 index 00000000..43f3a866 --- /dev/null +++ b/v2/crates/cog-ha-matter/src/witness_signing.rs @@ -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 { + 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()); + } +}