From 9f80b66ae3dd8f4fac293f9a5bd0a0c441d4917c Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 14 Jun 2026 19:04:09 -0400 Subject: [PATCH] =?UTF-8?q?harden(cog-ha-matter=20crypto):=20domain-separa?= =?UTF-8?q?te=20witness=20signing=20+=20verify=5Fstrict=20(signing=20chain?= =?UTF-8?q?=20otherwise=20sound=20=E2=80=94=20P2=20crypto=20core=20verifie?= =?UTF-8?q?d)=20(#1080)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cog-ha-matter): domain-separate witness signing chain + verify_strict (ADR-116 §2.2) Crypto review of the SHA-256 + Ed25519 witness chain that ADR-262 P2 reuses. The sibling wifi-densepose-engine bug class (unframed concatenation of operator-influenceable strings into a signed digest) is ABSENT here — canonical_bytes already length-prefixes kind/payload. Two real hardening gaps fixed: - CHM-WIT-01: add a versioned domain-separation tag (WITNESS_DOMAIN_TAG = b"cog-ha-matter/witness-event/v1\0") to canonical_bytes so the witness SHA-256 preimage / Ed25519 message cannot be replayed as a message for another signing context that shares key infrastructure (notably the manifest binary_signature). Completes the engine review's "domain-tag + length-prefix" rule. Witness bytes change by design (prior on-disk hashes/sigs invalidated); no in-repo crate consumes these bytes programmatically. - CHM-WIT-02: verify_signature uses VerifyingKey::verify_strict (rejects non-canonical encodings + small-order keys) for the audit-uniqueness property. Key stays caller-pinned (not read from the event). Pinned by fails-on-old tests: canonical_bytes_is_domain_separated, canonical_bytes_starts_with_domain_tag_then_prev_hash, witness_preimage_cannot_collide_with_a_bare_manifest_digest, signature_commits_to_domain_tag_not_bare_fields; key-pinning guarded by verify_uses_strict_path_and_pins_caller_key. cog-ha-matter 64 -> 68 tests, 0 failed. Co-Authored-By: claude-flow * docs(cog-ha-matter): record ADR-116 crypto review findings (CHM-WIT-01/02) CHANGELOG [Unreleased] Security entry + ADR-116 §4.1 review notes: engine-class signed-digest collision confirmed ABSENT (length-prefixing already correct), domain-separation tag added, verify_strict hardening, and the clean dimensions (verify-before-trust, key-handling, determinism, fail-closed parsing) with byte-layout evidence. Co-Authored-By: claude-flow --- CHANGELOG.md | 4 + docs/adr/ADR-116-cog-ha-matter-seed.md | 51 +++++++++++ v2/crates/cog-ha-matter/src/witness.rs | 89 ++++++++++++++++--- .../cog-ha-matter/src/witness_signing.rs | 66 +++++++++++++- 4 files changed, 197 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd60d0c0..4e5d46cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ADR-260: RuField MFS — the open specification for camera-free multimodal field sensing.** A common event / tensor / calibration / privacy / provenance model that sits *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and future quantum sensors (each modality emits a normalized `FieldEvent` → `FieldTensor` → `FusionGraph` → `PrivacyClass` → `ProvenanceReceipt`). Published as a **standalone repo** [`ruvnet/rufield`](https://github.com/ruvnet/rufield) and vendored here as the `vendor/rufield` submodule (the `vendor/rvcsi` pattern — not a `v2/` workspace member). The v0.1 reference stack is a self-contained 6-crate Rust workspace (`rufield-core`, `-provenance` [sha256 + ed25519], `-privacy` [P0–P5 guard], `-adapters` [deterministic `SyntheticSim` across wifi_csi/mmwave_radar/infrared_thermal], `-fusion` [graph + TOML weighted-Bayes rules → 7 room-state inferences], `-bench` [deterministic runner + the §31 acceptance test]). **60 tests / 0 failed, clippy-clean.** §27 acceptance criteria 1–8 and 10 PASS; the live dashboard (9) is deferred. **All benchmark metrics are SYNTHETIC** (scored against the simulator's own ground truth — presence/breathing/bed_exit/room_transition F1 = 1.000, nocturnal_scratch 0.923 reported honestly, p95 latency ~0.01 ms, provenance coverage 100%, 0 privacy violations) — they prove the pipeline recovers known truth, **not** field accuracy; real hardware adapters (ESP32 CSI, mmWave, thermal IR) are a documented roadmap item, none validated in v0.1. The Python deterministic proof is unchanged (rufield is off the signal-processing proof path). ### Security +- **`cog-ha-matter` witness/manifest crypto review — engine-class signed-digest collision confirmed ABSENT (length-prefixing already correct); domain-separation tag ADDED + `verify_strict` HARDENED; key-handling & verify-before-trust confirmed clean (ADR-116 §2.2).** Beyond-SOTA crypto+security review of the Cognitum/HA-Matter bridge's SHA-256 + Ed25519 witness chain — the exact signing chain ADR-262 P2 proposes to reuse — un-covered by the ADR-154–159 sweep. **Top-priority check: the sibling `wifi-densepose-engine` bug class (unframed boundary-to-boundary concatenation of operator-influenceable strings into a signed/hashed digest).** Result reported honestly: **that bug class is ABSENT here** — `witness::canonical_bytes` already length-prefixes the two variable-length operator-influenceable fields (`kind_len:u32-be ‖ kind`, `payload_len:u32-be ‖ payload`) over fixed-width `prev_hash[32] ‖ seq:u64-be ‖ ts:u64-be`, an injective encoding (proven pre-existing by `canonical_bytes_length_prefixing_prevents_ambiguity`), and `witness_signing::sign_event`/`verify_signature` sign/verify the **identical** bytes the hash chain commits to (no separate unframed concatenation). The manifest `binary_signature` (Ed25519 over the fixed 64-hex-char `binary_sha256`) is signed **at build time by the Makefile**, not in-crate, and over a single fixed-length value — no in-crate manifest-signing concatenation surface. **Two real hardening gaps fixed, the first pinned by fails-on-old tests:** + - **CHM-WIT-01 (missing domain-separation tag, LOW) — ADDED.** The engine review's prescribed fix is "domain-tag **+** length-prefix"; the length-prefix half was present, the **domain tag was absent**. The witness SHA-256 preimage / Ed25519 message carried no tag distinguishing it from any other signing context that shares key infrastructure — notably the manifest `binary_signature`, the very chain ADR-262 P2 reuses. **Fix:** prepend a versioned, NUL-terminated `WITNESS_DOMAIN_TAG = b"cog-ha-matter/witness-event/v1\x00"` to `canonical_bytes` (the doc-comment already anticipated a leading version migration). Cross-protocol separation now holds: a witness signature can never be replayed as a message for another Ed25519 context. **Witness-bytes change by design** (prior on-disk witness hashes/signatures invalidated, like the engine fix) — verified safe: **no in-repo crate consumes cog-ha-matter's witness bytes/signatures programmatically** (all references are doc-comment mentions; the crate is self-contained, no `use cog_ha_matter::` anywhere). Pinned by `canonical_bytes_is_domain_separated`, `canonical_bytes_starts_with_domain_tag_then_prev_hash`, `witness_preimage_cannot_collide_with_a_bare_manifest_digest` (witness.rs) and `signature_commits_to_domain_tag_not_bare_fields` (witness_signing.rs — a signature over the **un-tagged** field concatenation must NOT verify); the domain-separation guard **FAILED on the reverted un-tagged encoding** ("canonical message is not domain-separated"). + - **CHM-WIT-02 (permissive Ed25519 verification, LOW) — HARDENED to `verify_strict`.** For a tamper-evident **audit** chain the signature is the attestation, so `verify_signature` now uses `VerifyingKey::verify_strict` (rejects non-canonical encodings + small-order public keys per RFC 8032) instead of the permissive `Verifier::verify` — giving auditors the "one canonical signature per event" property they rely on when comparing/deduplicating signed records. Not a forgery fix (the public key is caller-pinned, never parsed from the event), reported at true LOW severity. Guarded by `verify_uses_strict_path_and_pins_caller_key`. + - **Dimensions confirmed clean (with evidence, no invented issues):** (1) **verify-before-trust + key-pinning** — `verify_signature` takes the verifying key as a **caller-supplied parameter** (the Seed's known key), never reads a key from the event/manifest, so a forged event carrying its own key cannot self-attest; `WitnessChain::read_jsonl` re-derives and re-checks every `this_hash` on load (tampered bundle → `HashMismatch`) and runs a chain-level `verify()` catching reordered/spliced events (existing `verify_rejects_*`, `jsonl_parser_rejects_tampered_payload`, `read_jsonl_chain_verify_catches_reordered_events`). (2) **key handling** — the crate **never generates, stores, logs, or serializes** a signing key: `sign_event` takes `&SigningKey` by reference, the manifest struct has no key field, and the only key material in-crate is the **test-only** fixed seed (clearly documented "DO NOT use in production"); production keys come from the Seed's secure key store (out of scope, ADR-116 §key-management). No hardcoded/default/predictable production key, no key in the manifest, no world-readable key path (the crate does no key file I/O). (3) **determinism/canonicalization** — `canonical_bytes` is pure positional bytes (no HashMap iteration, no float formatting); Ed25519 is deterministic (pinned by `signature_is_deterministic_for_same_event_and_key`); the JSONL wire form is hand-rolled with **alphabetically-locked** field order (`jsonl_field_order_is_alphabetical_for_byte_stability`) and the mdns TXT records are `sort()`-ed for byte-stable advertisement — no iteration-order or float-format nondeterminism feeds any hash/signature. (4) **fail-closed parsing / DoS** — `from_jsonl_line`/`from_hex`/`hex_decode` return structured errors (never panic) on wrong length, non-hex, missing field, odd-length payload, or hash mismatch (`jsonl_parser_rejects_non_hex_hash`, `hex_decode_rejects_odd_length`, …); `main.rs` reads no untrusted files/paths (clap args only; `--print-manifest` emits a static template) — no path/injection surface. (5) **de-magic** — the witness/signing byte layout is already expressed as named widths; no bare security-relevant literals worth extracting beyond the new named `WITNESS_DOMAIN_TAG`. `cog-ha-matter --no-default-features`: **64→68 tests**, 0 failed (+3 domain-tag witness, +1 signing-layer domain-commit, +1 strict-verify key-pin; one pre-existing test renamed to assert the tag). Workspace green; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — cog-ha-matter is off the signal proof path). Review notes appended to ADR-116 §2.2. - **`homecore-api` (HA-wire-compat REST + WebSocket) beyond-SOTA security review — `GET /api/` auth-gate gap FIXED + WS event-stream lag-DoS robustness FIXED; auth/traversal/injection/info-leak dimensions confirmed clean (ADR-161 / ADR-130).** Network-facing review of the HA-wire-compat API layer (remote attack surface), not covered by the ADR-154–159 sweep — same scrutiny the sibling `wifi-densepose-engine` and `-bfld` reviews got. **Two real bugs fixed, each pinned by a fails-on-old test.** - **HC-API-AUTH-01 (auth-gate gap, LOW) — `GET /api/` was unauthenticated; FIXED.** Every sibling REST route (`/api/config`, `/api/states`, `/api/services`, …) calls `BearerAuth::from_headers` first, but `rest::api_root` took no headers and unconditionally returned `200 {"message":"API running."}`. HA's `APIStatusView` inherits `requires_auth = True`, so an unauthenticated/wrong-token request to `/api/` must be **401** — HA clients use this status route as a token-validation probe, and a 200 both told a bad-token client its token was good and let an unauthenticated party confirm a live endpoint. Severity is LOW (the body is a static string — no entity/state data leaks), reported at true severity, not inflated. **Fix:** `api_root` now validates the bearer like its siblings. Pinned by `api_root_rejects_missing_bearer` + `api_root_rejects_wrong_bearer` (both 200→assert-401 on old code) and guarded by `api_root_accepts_correct_bearer`. - **HC-WS-LAG-01 (DoS-adjacent silent failure, LOW) — `subscribe_events` killed the event stream on a broadcast lag; FIXED.** The per-subscription task matched `Err(_) => break` on both `broadcast::Receiver::recv()` arms, but `Lagged(n)` (a slow consumer falling >4,096 events — `EVENT_CHANNEL_CAPACITY` — behind) is **recoverable**: the bus doc itself says "Lagged receivers must re-sync", and HA's WS contract keeps the subscription alive across a lag. The old code treated the first lag as fatal, so after an event burst the client's stream went **permanently silent** with no error frame — a self-inflicted event-delivery DoS under load. **Fix:** `Lagged(_) => continue` (skip the dropped window, re-sync), `Closed => break`, on both the system and domain arms. Pinned by `subscription_survives_broadcast_lag` (subscribes, floods 6,000 filtered events past the 4,096 capacity to force a `Lagged`, then asserts a subsequent subscribed event is still delivered — 5s-timeout panic on old code). diff --git a/docs/adr/ADR-116-cog-ha-matter-seed.md b/docs/adr/ADR-116-cog-ha-matter-seed.md index c6919082..354c1a35 100644 --- a/docs/adr/ADR-116-cog-ha-matter-seed.md +++ b/docs/adr/ADR-116-cog-ha-matter-seed.md @@ -104,6 +104,57 @@ Ranked by build cost × user impact: | **P9** | HACS integration repo (`hass-wifi-densepose`) for HA-side install path | pending | | **P10** | Witness bundle + CSA-style spec compliance check | pending | +## 4.1 Crypto/security review notes (§2.2 witness chain — ADR-262 P2 prerequisite) + +Beyond-SOTA crypto+security review of the SHA-256 + Ed25519 witness chain +(`witness.rs` / `witness_signing.rs`) and the manifest signature surface +(`manifest.rs`), because ADR-262 P2 proposes to **reuse this exact signing +chain**. Top priority was the sibling `wifi-densepose-engine` bug class — +unframed boundary-to-boundary concatenation of operator-influenceable strings +into a signed/hashed digest. + +- **Engine bug class ABSENT (good result, reported with byte evidence).** + `canonical_bytes` is `DOMAIN_TAG ‖ prev_hash[32] ‖ seq:u64-be ‖ ts:u64-be ‖ + kind_len:u32-be ‖ kind ‖ payload_len:u32-be ‖ payload`. The two + variable-length operator-influenceable fields (`kind`, `payload`) are + **length-prefixed**; the fixed-width fields are self-delimiting → the + encoding is injective (no two distinct event tuples share a preimage). The + Ed25519 signature signs the **identical** bytes the SHA-256 chain commits to. + No separate unframed concatenation exists; the manifest `binary_signature` + is signed at build time (Makefile) over a single fixed-length `binary_sha256` + hex value, not in-crate. + +- **CHM-WIT-01 (FIXED) — domain-separation tag added.** The engine fix + prescribed *domain-tag + length-prefix*; length-prefix was present, the + domain tag was not. Added a versioned, NUL-terminated + `WITNESS_DOMAIN_TAG = b"cog-ha-matter/witness-event/v1\x00"` prefix so the + witness message can never be replayed as a message for another Ed25519 + context that shares key infrastructure (notably the manifest signature). + **Witness bytes change by design** (prior on-disk hashes/signatures + invalidated, as with the engine fix); verified safe because no in-repo crate + consumes cog-ha-matter witness bytes programmatically (doc-mentions only). + +- **CHM-WIT-02 (HARDENED) — `verify_signature` now uses `verify_strict`.** For + an audit chain the signature is the attestation, so non-canonical encodings + and small-order keys are rejected (RFC 8032 strict), giving the "one + canonical signature per event" property. Not a forgery fix — the verifying + key is caller-pinned, never read from the event. + +- **Confirmed clean (with evidence):** verify-before-trust + key-pinning + (`verify_signature` takes the verifying key as a parameter; `read_jsonl` + re-derives every hash and chain-verifies); key handling (the crate never + generates/stores/logs/serializes a signing key — only a documented test-only + fixed seed; production keys come from the Seed secure store, out of scope); + determinism (positional bytes, deterministic Ed25519, alphabetically-locked + JSONL field order, sorted TXT records — no HashMap/float nondeterminism feeds + any digest); fail-closed parsing (structured errors, no panics; `main.rs` + reads no untrusted files/paths). + +Tests: `cog-ha-matter --no-default-features` 64 → **68**, 0 failed (CHM-WIT-01 +pinned by 4 fails-on-old tests across `witness.rs`/`witness_signing.rs`; +CHM-WIT-02 guarded by a key-pinning test). Python deterministic proof +unchanged (cog-ha-matter is off the signal proof path). + ## 5. References - ADR-101 — `cog-pose-estimation` packaging precedent (signed binaries on GCS, .cog manifest) diff --git a/v2/crates/cog-ha-matter/src/witness.rs b/v2/crates/cog-ha-matter/src/witness.rs index b4562309..c331dde9 100644 --- a/v2/crates/cog-ha-matter/src/witness.rs +++ b/v2/crates/cog-ha-matter/src/witness.rs @@ -102,19 +102,43 @@ pub struct WitnessEvent { pub this_hash: WitnessHash, } +/// Domain-separation tag prefixing every witness canonical message. +/// +/// This is the *domain tag* half of the "domain-tag + length-prefix" +/// rule for any hashed/signed message whose fields are +/// operator-influenceable. The witness chain already length-prefixes +/// `kind` and `payload` (preventing intra-protocol concatenation +/// forgery); the tag adds cross-protocol separation so a SHA-256 +/// preimage / Ed25519 message produced here can never be re-interpreted +/// as a message from another signing context that shares key +/// infrastructure — notably ADR-116's *manifest* `binary_signature` +/// (Ed25519 over `binary_sha256`), which ADR-262 P2 reuses this exact +/// chain for. A signature is only ever valid for the one domain whose +/// tag it commits to. +/// +/// The trailing NUL terminates the version string so a future +/// migration (Blake3, extra fields, Merkle tier) bumps the tag instead +/// of silently colliding with v1 bundles. +pub const WITNESS_DOMAIN_TAG: &[u8] = b"cog-ha-matter/witness-event/v1\x00"; + /// 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: +/// The format is domain-tagged and length-prefixed: /// /// ```text -/// prev_hash[32] | seq:u64-be | ts:u64-be | kind_len:u32-be | kind | payload_len:u32-be | payload +/// DOMAIN_TAG | 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. +/// * The leading [`WITNESS_DOMAIN_TAG`] gives cross-protocol +/// separation: bytes signed/hashed here cannot be replayed as a +/// message for another Ed25519 context in the same trust chain +/// (e.g. the manifest `binary_signature`). It also carries a format +/// version for staged migrations. +/// * Length-prefixing `kind` and `payload` prevents the classic +/// "concatenation forgery" where `"abc" + "def"` and `"ab" + "cdef"` +/// would hash the same. The fixed-width `prev_hash`/`seq`/`ts` +/// fields are self-delimiting. pub fn canonical_bytes( prev_hash: WitnessHash, seq: u64, @@ -123,7 +147,10 @@ pub fn canonical_bytes( payload: &[u8], ) -> Vec { let kind_bytes = kind.as_bytes(); - let mut out = Vec::with_capacity(32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len()); + let mut out = Vec::with_capacity( + WITNESS_DOMAIN_TAG.len() + 32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len(), + ); + out.extend_from_slice(WITNESS_DOMAIN_TAG); 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()); @@ -466,11 +493,51 @@ mod tests { } #[test] - fn canonical_bytes_starts_with_prev_hash() { + fn canonical_bytes_starts_with_domain_tag_then_prev_hash() { // Locks the on-wire format. A future migration that flips - // field order must bump a version byte and update this test. + // field order must bump the domain tag and update this test. let bytes = canonical_bytes(WitnessHash([7u8; 32]), 1, 2, "k", b"p"); - assert_eq!(&bytes[..32], &[7u8; 32]); + let tag = WITNESS_DOMAIN_TAG.len(); + assert_eq!(&bytes[..tag], WITNESS_DOMAIN_TAG); + assert_eq!(&bytes[tag..tag + 32], &[7u8; 32]); + } + + #[test] + fn canonical_bytes_is_domain_separated() { + // Cross-protocol separation: the witness preimage must begin + // with the domain tag so its SHA-256 / Ed25519 message can + // never be reinterpreted as a message from another signing + // context that shares key infrastructure (e.g. the manifest + // `binary_signature` over `binary_sha256`). Fails on the old + // un-tagged encoding, which began directly with `prev_hash`. + let bytes = canonical_bytes(WitnessHash::GENESIS, 0, 0, "k", b"p"); + assert!( + bytes.starts_with(WITNESS_DOMAIN_TAG), + "canonical message is not domain-separated" + ); + // The tag is versioned and NUL-terminated. + assert!(WITNESS_DOMAIN_TAG.ends_with(b"\x00")); + assert!(WITNESS_DOMAIN_TAG.windows(2).any(|w| w == b"v1")); + } + + #[test] + fn witness_preimage_cannot_collide_with_a_bare_manifest_digest() { + // The manifest `binary_signature` signs a bare 64-byte + // SHA-256 hex string. A witness preimage must never *equal* + // such a string, even if an operator crafted kind/payload to + // try — the domain tag (33 bytes) + fixed 48-byte prefix make + // the witness message structurally longer and tag-distinct. + // Fails on the old encoding only if it could ever produce a + // 64-byte all-hex message; the tag makes the impossibility + // explicit and regression-guarded. + let manifest_digest_msg = "a".repeat(64); // 64 ASCII hex bytes + let witness = canonical_bytes(WitnessHash::GENESIS, 0, 0, "", b""); + assert_ne!(witness.as_slice(), manifest_digest_msg.as_bytes()); + assert!( + witness.len() > manifest_digest_msg.len(), + "domain tag must make witness preimage structurally distinct" + ); + assert!(!witness.starts_with(b"aaaa")); } #[test] diff --git a/v2/crates/cog-ha-matter/src/witness_signing.rs b/v2/crates/cog-ha-matter/src/witness_signing.rs index 43f3a866..643c2ca3 100644 --- a/v2/crates/cog-ha-matter/src/witness_signing.rs +++ b/v2/crates/cog-ha-matter/src/witness_signing.rs @@ -36,7 +36,7 @@ //! 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 ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use crate::witness::{canonical_bytes, WitnessEvent}; @@ -58,6 +58,16 @@ pub fn sign_event(event: &WitnessEvent, key: &SigningKey) -> Signature { /// 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. +/// +/// Uses `verify_strict` (not the permissive `Verifier::verify`) on +/// purpose: for a tamper-evident *audit* chain the signature is the +/// attestation, so non-canonical encodings and small-order public +/// keys must be rejected. `verify_strict` enforces RFC 8032's +/// stricter checks, giving the "one canonical signature per event" +/// property an auditor relies on when comparing or deduplicating +/// signed witness records. The public key is caller-pinned (the +/// Seed's known verifying key) — never parsed from the event — so a +/// forged event carrying its own key cannot self-verify. pub fn verify_signature( event: &WitnessEvent, signature: &Signature, @@ -71,7 +81,7 @@ pub fn verify_signature( &event.payload, ); public_key - .verify(&bytes, signature) + .verify_strict(&bytes, signature) .map_err(|_| SignatureVerifyError::Invalid) } @@ -140,6 +150,58 @@ mod tests { verify_signature(&event, &sig, &public).expect("clean signature verifies"); } + #[test] + fn signature_commits_to_domain_tag_not_bare_fields() { + // The signature is over the domain-tagged canonical bytes. A + // signature produced over the *un-tagged* concatenation of the + // same fields must NOT verify — proving cross-protocol + // separation reaches the signature layer, not just the hash. + // Fails on the old encoding where the signed message began + // directly with `prev_hash` (no tag). + use ed25519_dalek::Signer; + let key = fixed_key(); + let public = key.verifying_key(); + let event = fresh_event(); + + // Hand-build the OLD (un-tagged) preimage and sign it. + let mut untagged = Vec::new(); + untagged.extend_from_slice(&event.prev_hash.0); + untagged.extend_from_slice(&event.seq.to_be_bytes()); + untagged.extend_from_slice(&event.timestamp_unix_s.to_be_bytes()); + untagged.extend_from_slice(&(event.kind.len() as u32).to_be_bytes()); + untagged.extend_from_slice(event.kind.as_bytes()); + untagged.extend_from_slice(&(event.payload.len() as u32).to_be_bytes()); + untagged.extend_from_slice(&event.payload); + let old_sig = key.sign(&untagged); + + // The current verifier (which uses the domain-tagged message) + // must reject a signature made over the un-tagged bytes. + let err = verify_signature(&event, &old_sig, &public).unwrap_err(); + assert_eq!(err, SignatureVerifyError::Invalid); + + // Sanity: the proper signature still verifies. + let good = sign_event(&event, &key); + verify_signature(&event, &good, &public).expect("tagged signature verifies"); + } + + #[test] + fn verify_uses_strict_path_and_pins_caller_key() { + // Regression guard: verification must run through the strict + // path against a CALLER-supplied key. A wrong key fails; the + // event never carries its own verifying key, so a forged event + // cannot self-attest. (verify_strict additionally rejects + // non-canonical / small-order encodings.) + let key = fixed_key(); + let wrong = SigningKey::from_bytes(b"another-wrong-key-another-wrong-"); + let event = fresh_event(); + let sig = sign_event(&event, &key); + verify_signature(&event, &sig, &key.verifying_key()).expect("right key verifies"); + assert_eq!( + verify_signature(&event, &sig, &wrong.verifying_key()).unwrap_err(), + SignatureVerifyError::Invalid + ); + } + #[test] fn verify_rejects_signature_under_wrong_key() { let key = fixed_key();