mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be4efecbcd | |||
| 3833929dcb | |||
| 1e469aa336 | |||
| d4f0e12073 | |||
| 07b792715f | |||
| 34eced880f | |||
| bb154d4e78 | |||
| 1f5b7b48c9 |
@@ -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
|
||||
@@ -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) | in progress — (a) mDNS service-record builder shipped. (b) Witness hash-chain primitive shipped (append-only SHA-256, `verify()` catches tampering). (c) **Witness JSONL persistence shipped** — `WitnessEvent::{to,from}_jsonl_line` round-trips with alphabetical field order for byte-stable archival hashes; parser re-verifies stored `this_hash` against canonical bytes so tampered bundles fire `HashMismatch` before loading. (d) Responder (mdns-sd) + embedded rumqttd + Ed25519 signing layer still 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
+95
@@ -934,6 +934,8 @@ name = "cog-ha-matter"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"mdns-sd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -1074,6 +1076,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
@@ -1367,6 +1375,33 @@ dependencies = [
|
||||
"libloading 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1428,6 +1463,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1643,6 +1679,30 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1773,6 +1833,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -5097,6 +5163,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -6996,6 +7072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.9.1"
|
||||
@@ -7154,6 +7239,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -35,9 +35,16 @@ wifi-densepose-sensing-server = { version = "0.3.0", path = "../wifi-densepose-s
|
||||
# Hardware crate for SyncPacket + NodeState bridging (ADR-110 substrate).
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Witness chain (ADR-116 P4): SHA-256 only for now; Ed25519 signing
|
||||
# layers on top once we ship the key-management story.
|
||||
# Witness chain (ADR-116 P4): SHA-256 hash chain + Ed25519 signature
|
||||
# layer for tamper-evident audit logs (ADR-116 §2.2). Same version
|
||||
# already vetted by ruv-neural — keep them aligned.
|
||||
sha2 = { workspace = true }
|
||||
ed25519-dalek = "2.1"
|
||||
|
||||
# mDNS responder (ADR-116 P4 §2.2): pure-Rust zero-conf daemon.
|
||||
# Same version pinned in wifi-densepose-desktop to keep the
|
||||
# workspace lockfile narrow.
|
||||
mdns-sd = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -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)-*
|
||||
@@ -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"
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub mod manifest;
|
||||
pub mod mdns;
|
||||
pub mod runtime;
|
||||
pub mod witness;
|
||||
pub mod witness_signing;
|
||||
|
||||
/// Cog identifier used in Seed's app-registry.json + the manifest.
|
||||
pub const COG_ID: &str = "ha-matter";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
//! are broadcast in cleartext and harvested by passive scanners, so
|
||||
//! treating them as PII-clean is part of the privacy posture.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
use crate::COG_ID;
|
||||
|
||||
/// Default mDNS instance name template. `{node_id}` is substituted
|
||||
@@ -74,6 +78,33 @@ impl MdnsService {
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Convert into the `mdns_sd::ServiceInfo` the responder daemon
|
||||
/// consumes. Pure transform — no socket binding, no daemon
|
||||
/// registration. The caller wires the resulting `ServiceInfo`
|
||||
/// into `ServiceDaemon::register` (next iter).
|
||||
///
|
||||
/// `hostname` should end in `.local.` per RFC 6762 — e.g.
|
||||
/// `"cognitum-seed-1.local."`. `ipv4` is the LAN-routable
|
||||
/// address HA's discovery will reach back on.
|
||||
pub fn to_service_info(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<ServiceInfo, mdns_sd::Error> {
|
||||
let mut props: HashMap<String, String> = HashMap::with_capacity(self.txt_records.len());
|
||||
for (k, v) in &self.txt_records {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
ServiceInfo::new(
|
||||
&self.service_type,
|
||||
&self.instance_name,
|
||||
hostname,
|
||||
ipv4,
|
||||
self.control_port,
|
||||
Some(props),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the cog's mDNS advertisement record from the cog's typed
|
||||
@@ -203,6 +234,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_carries_service_type_and_port() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// mdns-sd may rewrite the type with a trailing dot; allow
|
||||
// both forms.
|
||||
let ty = info.get_type();
|
||||
assert!(
|
||||
ty == "_ruview-ha._tcp" || ty == "_ruview-ha._tcp.",
|
||||
"unexpected service type: {ty}"
|
||||
);
|
||||
assert_eq!(info.get_port(), 9180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_propagates_txt_records() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// Every locked TXT key must reach the wire-format payload.
|
||||
assert_eq!(info.get_property_val_str("cog_id"), Some(crate::COG_ID));
|
||||
assert_eq!(info.get_property_val_str("mqtt_port"), Some("1883"));
|
||||
assert_eq!(info.get_property_val_str("privacy"), Some("1"));
|
||||
assert_eq!(info.get_property_val_str("proto"), Some("ruview-ha/1"));
|
||||
assert!(info.get_property_val_str("node_id").is_some());
|
||||
assert!(info.get_property_val_str("cog_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_does_not_silently_drop_caller_hostname() {
|
||||
// mdns-sd 0.11 accepts bare hostnames (no `.local.`); the
|
||||
// responsibility for the trailing dot lives in our wrapper.
|
||||
// Lock that the caller's hostname survives the conversion
|
||||
// verbatim — a future bump that starts mutating the value
|
||||
// surfaces a named test instead of a silent change.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.unwrap();
|
||||
assert!(info.get_hostname().contains("cognitum-seed-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_keys_match_locked_surface() {
|
||||
// The HA-side YAML auto-discovery binds on these exact keys.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
//! 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
|
||||
@@ -198,6 +200,49 @@ impl WitnessChain {
|
||||
&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.
|
||||
@@ -239,6 +284,23 @@ pub enum WitnessVerifyError {
|
||||
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}")]
|
||||
@@ -612,4 +674,122 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user