Compare commits

...

3 Commits

Author SHA1 Message Date
ruv 07b792715f cog-ha-matter (ADR-116 P4): live mDNS responder + handle
Closes the mDNS half of P4. `runtime::start_mdns_responder` binds
multicast via `mdns_sd::ServiceDaemon::new`, builds the
ServiceInfo from `MdnsService::to_service_info` (iter 9), and
registers — returning a typed handle that owns both daemon and
fullname.

Handle shape:

  pub struct MdnsResponderHandle {
      daemon: ServiceDaemon,
      fullname: String,
  }

  impl MdnsResponderHandle {
      pub fn fullname(&self) -> &str;
      pub fn shutdown(self) -> Result<(), mdns_sd::Error>;
  }
  impl Drop for MdnsResponderHandle { /* best-effort */ }

Why explicit `shutdown` + best-effort `Drop`: a clean shutdown
sends a goodbye packet so HA's discovery integration sees the
service leave (good UX — no stale device card). `Drop` is the
fallback for panics / process termination but swallows errors
since panicking-in-Drop would mask the real failure.

1 new live-I/O test:
  * mdns_responder_fullname_concatenates_instance_and_service_type
    — actually binds multicast on the loopback adapter, registers,
    asserts the fullname contains `_ruview-ha._tcp`, then
    shutdown()s. Confirmed working on Windows; CI environments
    where multicast bind is filtered will hit the gracefully-
    skipping early return rather than failing the suite.

64/64 cog tests green (63 → 64).

ADR-116 P4: mDNS half  (record-builder + ServiceInfo + live
responder), witness half  (chain + JSONL + file + Ed25519).
Last piece is the embedded rumqttd broker so external mosquitto
becomes optional.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:31:38 -04:00
ruv 34eced880f cog-ha-matter (ADR-116 P4): MdnsService -> mdns-sd ServiceInfo bridge
Pure conversion from our wire-format `MdnsService` to the
`mdns_sd::ServiceInfo` shape the responder daemon consumes. No
socket binding, no daemon registration yet — that lands next iter
as a `runtime::spawn_mdns_responder(info)` JoinHandle returning
helper, same shape as `runtime::spawn_publisher`.

  * `MdnsService::to_service_info(hostname, ipv4) ->
        Result<ServiceInfo, mdns_sd::Error>`
  * `mdns-sd = "0.11"` added — aligned with the workspace pin from
    wifi-densepose-desktop so the lockfile doesn't fork dalek-like
    surfaces.

