cog-ha-matter (ADR-116 P4): Ed25519 signing layer for witness chain
Closes the cryptographic-attestation gap in ADR-116 §2.2: every
witness event can now be signed by the Seed's Ed25519 key, with
verify available to any auditor holding the public key.
Module shape (`src/witness_signing.rs`, kept separate from
`witness::` so the hash chain stays usable without dalek linked
in — important for the wasm32 audit-verifier variant we'll ship
later):
* sign_event(event, &SigningKey) -> Signature
* verify_signature(event, &Signature, &VerifyingKey)
-> Result<(), SignatureVerifyError>
* signature_to_hex / signature_from_hex (128-char lowercase,
matches the witness hex convention)
* SignatureVerifyError::Invalid
* SignatureParseError::{Length, Hex}
Key design point: signature covers the SAME canonical bytes
witness::hash_event hashes. That means:
1. A signed event commits to the entire event content (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 event's *chain
position* via prev_hash — splicing a signed event into a
different chain breaks verification.
Adds `ed25519-dalek = "2.1"` to cog-ha-matter (already in
workspace via ruv-neural, version kept aligned).
9 new tests:
* sign_and_verify_round_trip
* verify_rejects_signature_under_wrong_key
* verify_rejects_tampered_event (mutate payload after sign)
* verify_rejects_event_with_wrong_prev_hash (splice attack)
* signature_hex_round_trip
* signature_from_hex_rejects_wrong_length
* signature_from_hex_rejects_non_hex
* signature_is_deterministic_for_same_event_and_key
(locks Ed25519's determinism — catches future accidental
swap to a randomized scheme)
* different_events_produce_different_signatures
60/60 cog tests green (51 → 60). Key management is intentionally
out of scope here — the cog runtime reads the Seed's key from the
Cognitum control plane's secure store (separate concern).
ADR-116 P4 now ⁵⁄₆: ✅ mDNS record, ✅ chain, ✅ JSONL, ✅ file
persistence, ✅ Ed25519 signing; ⏳ responder + embedded broker.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
1f5b7b48c9
commit
bb154d4e78
|
|
@ -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) | 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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue