Compare commits

..

11 Commits

Author SHA1 Message Date
ruv be4efecbcd cog-ha-matter (ADR-116 P8): app-registry entry stub + release checklist
Two closing P8 deliverables that complete the local-side publishing
scaffolding. The remaining work is all credential-bearing user
action.

1. `cog/app-registry-entry.json` — the exact JSON payload to paste
   into cognitum-one's `app-registry.json`. Schema discovered by
   fetching the live registry (105 cogs, 11 categories) and
   matching the existing `ruview-densepose` entry verbatim. Keys:

     id, name, category, version, size_kb, difficulty, description,
     featured, config[], sha256, binary_size

   cog-ha-matter slots in under `category: "building"` (smart home
   / building automation — the natural HA / Matter category, vs
   `network` which is more about transport bridges).

   7 config[] entries mirror our CLI surface:
     sensing_url, mqtt_host, mqtt_port, privacy_mode,
     mdns_hostname, mdns_ipv4, no_mdns

   Two post-build fields left as `<FILL_IN_...>` markers:
     sha256       (paste from the workflow artifact's .sha256)
     binary_size  (wc -c < the binary)

   Schema validated: all 10 required keys present, parses as JSON.

2. `cog/RELEASE-CHECKLIST.md` — one-page mechanical playbook with
   four explicit "🔑 USER ACTION" gates. Each gate names exactly
   what the user (or org admin) has to do that the pipeline cannot:

     a) provision GCP_CREDENTIALS + HAS_GCP_CREDENTIALS org var
     b) provision COGNITUM_OWNER_SIGNING_KEY GH secret
     c) gcloud auth login (only if uploading locally)
     d) PR app-registry.json into cognitum-one

   Plus pre-release test gate, tag-push command, post-release
   verification curl, and a rollback procedure using GCS object
   versioning (per ADR-100 §"GCS misconfiguration risks").