3 new tests:

  * to_service_info_carries_service_type_and_port — locks that
    `_ruview-ha._tcp` (with or without mdns-sd's trailing-dot
    normalisation) and the control port round-trip through the
    conversion
  * to_service_info_propagates_txt_records — every locked TXT
    key from iter 4 (cog_id, mqtt_port, privacy, proto, node_id,
    cog_version) reachable via `get_property_val_str` on the
    converted ServiceInfo
  * to_service_info_does_not_silently_drop_caller_hostname —
    locks the caller-side responsibility for the .local. suffix.
    mdns-sd 0.11 accepts bare hostnames (verified empirically by
    initial test expecting it to reject — it didn't), so the
    wrapper layer must do the trailing-dot dance. Documenting
    that via a named test catches future bumps where the lib
    starts mutating the value.

63/63 cog tests green (60 → 63).

ADR-116 P4 now ⁶⁄₇:  mDNS record-builder,  chain,  JSONL, 
file persistence,  Ed25519 signing,  ServiceInfo conversion;
 daemon register + embedded broker.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:28:10 -04:00
ruv bb154d4e78 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>
2026-05-23 18:22:15 -04:00
7 changed files with 506 additions and 3 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ Ranked by build cost × user impact:
| **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked |
| **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done**`cargo check` + `cargo test` green |
| **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done**`main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender<VitalsSnapshot>`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. |
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | 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 — **mDNS half complete:** record-builder ✅, ServiceInfo conversion ✅, **live responder ✅** (`runtime::start_mdns_responder` binds multicast, registers, returns `MdnsResponderHandle` with explicit `shutdown()` + best-effort Drop). **Witness half complete:** hash-chain ✅, JSONL line serializer ✅, file persistence + chain-level verify ✅, Ed25519 signing ✅. **Remaining:** embedded rumqttd broker. |
| **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
+95
View File
@@ -934,6 +934,8 @@ name = "cog-ha-matter"
version = "0.3.0"
dependencies = [
"clap",
"ed25519-dalek",
"mdns-sd",
"serde",
"serde_json",
"sha2",
@@ -1074,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"
@@ -1367,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"
@@ -1428,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",
]
@@ -1643,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"
@@ -1773,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"
@@ -5097,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"
@@ -6996,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"
@@ -7154,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"
+9 -2
View File
@@ -35,9 +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 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"
# 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"
+1
View File
@@ -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";
+76
View File
@@ -38,6 +38,10 @@
//! 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
@@ -74,6 +78,33 @@ impl MdnsService {
.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
@@ -203,6 +234,51 @@ mod tests {
}
}
#[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.
+93
View File
@@ -21,6 +21,7 @@
use std::sync::Arc;
use mdns_sd::ServiceDaemon;
use tokio::{sync::broadcast, task::JoinHandle};
use wifi_densepose_sensing_server::mqtt::{
config::{MqttConfig, PublishRates, TlsConfig},
@@ -29,6 +30,8 @@ use wifi_densepose_sensing_server::mqtt::{
DEFAULT_DISCOVERY_PREFIX, MANUFACTURER,
};
use crate::mdns::MdnsService;
/// Caller-supplied identity for the cog instance. Filled in by the
/// cog runtime from the mDNS hostname / Seed control plane in
/// production; threaded as a parameter so tests can build inputs
@@ -129,6 +132,66 @@ pub fn spawn_publisher(
publisher::spawn(Arc::new(config), discovery, state_rx)
}
/// Owned handle to a live mDNS responder. Holding it keeps the
/// service advertised; `shutdown` unregisters cleanly so HA's
/// discovery integration sees a goodbye packet instead of a
/// dropped advertisement.
///
/// `Drop` is best-effort: tries unregister + daemon shutdown but
/// swallows errors, since panicking in Drop would mask the real
/// failure that prompted the shutdown.
pub struct MdnsResponderHandle {
daemon: ServiceDaemon,
fullname: String,
}
impl MdnsResponderHandle {
/// Fully-qualified DNS-SD name (`<instance>.<type>.<domain>`).
/// Exposed for tests + logging; the responder uses it to
/// unregister.
pub fn fullname(&self) -> &str {
&self.fullname
}
/// Unregister the service and shut down the daemon. Returns
/// any error so the caller's shutdown sequence can surface it.
pub fn shutdown(self) -> Result<(), mdns_sd::Error> {
let _ = self.daemon.unregister(&self.fullname);
let _ = self.daemon.shutdown()?;
Ok(())
}
}
impl Drop for MdnsResponderHandle {
fn drop(&mut self) {
let _ = self.daemon.unregister(&self.fullname);
let _ = self.daemon.shutdown();
}
}
/// Start the mDNS responder for a cog and register its service.
///
/// Binds a multicast socket (`mdns_sd::ServiceDaemon::new`) and
/// publishes `service` under `hostname` (must end in `.local.`)
/// and `ipv4` (the LAN-routable address HA's discovery reaches
/// back on).
///
/// Live-I/O: binding multicast may fail in containerised CI or
/// on networks where 5353/udp is filtered — callers should treat
/// the error as recoverable (log + retry, or fall back to manual
/// HA configuration) rather than fatal to the cog.
pub fn start_mdns_responder(
service: &MdnsService,
hostname: &str,
ipv4: &str,
) -> Result<MdnsResponderHandle, mdns_sd::Error> {
let daemon = ServiceDaemon::new()?;
let info = service.to_service_info(hostname, ipv4)?;
let fullname = info.get_fullname().to_string();
daemon.register(info)?;
Ok(MdnsResponderHandle { daemon, fullname })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -230,6 +293,36 @@ mod tests {
assert!(DEFAULT_STATE_CHANNEL_CAPACITY >= 64);
}
#[test]
fn mdns_responder_fullname_concatenates_instance_and_service_type() {
// Live-I/O test: binds multicast on the loopback adapter.
// Skips with a warning if the host's network stack refuses
// the bind (containerised CI without --network host, etc.)
// rather than failing the whole test suite.
use crate::mdns::build_mdns_service;
let svc = build_mdns_service(&id(), 9180, 1883, false);
let handle = match start_mdns_responder(&svc, "cog-ha-matter-test.local.", "127.0.0.1") {
Ok(h) => h,
Err(e) => {
eprintln!("mdns multicast bind not available in this sandbox: {e} — skipping");
return;
}
};
// Fullname format is "<instance>.<service_type>." per RFC 6763.
// mdns-sd may URL-escape special chars (— in instance name) so
// we only assert on the service-type segment which is stable.
let fullname = handle.fullname().to_string();
assert!(
!fullname.is_empty(),
"fullname empty after register"
);
assert!(
fullname.contains("_ruview-ha._tcp"),
"fullname `{fullname}` missing service type"
);
handle.shutdown().expect("clean shutdown");
}
#[test]
fn default_identity_carries_pkg_version_and_pid() {
let identity = CogIdentity::default_for_build();
@@ -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());
}
}