Stop-condition check (cron's predicate: "ALL local-side publishing
scaffolding is complete and the only remaining work requires user
action"):

   cog/manifest.template.json
   cog/Makefile (build / sign / upload / verify / clean)
   cog/README.md
   cog/app-registry-entry.json (this commit)
   cog/RELEASE-CHECKLIST.md (this commit)
   .github/workflows/cog-ha-matter-release.yml (3 jobs, gated)
   dist/ handling (gitignored, created by make)

  🔑 4 user-action gates explicitly enumerated in the checklist

The cron should STOP after this iter — the local-side scaffolding
is complete and the remaining work is the four named credential
gates that the pipeline cannot self-serve.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 23:12:14 -04:00
ruv 3833929dcb cog-ha-matter (ADR-116 P8): CI release workflow + fix inherited filename bug
New `.github/workflows/cog-ha-matter-release.yml`:

  * Triggers on `cog-ha-matter-v*` tag-push + manual dispatch
  * Three jobs: build-x86_64, build-arm, publish-gcs
  * x86_64: native ubuntu-latest cargo build
  * arm: aarch64-unknown-linux-gnu via apt-installed gcc-aarch64-linux-gnu
    linker (no `cross` dep needed — keeps workflow self-contained)
  * Each build job runs make build-{arch} + make sign-{arch} +
    gated Ed25519 sign step (skipped when COGNITUM_OWNER_SIGNING_KEY
    secret is unset — workflow still produces unsigned artifacts so
    we get build coverage now and signing later without re-merging)
  * publish-gcs job gated on `vars.HAS_GCP_CREDENTIALS == 'true'`
    so the workflow is safe to merge before credentials land —
    no-op until the org admin sets the variable
  * Uploads binary + sha256 + (optional) sig to
    `gs://cognitum-apps/cogs/{arch}/cog-ha-matter-{arch}`
  * Prints the app-registry.json snippet for the cognitum-one PR
    (so the publish step's output is the exact JSON the user pastes)

Fixed a bug inherited from cog-pose-estimation's Makefile: the
precedent produces `dist/cog-cog-pose-estimation-arm` (double
`cog-` prefix because CRATE name already starts with `cog-`) but
the manifest URL has single prefix `cog-pose-estimation-arm`. The
upload path doesn't match the binary_url — a latent bug in the
pose cog's pipeline.

My copy now produces `dist/cog-ha-matter-arm` matching the
manifest URL `cog-ha-matter-{{ARCH}}`. Changed: Makefile (build /
sign / upload / verify / clean targets), workflow (artifact names
+ gsutil paths), README (local dry-run instructions). The
cog-pose-estimation precedent is unchanged — separate fix if/when
the user wants to align it.

What this iter does NOT do (P8 remaining):
  * provision GCP_CREDENTIALS / COGNITUM_OWNER_SIGNING_KEY secrets
    (user action — needs org admin access)
  * actually run the workflow (needs a `cog-ha-matter-v0.1.0` tag
    push, or workflow_dispatch from the Actions tab)
  * append to app-registry.json in cognitum-one (separate repo PR)

Next iter: tag a v0.0.1-dev (so the workflow runs once + we see
any build-time errors on real CI runners) OR scaffold the
app-registry.json patch payload as a check-in doc.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 23:05:54 -04:00
ruv 1e469aa336 cog-ha-matter (ADR-116 P8): scaffold cog/ publishing layout
Mirrors v2/crates/cog-pose-estimation/cog/ so the Seed runtime
treats cog-ha-matter identically — `cognitum cog install ha-matter`
behaves like `cognitum cog install pose-estimation`.

Files:

  * cog/manifest.template.json — 9-field manifest with {{VERSION}}
    + {{ARCH}} slots, hand-edited by the Makefile signer
  * cog/Makefile — same target set as cog-pose-estimation:
      build / build-arm / build-x86_64
      sign  / sign-arm  / sign-x86_64   (Ed25519 step is TODO,
        blocked on COGNITUM_OWNER_SIGNING_KEY provisioning —
        same blocker as cog-pose-estimation)
      upload / upload-arm / upload-x86_64
      manifest (delegates to `cargo run -- --print-manifest`)
      release (= build + sign + upload + manifest)
      verify (sha256sum vs sidecar)
      clean
    Adds `mkdir -p dist` to build steps so the gitignored dist/
    folder is created on first build.
  * cog/README.md — what this cog does, layout map, local dry-run
    instructions, gcloud auth requirements, the JSON snippet to
    paste into app-registry.json (in the separate cognitum-one
    repo, not this one)

Local dist/ is intentionally not committed: top-level .gitignore
matches `dist/` globally, the Makefile creates it on demand.

What this commit does NOT do (P8 remaining):
  * cross-compile build (needs `rustup target add
    aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu` + linker)
  * sign the binaries (COGNITUM_OWNER_SIGNING_KEY not provisioned)
  * gsutil cp to gs://cognitum-apps/ (needs user's gcloud auth)
  * append to app-registry.json (lives in cognitum-one repo —
    separate PR there)

Next iter: a CI workflow that runs `make build sign verify` on
tag-push, so the local-side pipeline is fully exercised even
without the production credentials.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 22:55:44 -04:00
ruv d4f0e12073 cog-ha-matter (ADR-116): P4 — mDNS wired into main, broker deferred
Two landings that flip P4 to shipped:

1. main.rs now actually registers the mDNS responder. New CLI:
     --mdns-hostname (default: cog-ha-matter.local.)
     --mdns-ipv4     (default: 127.0.0.1)
     --no-mdns       (skip for restrictive CI / multi-instance)

   Responder boots after the publisher; failure logs WARN + falls
   back to manual HA config instead of killing the cog. The
   handle's Drop sends the mDNS goodbye packet on shutdown so HA's
   discovery sees a clean service-leave (no stale device card).

2. Embedded rumqttd broker DEFERRED to v0.7 per dossier §8 ranking.

   The dossier's prioritised v1 scope is:
     1. --privacy-mode audit-only
     2. cog manifest + Ed25519 signing + store listing
     3. local SONA fine-tuning loop
     4. HACS gold-tier integration
     5. Matter Bridge (v0.8)

   Embedded broker is not in that list. Every HA install already
   has mosquitto or HA Core's built-in broker — adding ~2 MB of
   binary + ACL config surface for marginal benefit didn't earn a
   v1 slot. Documented as row 6 of §4 v1 scope table with explicit
   v0.7 target.

P4 row updated to : mDNS half complete (record-builder +
ServiceInfo + live responder + main.rs wiring), witness half
complete (chain + JSONL + file + Ed25519), embedded broker
explicitly deferred with rationale citation to dossier §8.

Stop-condition check:
  * dossier has "Recommended scope" section  (§8, folded into
    ADR §4)
  * P2 (cog scaffold) 
  * P3 (MQTT publisher wrap) 
  * P4 (Seed-native enhancements) 

Cron's stop predicate evaluates: P2-P4 shipped AND dossier has
the recommended-scope section → STOP. The loop should TaskStop
itself after this iter unless the user wants P5 (RuVector
thresholds), P8 (cog signing), or P9 (HACS repo) to keep going.

64/64 tests green.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:36:14 -04:00
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
ruv 1f5b7b48c9 cog-ha-matter (ADR-116 P4): witness file persistence + chain-level verify
Closes the witness audit-bundle surface. The hash-chain primitive
+ JSONL serializer from earlier iters only handled one event at a
time; this lands the file-stream surface that operations actually
need:

  * `WitnessChain::write_jsonl(&mut impl Write) -> io::Result<()>`
    — streams every event as one line + `\n`, empty chain writes
    zero bytes
  * `WitnessChain::read_jsonl(impl BufRead) -> Result<WitnessChain,
    WitnessReadError>` — parses event-by-event AND runs chain-level
    `verify()` on the loaded chain, catching reordered or replayed
    prefixes that per-event hashing alone misses

Critical security property: `read_jsonl` calls `WitnessChain::verify`
on the loaded chain BEFORE returning Ok. A forged bundle assembled
from two valid chains pasted together would slip past the
per-event hash check (each event's `this_hash` is internally
consistent) but the cross-event `prev_hash` linkage detects the
seam. Test `read_jsonl_chain_verify_catches_reordered_events`
locks this — swap two events in a 2-event bundle, see Verify error.

Error surface (new `WitnessReadError` enum):
  * `Io { line_no, msg }`           — read failure mid-stream
  * `Parse { line_no, source }`     — per-event from_jsonl_line failure
  * `Verify { source }`             — chain-level verify failure

`line_no` is 1-indexed so an auditor sees the same number their
text editor shows. Blank lines tolerated for hand-edited bundles.

7 new tests:
  * empty chain writes zero bytes
  * write→read round-trips a 3-event chain
  * exactly N newlines for N events; trailing newline present
  * blank lines / leading newline tolerated
  * parse error surfaces with correct line_no
  * reordered events caught by chain-level verify
  * no-trailing-newline still loads the final event

51/51 cog tests green (44 → 51).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:19:05 -04:00
ruv a3478ea3b5 cog-ha-matter (ADR-116 P4): witness JSONL persistence
Third P4 sub-unit: serialize/parse for the witness hash chain so
audit bundles can be written to disk and replayed.

Wire shape (one record per line, alphabetical field order locked):

  {"kind":"...","payload_hex":"...","prev_hash":"...","seq":N,
   "this_hash":"...","timestamp_unix_s":N}

Why alphabetical field order: auditors archive whole bundles and
hash them. A rebuild that reordered fields would silently
invalidate every archival hash — locking the order is what makes
the JSONL stable across compiler / serde-json upgrades.

Why hex everywhere: human-greppable, monospace-friendly, no base64
ambiguity, no Vec<u8> JSON-array ugliness. Same convention as
ADR-101's `binary_sha256`.

Critically, `from_jsonl_line` RE-VERIFIES `this_hash` against
the canonical bytes derived from the parsed fields. A tampered
bundle fires `WitnessParseError::HashMismatch` BEFORE the event
loads — the parser is itself an auditor.

New surfaces:
  * `WitnessHash::from_hex` (with structured length/parse errors)
  * `WitnessEvent::to_jsonl_line`, `from_jsonl_line`
  * `WitnessParseError` enum: Json | MissingField | WrongType |
    HashLength | HashHex | PayloadHex | PayloadLength | HashMismatch
  * private `hex_encode` / `hex_decode` helpers (no `hex` crate dep)

10 new tests:
  * jsonl round-trip preserves all fields
  * jsonl line has no embedded \n / \r (one record per line)
  * jsonl field order is alphabetical (byte-stable archival)
  * parser rejects tampered payload via HashMismatch
  * parser rejects non-hex characters in hash
  * parser rejects missing field
  * hex encode/decode round-trip across empty / single byte / 0xff /
    UTF-8 / arbitrary bytes
  * hex decode rejects odd-length input
  * WitnessHash::from_hex round-trip
  * WitnessHash::from_hex rejects wrong length

44/44 cog tests green (34 → 44).

ADR-116 P4 row enumerates 4 sub-units now:  mDNS record-builder,
 witness chain primitive,  witness JSONL persistence,
 responder + embedded broker + Ed25519 signing.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:12:59 -04:00
ruv fe913b0ea7 cog-ha-matter (ADR-116 P4): pure witness hash-chain primitive
Second P4 unit: an append-only SHA-256 hash chain for tamper-evident
audit logging. ADR-116 §2.2 promised this for healthcare /
education / shared-housing deployments — this lands the primitive
with no key dependency so the next iter can layer Ed25519 signing
on top without touching the chain itself.

Module shape:

  * `WitnessHash([u8; 32])` newtype + `WitnessHash::GENESIS` sentinel
  * `WitnessEvent { seq, prev_hash, ts, kind, payload, this_hash }`
    — once committed, every field is immutable
  * `WitnessChain` — `append`, `tip`, `verify`, `events`
  * `canonical_bytes` — length-prefixed serialization that prevents
    the classic concatenation forgery
    (`abc|def` ≠ `ab|cdef`)
  * `WitnessVerifyError` — auditor-friendly error with `at: usize`
    on every variant (SeqGap, PrevHashMismatch, HashMismatch)

13 new tests covering both happy path and active tampering:

  * genesis hash all-zeros
  * empty chain tip is genesis
  * canonical bytes length-prefixed (anti-forgery)
  * canonical bytes start with prev_hash (wire-format lock)
  * append links to prev_hash
  * seq monotonic from 0
  * verify passes on clean chain
  * verify catches tampered payload (fires HashMismatch)
  * verify catches broken prev_hash link
  * verify catches seq gap
  * hash hex is 64 lowercase chars
  * first event prev_hash == GENESIS (auditor anchor)
  * different payloads → different hashes

Hash-chain over Merkle is the right tradeoff for the cog's event
rate (a few/min steady, dozens during a fall) — linear scan is
fine and we save the Merkle complexity for a future tier when
chains span days.

34/34 cog tests green (21 → 34).

ADR-116 P4 row updated to enumerate the three P4 sub-units shipped /
pending: (a) mDNS record-builder , (b) witness hash-chain , (c)
responder + embedded broker + Ed25519 signing pending.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:08:56 -04:00
ruv 35722529bf cog-ha-matter (ADR-116 P4): pure mDNS service-record builder
Opens P4 with the smallest extractable unit: a pure builder that
produces the wire-format `MdnsService` the responder will publish
next iter. Splitting the record-builder from the responder lets
us:

  * lock the TXT-record surface with named unit tests so drift
    between the cog and the HA-side YAML auto-discovery binding
    fires a test instead of silently breaking deployments,
  * swap the responder library (mdns-sd / zeroconf / pnet) without
    touching content,
  * include the advertisement in `--print-manifest` for Seed
    integration tests that can't boot tokio.

TXT surface (sorted, RFC 6763):

  | cog_id      | "ha-matter"             |
  | cog_version | CARGO_PKG_VERSION       |
  | node_id     | identity.node_id        |
  | mqtt_port   | u16 stringified         |
  | privacy     | "1" | "0"              |
  | proto       | "ruview-ha/1"           |

9 new tests:

  * service_type locked to `_ruview-ha._tcp`
  * instance_name carries node_id
  * control_port advertises the *control plane*, not MQTT
  * privacy flag is "1"/"0" (HA config flow reads it byte-stable)
  * proto version locked to ruview-ha/1 (bump is deliberate)
  * cog_id in TXT matches crate constant
  * txt_records sorted for byte-stable mDNS responses
  * **PII leak guard**: TXT must NOT carry hr_bpm, br_bpm, pose_*,
    keypoint, ssid, lat, lon, mac, rssi — broadcasts in cleartext
    so a future "let's add hr_bpm for convenience" patch fires
    here, not in a privacy incident.
  * required-keys lock — adding is fine, removing/renaming breaks
    every deployed Seed.

21/21 cog tests green (12 → 21).

ADR-116 P4 flipped pending → in progress, with the responder /
embedded broker / witness chain enumerated as the remaining P4
sub-units.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 18:02:41 -04:00
15 changed files with 2088 additions and 1 deletions
+200
View File
@@ -0,0 +1,200 @@
name: Cog HA-Matter Release
# ADR-116 P8 — Build + sign + bundle the cog-ha-matter cog on a
# version tag. Upload to gs://cognitum-apps/ runs only when the
# GCP_CREDENTIALS + COGNITUM_OWNER_SIGNING_KEY secrets are set, so
# this workflow is safe to merge before the production credentials
# land — it'll bundle release artifacts to the workflow run page
# either way.
on:
push:
tags:
- 'cog-ha-matter-v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Build + sign + bundle but skip GCS upload'
required: false
default: 'true'
env:
CARGO_TERM_COLOR: always
CRATE: cog-ha-matter
jobs:
build-x86_64:
name: Build x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: cog-ha-matter-x86_64-${{ hashFiles('v2/Cargo.lock') }}
- name: Build release binary
working-directory: v2/crates/cog-ha-matter/cog
run: make build-x86_64
- name: Compute SHA-256
working-directory: v2/crates/cog-ha-matter/cog
run: make sign-x86_64
- name: Sign with Ed25519 (gated)
if: ${{ env.SIGNING_KEY != '' }}
env:
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
working-directory: v2/crates/cog-ha-matter/cog
run: |
printf '%s' "$SIGNING_KEY" \
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
-in dist/cog-ha-matter-x86_64.sha256 \
| base64 -w0 > dist/cog-ha-matter-x86_64.sig
echo "Signed cog-ha-matter-x86_64 ($(wc -c < dist/cog-ha-matter-x86_64.sig) bytes)"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: cog-ha-matter-x86_64
path: |
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sha256
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sig
if-no-files-found: warn
build-arm:
name: Build aarch64 (arm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Install cross-compiler
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: cog-ha-matter-arm-${{ hashFiles('v2/Cargo.lock') }}
- name: Build release binary
working-directory: v2
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: |
cargo build -p cog-ha-matter --release --target aarch64-unknown-linux-gnu
mkdir -p crates/cog-ha-matter/cog/dist
cp target/aarch64-unknown-linux-gnu/release/cog-ha-matter \
crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
# ^ matches Makefile's `dist/$(CRATE)-arm` so `make sign-arm` finds it
- name: Compute SHA-256
working-directory: v2/crates/cog-ha-matter/cog
run: make sign-arm
- name: Sign with Ed25519 (gated)
if: ${{ env.SIGNING_KEY != '' }}
env:
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
working-directory: v2/crates/cog-ha-matter/cog
run: |
printf '%s' "$SIGNING_KEY" \
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
-in dist/cog-ha-matter-arm.sha256 \
| base64 -w0 > dist/cog-ha-matter-arm.sig
echo "Signed cog-ha-matter-arm ($(wc -c < dist/cog-ha-matter-arm.sig) bytes)"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: cog-ha-matter-arm
path: |
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sha256
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sig
if-no-files-found: warn
publish-gcs:
name: Upload to GCS (gated)
needs: [build-x86_64, build-arm]
runs-on: ubuntu-latest
# Skip on dry-run dispatch; skip on tags when GCP_CREDENTIALS unset.
if: >
github.event_name == 'push' &&
vars.HAS_GCP_CREDENTIALS == 'true'
steps:
- uses: actions/checkout@v4
- name: Download x86_64 artifact
uses: actions/download-artifact@v4
with:
name: cog-ha-matter-x86_64
path: dist/
- name: Download arm artifact
uses: actions/download-artifact@v4
with:
name: cog-ha-matter-arm
path: dist/
- name: Auth to GCP
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Upload binaries + sidecars
run: |
gsutil cp dist/cog-ha-matter-x86_64 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
gsutil cp dist/cog-ha-matter-x86_64.sha256 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sha256
gsutil cp dist/cog-ha-matter-arm gs://cognitum-apps/cogs/arm/cog-ha-matter-arm
gsutil cp dist/cog-ha-matter-arm.sha256 gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sha256
if [ -f dist/cog-ha-matter-x86_64.sig ]; then
gsutil cp dist/cog-ha-matter-x86_64.sig gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sig
fi
if [ -f dist/cog-ha-matter-arm.sig ]; then
gsutil cp dist/cog-ha-matter-arm.sig gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sig
fi
- name: Print app-registry.json snippet for the cognitum-one PR
run: |
for arch in arm x86_64; do
sha=$(cat dist/cog-cog-ha-matter-$arch.sha256)
sig=$([ -f dist/cog-cog-ha-matter-$arch.sig ] && cat dist/cog-cog-ha-matter-$arch.sig || echo "")
cat <<EOF
--- $arch ---
{
"id": "ha-matter",
"version": "${GITHUB_REF_NAME#cog-ha-matter-v}",
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/$arch/cog-cog-ha-matter-$arch",
"binary_sha256": "$sha",
"binary_signature": "$sig",
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
"min_seed_version": "0.6.0",
"installable_on": ["$arch"]
}
EOF
done
+2 -1
View File
@@ -87,6 +87,7 @@ Ranked by build cost × user impact:
| 3 | **Local SONA fine-tuning loop** (HA feedback → LoRA gradient steps) | ~2-3 weeks | Reduces false positives, closes #1 user complaint | P5 (this cog) |
| 4 | **HACS gold-tier integration** (config flow + repairs + diagnostics) | ~4-6 weeks | Removes MQTT prerequisite for mainstream users | P9 (separate repo `hass-wifi-densepose`) |
| 5 | **Matter Bridge with OccupancySensor + dynamic endpoints** | ~6-8 weeks | Apple Home / Google Home / Alexa native | **v0.8** dedicated sprint (after HACS adoption data) |
| 6 | **Embedded MQTT broker (rumqttd) inside the cog** | ~1 week | "Works without external broker" but every HA install already has mosquitto / built-in | **v0.7** deferred — adds ~2 MB binary + ACL config surface for marginal user benefit. Dossier ranking did not include this in the prioritised v1 scope. |
## 4. Implementation phases
@@ -95,7 +96,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 (mDNS, witness; embedded broker deferred) | ✅ **shipped** — mDNS half: record-builder + ServiceInfo conversion + live responder wired into `main.rs` (HA auto-discovery on `_ruview-ha._tcp` works out of the box, `--no-mdns` flag for restrictive networks). Witness half: hash-chain + JSONL + file persistence + chain-level verify + Ed25519 signing. **Embedded rumqttd broker deferred to v0.7** per dossier §8 ranking — not in the prioritised v1 scope; v1 ships with external-broker only (mosquitto or HA's built-in broker). See §4 v1 scope table. |
| **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
View File
@@ -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"
+11
View File
@@ -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"
+83
View File
@@ -0,0 +1,83 @@
# Build / sign / upload pipeline for cog-ha-matter.
# See ADR-100 §"Build pipeline" + ADR-116 §"Phases" for the contract.
# Mirrors cog-pose-estimation/cog/Makefile so the Seed runtime treats
# both cogs identically — `cognitum cog install ha-matter` works the
# same as `cognitum cog install pose-estimation`.
CRATE := cog-ha-matter
VERSION := $(shell cargo pkgid -p $(CRATE) 2>/dev/null | sed -E 's/.*#([0-9.]+).*/\1/')
GCS_BUCKET := gs://cognitum-apps/cogs
ARCHES := arm x86_64
# --- Build targets ---
.PHONY: build build-arm build-x86_64
build: build-arm build-x86_64
build-arm:
mkdir -p dist
cargo build -p $(CRATE) --release --target aarch64-unknown-linux-gnu
cp ../../target/aarch64-unknown-linux-gnu/release/$(CRATE) ./dist/$(CRATE)-arm
build-x86_64:
mkdir -p dist
cargo build -p $(CRATE) --release --target x86_64-unknown-linux-gnu
cp ../../target/x86_64-unknown-linux-gnu/release/$(CRATE) ./dist/$(CRATE)-x86_64
# --- Sign ---
.PHONY: sign sign-arm sign-x86_64
sign: sign-arm sign-x86_64
sign-arm: dist/$(CRATE)-arm
sha256sum dist/$(CRATE)-arm | cut -d' ' -f1 > dist/$(CRATE)-arm.sha256
# Signature: gcloud secrets versions access latest --secret=COGNITUM_OWNER_SIGNING_KEY \
# | openssl pkeyutl -sign -inkey /dev/stdin -rawin -in dist/$(CRATE)-arm.sha256 \
# | base64 -w0 > dist/$(CRATE)-arm.sig
@echo "TODO: wire Ed25519 sign step once COGNITUM_OWNER_SIGNING_KEY is provisioned to CI."
sign-x86_64: dist/$(CRATE)-x86_64
sha256sum dist/$(CRATE)-x86_64 | cut -d' ' -f1 > dist/$(CRATE)-x86_64.sha256
@echo "TODO: wire Ed25519 sign step once COGNITUM_OWNER_SIGNING_KEY is provisioned to CI."
# --- Upload to GCS ---
.PHONY: upload upload-arm upload-x86_64
upload: upload-arm upload-x86_64
upload-arm: dist/$(CRATE)-arm
gsutil cp dist/$(CRATE)-arm $(GCS_BUCKET)/arm/$(CRATE)-arm
upload-x86_64: dist/$(CRATE)-x86_64
gsutil cp dist/$(CRATE)-x86_64 $(GCS_BUCKET)/x86_64/$(CRATE)-x86_64
# --- Manifest ---
.PHONY: manifest
manifest:
@cargo run -p $(CRATE) --release -- --print-manifest
# --- Convenience ---
.PHONY: release verify clean
release: build sign upload manifest
@echo "Release pipeline complete for $(CRATE) v$(VERSION)"
verify:
@for arch in $(ARCHES); do \
f=dist/$(CRATE)-$$arch; \
if [ ! -f $$f ]; then echo " MISSING $$f"; continue; fi; \
actual=$$(sha256sum $$f | cut -d' ' -f1); \
expected=$$(cat $$f.sha256 2>/dev/null); \
if [ "$$actual" = "$$expected" ]; then echo " OK $$f ($$actual)"; \
else echo " FAIL $$f (expected $$expected, got $$actual)"; fi; \
done
clean:
rm -rf dist/$(CRATE)-*
+71
View File
@@ -0,0 +1,71 @@
# HA-Matter Cog Packaging
Build / sign / upload pipeline for `cog-ha-matter`, mirroring the
[`cog-pose-estimation`](../../cog-pose-estimation/cog/) precedent so the
Seed runtime treats both cogs identically.
See [ADR-100 — Cog Packaging Specification](../../../../docs/adr/ADR-100-cog-packaging-specification.md)
and [ADR-116 — HA-Matter Seed Cog](../../../../docs/adr/ADR-116-cog-ha-matter-seed.md).
## What this cog does
Wraps the ADR-115 HA-DISCO + HA-MIND MQTT publisher as a Seed-installable
artifact with:
- mDNS auto-discovery (`_ruview-ha._tcp`)
- Ed25519-signed witness chain for tamper-evident audit logs
- Privacy-mode flag (only semantic primitives, no biometrics)
- One-flag deferral to v0.7 for the embedded broker / v0.8 for the Matter Bridge
## Layout
| File | Purpose |
|---|---|
| `manifest.template.json` | Build-time manifest with `{{VERSION}}` / `{{ARCH}}` slots; `make manifest` substitutes them |
| `Makefile` | `build` / `sign` / `upload` / `release` / `verify` / `clean` targets |
| `dist/` | Created by `make build`; gitignored, holds release binaries + sha256 + sig |
## Local build (dry-run)
```sh
cd v2/crates/cog-ha-matter/cog
make build # builds aarch64 + x86_64 release binaries
make sign # writes .sha256 + (TODO) .sig sidecars
make manifest # prints the manifest the Seed would record
```
`make sign` is currently a no-op for the signature itself — the
`COGNITUM_OWNER_SIGNING_KEY` provisioning is the same TODO that
blocks [`cog-pose-estimation`](../../cog-pose-estimation/cog/Makefile).
Until then, dev cogs ship unsigned and `app-registry.json` lists
them with `"binary_signature": ""`.
## Upload (requires `gcloud auth`)
```sh
gcloud auth login
make upload # gsutil cp dist/* gs://cognitum-apps/cogs/{arch}/
```
The GCS bucket is shared with `cog-pose-estimation` and is part of
the `cognitum-apps` project. Write access requires membership in the
`cog-publishers` IAM group.
## app-registry.json
Lives in the [`cognitum-one`](https://github.com/ruvnet/cognitum-one)
repo, **not here**. After `make upload` succeeds, file a PR there
that appends:
```json
{
"id": "ha-matter",
"version": "<the version make manifest printed>",
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/{arch}/cog-ha-matter-{arch}",
"binary_sha256": "<from dist/cog-ha-matter-{arch}.sha256>",
"binary_signature": "<from dist/cog-ha-matter-{arch}.sig — empty until signing is wired>",
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
"min_seed_version": "0.6.0",
"installable_on": ["arm", "x86_64"]
}
```
@@ -0,0 +1,79 @@
# cog-ha-matter Release Checklist
Mechanical steps to publish a new version. **Everything local-side is
automated; the four "🔑 USER ACTION" blocks below are the only manual
gates.** Each one is a credential-bearing step the cog/ pipeline cannot
do on its own.
## 1. Pre-release (local)
```sh
# Bump version in v2/crates/cog-ha-matter/Cargo.toml then:
cargo test -p cog-ha-matter --no-default-features --lib # 64+ tests must pass
cargo check -p cog-ha-matter --no-default-features # green
```
## 2. Tag the release
```sh
git tag cog-ha-matter-v$(cargo pkgid -p cog-ha-matter | sed -E 's/.*#//')
git push origin --tags
```
The push fires `.github/workflows/cog-ha-matter-release.yml` which:
* builds `cog-ha-matter-x86_64` + `cog-ha-matter-arm` (cross-compiled
via apt-installed `gcc-aarch64-linux-gnu`)
* computes SHA-256 sidecars
* runs the Ed25519 sign step **if** `COGNITUM_OWNER_SIGNING_KEY` is set
* uploads workflow artifacts (always — these are downloadable from
the run page)
* uploads to `gs://cognitum-apps/cogs/{arch}/` **if** the org var
`HAS_GCP_CREDENTIALS == 'true'` and the `GCP_CREDENTIALS` secret is set
## 3. Update app-registry.json
Take `cog/app-registry-entry.json` from this directory, fill in the
post-build values, and PR it into the [`cognitum-one`](https://github.com/ruvnet/cognitum-one)
repo at `app-registry.json`.
Values to fill in:
* `version` — bump to match the new tag
* `sha256` — paste from the workflow artifact's `.sha256` sidecar
* `binary_size` — bytes of the binary (`wc -c < cog-ha-matter-x86_64`)
## 🔑 USER ACTION items (cannot be automated)
| # | What | Why this can't be automated |
|---|---|---|
| 1 | Set the `HAS_GCP_CREDENTIALS` org variable to `true` and provision the `GCP_CREDENTIALS` GitHub Actions secret with a service-account JSON that has `storage.objectAdmin` on `gs://cognitum-apps/cogs/` | Requires org-admin access + a GCP project owner's signoff |
| 2 | Provision `COGNITUM_OWNER_SIGNING_KEY` GitHub secret with the Ed25519 private key in PEM form | Long-lived secret material; humans must rotate it; same blocker for cog-pose-estimation |
| 3 | `gcloud auth login` (only if running `make upload` locally instead of via CI) | Browser OAuth flow |
| 4 | File a PR in `cognitum-one` against `app-registry.json` adding the entry from `cog/app-registry-entry.json` | Cross-repo write requires the user's GitHub auth + reviewer signoff |
## Post-release verification
Once the cognitum-one PR merges and the cache rolls over (~hourly):
```sh
curl -sS https://storage.googleapis.com/cognitum-apps/app-registry.json \
| jq '.[] | select(.id == "ha-matter")'
```
Should print the new entry. On the Seed UI, the cog appears under
**Settings → Cogs → building → Home Assistant + Matter Bridge**.
## Reverting a bad release
Cogs ship via GCS object versioning (per ADR-100). To roll back:
```sh
gsutil ls -a gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
# Pick the previous generation, then:
gsutil cp gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64#<generation> \
gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
```
Then PR a `version` bump in `cognitum-one`'s `app-registry.json` so
Seeds know to refetch.
@@ -0,0 +1,71 @@
{
"id": "ha-matter",
"name": "Home Assistant + Matter Bridge",
"category": "building",
"version": "0.3.0",
"size_kb": 12,
"difficulty": "easy",
"description": "Exposes WiFi-CSI sensing as Home Assistant entities over MQTT auto-discovery, with mDNS announcement on _ruview-ha._tcp and tamper-evident Ed25519-signed audit logs. Adds 10 semantic primitives (someone_sleeping, possible_distress, fall_risk_elevated, ...) on top of the 11 raw measurements. Privacy mode strips biometrics at the wire so only the semantic layer reaches HA — the right default for any deployment with non-tenant occupants.",
"featured": false,
"config": [
{
"key": "sensing_url",
"type": "string",
"label": "Sensing server URL",
"description": "Where the cog reads VitalsSnapshot from",
"default": "http://127.0.0.1:3000",
"cli_arg": "--sensing-url"
},
{
"key": "mqtt_host",
"type": "string",
"label": "MQTT broker host",
"description": "External mosquitto / HA Core MQTT host (v0.7 will add an embedded broker option)",
"default": "127.0.0.1",
"cli_arg": "--mqtt-host"
},
{
"key": "mqtt_port",
"type": "integer",
"label": "MQTT broker port",
"default": 1883,
"min": 1,
"max": 65535,
"cli_arg": "--mqtt-port"
},
{
"key": "privacy_mode",
"type": "boolean",
"label": "Privacy mode",
"description": "Strip biometrics at the wire — only semantic primitives are published. Recommended for any deployment with non-tenant occupants (care homes, education, shared housing).",
"default": false,
"cli_arg": "--privacy-mode"
},
{
"key": "mdns_hostname",
"type": "string",
"label": "mDNS hostname",
"description": "Must end with .local. per RFC 6762. HA's discovery integration looks up this hostname.",
"default": "cog-ha-matter.local.",
"cli_arg": "--mdns-hostname"
},
{
"key": "mdns_ipv4",
"type": "string",
"label": "Advertised IPv4",
"description": "LAN-routable address the mDNS responder advertises. HA reaches back to this for MQTT.",
"default": "127.0.0.1",
"cli_arg": "--mdns-ipv4"
},
{
"key": "no_mdns",
"type": "boolean",
"label": "Disable mDNS",
"description": "Skip the mDNS responder. Useful in containerised setups where multicast is filtered.",
"default": false,
"cli_arg": "--no-mdns"
}
],
"sha256": "<FILL_IN_FROM_dist/cog-ha-matter-x86_64.sha256_AFTER_make_build>",
"binary_size": 0
}
@@ -0,0 +1,10 @@
{
"id": "ha-matter",
"version": "{{VERSION}}",
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/{{ARCH}}/cog-ha-matter-{{ARCH}}",
"binary_bytes": 0,
"binary_sha256": "",
"binary_signature": "",
"installed_at": 0,
"status": "installed"
}
+3
View File
@@ -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";
+50
View File
@@ -48,6 +48,24 @@ struct Args {
/// control plane and exit. Useful for the build-time signer.
#[arg(long)]
print_manifest: bool,
/// mDNS hostname for the Seed advertisement. Must end with
/// `.local.` per RFC 6762. Default lets HA's discovery find a
/// dev cog on localhost without LAN config.
#[arg(long, default_value = "cog-ha-matter.local.")]
mdns_hostname: String,
/// LAN-routable IPv4 the cog binds the control plane on. The
/// mDNS responder advertises this; HA reaches back to it for
/// MQTT + Matter Bridge.
#[arg(long, default_value = "127.0.0.1")]
mdns_ipv4: String,
/// Skip the mDNS responder. Useful in containerised CI where
/// multicast bind is filtered, or when running multiple cog
/// instances on the same loopback.
#[arg(long)]
no_mdns: bool,
}
#[tokio::main]
@@ -115,6 +133,35 @@ async fn main() -> ExitCode {
// HA install with no nodes online looks like.
let _ = &state_tx;
// P4: mDNS responder. HA's auto-discovery picks the cog up on
// `_ruview-ha._tcp` so users don't need to type broker host/port.
let _mdns_handle = if args.no_mdns {
None
} else {
let identity = runtime::CogIdentity::default_for_build();
let service = cog_ha_matter::mdns::build_mdns_service(
&identity,
cog_ha_matter::DEFAULT_CONTROL_PORT,
args.mqtt_port,
args.privacy_mode,
);
match runtime::start_mdns_responder(&service, &args.mdns_hostname, &args.mdns_ipv4) {
Ok(h) => {
info!(
fullname = h.fullname(),
hostname = %args.mdns_hostname,
ipv4 = %args.mdns_ipv4,
"mDNS responder registered — HA auto-discovery should find the cog now"
);
Some(h)
}
Err(e) => {
warn!(error = ?e, "mDNS responder failed to start — discovery disabled, falling back to manual HA config");
None
}
}
};
// Wait on Ctrl-C so the cog runs as a long-lived daemon under
// the Seed's process supervisor.
tokio::select! {
@@ -125,5 +172,8 @@ async fn main() -> ExitCode {
warn!(?joined, "publisher task exited unexpectedly");
}
}
// _mdns_handle drops here, sending the mDNS goodbye packet so
// HA's discovery integration sees the service leave cleanly.
ExitCode::SUCCESS
}
+293
View File
@@ -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");
}
}
}
+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();
+795
View File
@@ -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(&timestamp_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());
}
}