diff --git a/docs/TODO-unified.md b/docs/TODO-unified.md index 6143c7e57..22911a13f 100644 --- a/docs/TODO-unified.md +++ b/docs/TODO-unified.md @@ -137,6 +137,7 @@ The Trust residential runs the **prod profile**, not the dev profile with dev mo - `CORS_ALLOWED_ORIGINS`: **required on a LAN host.** Set it to the Trust box's own origin, for example `http://:3001`; the compose default names the owner's public domain. - Security profile: a production artefact may not bind with findings. Set `VISIONCLAW_SECURITY_PROFILE` (`src/config/security_profile.rs:596`) and the six flags that profile fixes (`:133-138`): `RBAC_PUBLIC_READS`, `RBAC_ALLOW_OWNERLESS`, `RBAC_OWNER_PUBKEY`, `RBAC_DEFAULT_ROLE`, `PUBKEY_VISIBILITY_FILTER=1` and `RBAC_GATE_MODE=enforce`. `single-tenant` or `multi-user-locked` both require `RBAC_OWNER_PUBKEY`. - Optional, and empty by default: `VISIONCLAW_NOSTR_PRIVKEY` (bead provenance, `:234`). The binary also warns without `JWT_SECRET` and `CORS_ALLOWED_ORIGINS` (`src/main.rs:70`). +- Governance signing key (K_broker): **the Trust box mints its own and never copies one.** After the first start, run `visionclaw-server mint-nostr-key --out /app/data/keys/k_broker.key` inside the prod container (`/app/visionclaw-server`, on the `visionclaw-data` volume, under `umask 077`), and set `ACSP_PANEL_NOSTR_KEY_FILE` to that path in `.env.prod`. The command writes the secret at 0600, refuses to overwrite, and prints only the pubkey and `did:nostr`. The loader refuses a key file that group or other can read, and a key file beats any inline `ACSP_PANEL_NOSTR_PRIVKEY`/`VISIONCLAW_NOSTR_PRIVKEY` (`src/services/acsp/key_file.rs`). Register the printed pubkey in the Trust relay's `agent_registry`. Never paste a key from the owner's estate or from agentbox: those events would be signed by somebody else's identity (G-5, W8v). - The headset's `XR_NOSTR_SECRET` must belong to an Owner or Admin key. HUD physics writes need `WriteSettings` (`src/middleware/rbac_gate.rs:169`), and an `editor` resolves only to `Authenticated` (`src/models/rbac.rs:87`). **Forbidden.** `.env.prod` must not define `SETTINGS_AUTH_BYPASS`, `ALLOW_INSECURE_DEFAULTS`, `VISIONCLAW_DEV_MODE` or `DEV_AUTH_LOOPBACK`, even as `0`, in either ingress mode (`scripts/launch.sh:195`, host ADR-2119). The release binary refuses the same four (`src/config/security_profile.rs:59-64`). So the ADR-2039/2108 dev bypass cannot exist on Trust hardware, and headset writes must carry a NIP-98 signature (owner decision 2026-10-02, Q3). diff --git a/docs/adr/ADR-2004-oxigraph-sqlite-persistence.md b/docs/adr/ADR-2004-oxigraph-sqlite-persistence.md index de1d7289a..e7b53f235 100644 --- a/docs/adr/ADR-2004-oxigraph-sqlite-persistence.md +++ b/docs/adr/ADR-2004-oxigraph-sqlite-persistence.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: fdcbc9120fda25fd93fdaee744d68d2c00713d6b +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [Cargo.toml, src/app_state.rs] owner: jjohare review_trigger: a scale requirement that exceeds a single-node embedded store, or any proposal to reintroduce a networked graph database @@ -201,3 +201,7 @@ sit beside is unchanged. `verified_commit` moved to the CI-repair commit. ## Re-verification — 2026-10-03 at fdcbc9120fda25fd93fdaee744d68d2c00713d6b `1d3e14a30` and `fdcbc9120` change `Cargo.toml`: solid-pod-rs and its siblings move to `=0.5.0-alpha.12` with feature `mrc20`, and `nostr-bbs-core` is patched to nostr-rust-forum `b73ec8c` (ADR-2111, S4 amendment). No oxigraph, rusqlite or persistence feature changed, and `src/app_state.rs` is untouched. The decision holds unchanged. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`1e55daebb` drops the `[patch.crates-io]` git override for `nostr-bbs-core` from `Cargo.toml` in favour of the crates.io `=1.0.0-beta.13` pin. That changes no storage dependency. In `780eb3edb`, `src/app_state.rs` changes only the decision-projection client's key lookup (`:1362-1368`, now `load_panel_secret`). Oxigraph and the per-writer SQLite wiring are untouched. The decision holds. diff --git a/docs/adr/ADR-2005-hexagonal-crate-split.md b/docs/adr/ADR-2005-hexagonal-crate-split.md index 9b5d83612..8ecacf43e 100644 --- a/docs/adr/ADR-2005-hexagonal-crate-split.md +++ b/docs/adr/ADR-2005-hexagonal-crate-split.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: fdcbc9120fda25fd93fdaee744d68d2c00713d6b +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [Cargo.toml, src/actors, crates/visionclaw-actors/src] owner: jjohare review_trigger: completion of the actor extraction into crates/visionclaw-actors, or a new subsystem that does not map to an existing crate layer @@ -220,3 +220,7 @@ the root binary are unchanged. `verified_commit` moved to the CI-repair commit. ## Re-verification — 2026-10-03 at fdcbc9120fda25fd93fdaee744d68d2c00713d6b `1d3e14a30` and `fdcbc9120` change `Cargo.toml` (the solid-pod-rs pin, feature `mrc20`, a `[patch.crates-io]` for `nostr-bbs-core`; ADR-2111, S4 amendment). Workspace members are unchanged, nothing moves between crates, and `src/actors` and `crates/visionclaw-actors/src` are untouched. The decision holds unchanged. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`1e55daebb` (`Cargo.toml`: the `nostr-bbs-core` git patch is removed and the crates.io pin used) leaves `[workspace].members` unchanged. In `780eb3edb`, `src/actors/elevation_actor.rs` and `decision_elevation_actor.rs` change only their panel-key lookup. The new loader is in the root `src/services/acsp/`, beside the ACSP client it serves. That adds nothing to the extraction backlog and moves nothing across a crate boundary. `implementation: partial` stands. The decision holds. diff --git a/docs/adr/ADR-2026-fail-closed-security-posture.md b/docs/adr/ADR-2026-fail-closed-security-posture.md index 855fe2c81..564f5b123 100644 --- a/docs/adr/ADR-2026-fail-closed-security-posture.md +++ b/docs/adr/ADR-2026-fail-closed-security-posture.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: live supersedes: [] superseded_by: [] -verified_commit: a32abac57f3a7cfe66ab68ea1b0faca013c0d6b2 +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/middleware/rbac_gate.rs, src/main.rs, src/services/role_store.rs] owner: jjohare review_trigger: any new security-relevant env flag, or a request to soften the release boot-abort to a warning @@ -172,3 +172,7 @@ line 170 are unmoved. `verified_commit` moved to the CI-repair commit. **Governed changes since `997440cd0`:** `src/main.rs` changed only at the sync-service construction (`GitHubSyncService::new` now takes a `CorpusSource` from `source_from_env_with_github`, ADR-2114). `rbac_gate.rs` and `role_store.rs` are unchanged. **Decision unaffected.** No security flag was added, read or defaulted; `enforce_release_env_hygiene` and the fail-closed boot order are untouched. `verified_commit` moved to the CI-repair commit. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` (W8v, VisionClaw's own K_broker) inserts eight lines at `src/main.rs:195-202`: `visionclaw-server mint-nostr-key --out ` is dispatched first and exits. That path loads no `.env`, reads no environment, binds no listener, and rejects every argument other than `--out`/`--help` with exit 2, so `--allow-skip-auth` cannot ride along. Every path that serves still runs `enforce_release_env_hygiene()` (now `:209`) and `assert_effective_profile_or_exit` (now `:931`) before `HttpServer::new` (`:951`) and `.bind()` (`:1232`). Every `main.rs` citation after `:195` in this record moves down by eight lines. The new panel-key loader (`src/services/acsp/key_file.rs`) also fails closed: a key-file variable that is set but unusable, such as a file that group or other can read, disables the signers and never falls through to an inline key. `rbac_gate.rs` and `role_store.rs` are unchanged. The decision holds. Tests: `--lib key_file` 14, `--test mint_nostr_key_cli` 2. diff --git a/docs/adr/ADR-2027-three-deployment-profiles.md b/docs/adr/ADR-2027-three-deployment-profiles.md index 893406258..25480a3be 100644 --- a/docs/adr/ADR-2027-three-deployment-profiles.md +++ b/docs/adr/ADR-2027-three-deployment-profiles.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: 920401379719cff87023be5bab6c7c6233fc63ed +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/config/security_profile.rs, src/middleware/rbac_gate.rs, src/main.rs, src/services/role_store.rs, src/handlers/socket_flow_handler/position_updates.rs, docker-compose.unified.yml] owner: jjohare review_trigger: adding a fourth profile, machine-selecting a profile at boot, or changing a compose security default @@ -309,3 +309,7 @@ moved down by six lines. `verified_commit` moved to the CI-repair commit. **Governed changes since `b39b1a626`:** `docker-compose.unified.yml` moved the `cloudflared` service from the `production`/`prod` profiles to its own `tunnel` profile (host ADR-2119). **Decision unaffected.** No compose security default moved: `VISIONCLAW_DEV_MODE`, the `RBAC_*` flags and the profile selection are as before; the change is ingress, not security posture. `verified_commit` moved to `920401379`. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` (W8v, VisionClaw's own K_broker) inserts eight lines at `src/main.rs:195-202`: `visionclaw-server mint-nostr-key --out ` is dispatched first and exits. That path loads no `.env`, reads no environment, binds no listener, and rejects every argument other than `--out`/`--help` with exit 2, so `--allow-skip-auth` cannot ride along. Every path that serves still runs `enforce_release_env_hygiene()` (now `:209`) and `assert_effective_profile_or_exit` (now `:931`) before `HttpServer::new` (`:951`) and `.bind()` (`:1232`). Every `main.rs` citation after `:195` in this record moves down by eight lines. No profile flag, selector or compose service changed. The decision holds. diff --git a/docs/adr/ADR-2037-production-build-excludes-dev-auth.md b/docs/adr/ADR-2037-production-build-excludes-dev-auth.md index abcd13545..54fe3106a 100644 --- a/docs/adr/ADR-2037-production-build-excludes-dev-auth.md +++ b/docs/adr/ADR-2037-production-build-excludes-dev-auth.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: staged supersedes: [] superseded_by: [] -verified_commit: 920401379719cff87023be5bab6c7c6233fc63ed +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/config/security_profile.rs, src/main.rs, .github/workflows/ci.yml, Dockerfile.production] owner: jjohare review_trigger: any change to the production Dockerfile build line, the dev-auth feature gates, or enforce_release_env_hygiene @@ -144,3 +144,7 @@ that run was red. `verified_commit` moved to the CI-repair commit. **Governed changes since `a32abac57`:** `.github/workflows/ci.yml` adds the `prod_ingress` target to the hermetic integration-contract step (host ADR-2119). The `dev-auth-release-gate` job is untouched. In the same commit `scripts/launch.sh` (`:195`) also refuses `DEV_AUTH_LOOPBACK` in `.env.prod`, so its list now matches the release binary's four `FORBIDDEN_DEV_VARS`, in LAN and tunnel ingress alike. **Decision unaffected.** `verified_commit` moved to `920401379`. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` (W8v, VisionClaw's own K_broker) inserts eight lines at `src/main.rs:195-202`: `visionclaw-server mint-nostr-key --out ` is dispatched first and exits. That path loads no `.env`, reads no environment, binds no listener, and rejects every argument other than `--out`/`--help` with exit 2, so `--allow-skip-auth` cannot ride along. Every path that serves still runs `enforce_release_env_hygiene()` (now `:209`) and `assert_effective_profile_or_exit` (now `:931`) before `HttpServer::new` (`:951`) and `.bind()` (`:1232`). Every `main.rs` citation after `:195` in this record moves down by eight lines. The subcommand is the same in every feature closure, and nothing in it is gated on `dev-auth`. `security_profile.rs`, `ci.yml` and `Dockerfile.production` are unchanged. The decision holds. diff --git a/docs/adr/ADR-2038-boot-time-profile-assertion.md b/docs/adr/ADR-2038-boot-time-profile-assertion.md index 04b6d819a..0cf5f24ab 100644 --- a/docs/adr/ADR-2038-boot-time-profile-assertion.md +++ b/docs/adr/ADR-2038-boot-time-profile-assertion.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: a32abac57f3a7cfe66ab68ea1b0faca013c0d6b2 +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/config/security_profile.rs, src/main.rs] owner: jjohare review_trigger: adoption of a production deployment, or any change to the profile env vars (RBAC_PUBLIC_READS, PUBKEY_VISIBILITY_FILTER, RBAC_DEFAULT_ROLE) @@ -281,3 +281,7 @@ CI-repair commit. **Governed changes since `997440cd0`:** `src/main.rs` changed only at the sync-service construction (ADR-2114 `CorpusSource`). **Decision unaffected.** The boot-time profile assertion and its illegal-combination abort are not on the changed lines. `verified_commit` moved to the CI-repair commit. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` (W8v, VisionClaw's own K_broker) inserts eight lines at `src/main.rs:195-202`: `visionclaw-server mint-nostr-key --out ` is dispatched first and exits. That path loads no `.env`, reads no environment, binds no listener, and rejects every argument other than `--out`/`--help` with exit 2, so `--allow-skip-auth` cannot ride along. Every path that serves still runs `enforce_release_env_hygiene()` (now `:209`) and `assert_effective_profile_or_exit` (now `:931`) before `HttpServer::new` (`:951`) and `.bind()` (`:1232`). Every `main.rs` citation after `:195` in this record moves down by eight lines. The assertion is still unconditional on every path that binds. The mint path never reaches a bind, so it needs no profile. `security_profile.rs` is unchanged. The decision holds. diff --git a/docs/adr/ADR-2042-vault-migrate-converter.md b/docs/adr/ADR-2042-vault-migrate-converter.md index 51c7aeeed..7fda65692 100644 --- a/docs/adr/ADR-2042-vault-migrate-converter.md +++ b/docs/adr/ADR-2042-vault-migrate-converter.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [ADR-2113] -verified_commit: fdcbc9120fda25fd93fdaee744d68d2c00713d6b +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [crates/vault-migrate, Cargo.toml, docs/VAULT-corpus-format.md] owner: jjohare review_trigger: the in-place conversion of the corpus repo is committed, after which the crate is kept only as the round-trip/no-op checker @@ -147,3 +147,7 @@ graph. ## Re-verification — 2026-10-03 at fdcbc9120fda25fd93fdaee744d68d2c00713d6b `1d3e14a30` and `fdcbc9120` change `Cargo.toml` (the solid-pod-rs pin, feature `mrc20`, a `[patch.crates-io]` for `nostr-bbs-core`; ADR-2111, S4 amendment). None of it concerns the converter this superseded record describes, and `crates/vault-migrate` and `docs/VAULT-corpus-format.md` are untouched. Nothing to re-decide. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`1e55daebb` removes the `nostr-bbs-core` `[patch.crates-io]` from `Cargo.toml`, which the 2026-10-03 note above covered. It does not concern this superseded converter, and `docs/VAULT-corpus-format.md` is untouched. Nothing to re-decide. diff --git a/docs/adr/ADR-2106-ontology-pull-model-into-the-embedded-pod.md b/docs/adr/ADR-2106-ontology-pull-model-into-the-embedded-pod.md index ccfeab5de..905dea80e 100644 --- a/docs/adr/ADR-2106-ontology-pull-model-into-the-embedded-pod.md +++ b/docs/adr/ADR-2106-ontology-pull-model-into-the-embedded-pod.md @@ -7,7 +7,7 @@ implementation_status: partial activation_status: live supersedes: [] superseded_by: [] -verified_commit: fdcbc9120fda25fd93fdaee744d68d2c00713d6b +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/services/ontology_generation.rs, .github/workflows/ontology-publish.yml, src/services/ontology_pull.rs, src/main.rs, scripts/ontology/pack-pod-resources.py, client/src/features/ontology/services/jss/contextLoader.ts, client/src/features/ontology/services/jss/schemaParser.ts, env.example] owner: jjohare review_trigger: A pod that becomes reachable from CI (self-hosted runner or public endpoint); a change to the /public/ontology/ resource set; the release channel moving off GitHub (e.g. to the Loom or narrativegoldmine.com). @@ -156,3 +156,7 @@ moved to the CI-repair commit. ## Re-verification — 2026-10-03 at fdcbc9120fda25fd93fdaee744d68d2c00713d6b `1d3e14a30` adds `inherited: false` to the `AclDocument` literal in `public_read_acl` (`src/services/ontology_pull.rs`), the field solid-pod-rs 0.5.0-alpha.12 added. The document is the container's own sidecar, so `false` is its meaning, and it is never serialised. The ACL the pull writes is byte-identical. Tests: `cargo test -p visionclaw-server --lib -- ontology_pull ontology_generation` (16 pass). The decision holds unchanged. + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` inserts the eight-line `mint-nostr-key` dispatch at `src/main.rs:195-202`, ahead of `.env` loading. The boot pull and `init_solid_state` are untouched, and they move down by eight lines. The other governed paths are unchanged. The decision holds. diff --git a/docs/adr/ADR-2110-augmentation-conditions-visionclaw-substrate.md b/docs/adr/ADR-2110-augmentation-conditions-visionclaw-substrate.md index 4a64c61a2..cd28dad7c 100644 --- a/docs/adr/ADR-2110-augmentation-conditions-visionclaw-substrate.md +++ b/docs/adr/ADR-2110-augmentation-conditions-visionclaw-substrate.md @@ -7,7 +7,7 @@ implementation_status: complete activation_status: staged supersedes: [] superseded_by: [] -verified_commit: 3fd97572ad2433f195f5b98a8987355af4c18881 +verified_commit: 780eb3edb788c9cb568d5756689a3c8455db79b7 verified_paths: [src/services/intent_match.rs, src/services/kpi_compute.rs, src/actors/elevation_actor.rs, src/adapters/sqlite_kpi_repository.rs, src/adapters/sqlite_enrichment_repository.rs, src/handlers/broker_inbox_handler.rs, client/src/features/control-center/governance/brokerCaseQueue.ts, client/src/features/control-center/governance/AcspCaseQueue.tsx] owner: jjohare review_trigger: The forum half of EXP-AC-002/004/006 landing, or the first live case queue with real decided cases @@ -335,3 +335,7 @@ and the owner's live high-tier 31403 (cycle exit item 4) has not happened. It moves to `live` on that receipt. The high-tier case for it is `solid-pod-rs-1.0.0-beta.1-release-20261002` on the agentbox-release-ops panel, raised 2026-10-02 (owner decision Q7). + +## Re-verification — 2026-10-03 at 780eb3edb788c9cb568d5756689a3c8455db79b7 + +`780eb3edb` changes only how `src/actors/elevation_actor.rs` obtains its panel secret, at `:199-206`. It now goes through `services::acsp::key_file::load_panel_secret`, so a 0600 key file named by `ACSP_PANEL_NOSTR_KEY_FILE` wins over the inline env value, and an unusable file disables the actor with a logged error. Case handling, the approve path and the relay-admission trust noted in Consequences item 2 are unchanged. The other seven governed paths are unchanged. The decision holds. Tests: `--lib elevation` 62 pass. diff --git a/docs/explanation/agent-control-surface.md b/docs/explanation/agent-control-surface.md index 10e33cc2b..f0b7736f5 100644 --- a/docs/explanation/agent-control-surface.md +++ b/docs/explanation/agent-control-surface.md @@ -222,7 +222,9 @@ relay's `agent_registry` before any publish succeeds. The client logs the pubkey at startup; a relay admin registers it via the NIP-98-gated `POST /api/governance/agents/register`. Until then every publish is rejected with `blocked: pubkey not in agent registry`. Signing uses a dedicated panel keypair -(`ACSP_PANEL_NOSTR_PRIVKEY`, falling back to `VISIONCLAW_NOSTR_PRIVKEY`) so that +(VisionClaw's own K_broker, minted by `visionclaw-server mint-nostr-key` and read +from `ACSP_PANEL_NOSTR_KEY_FILE`; the inline `ACSP_PANEL_NOSTR_PRIVKEY` and +`VISIONCLAW_NOSTR_PRIVKEY` remain as fallbacks) so that panel production can be rate-limited and revoked independently of bead provenance. The whole producer is env-gated — `FORUM_RELAY_URL` plus a signing key present, with each actor behind its own flag (`ELEVATION_ACTOR_ENABLED=1`). diff --git a/docs/how-to/operations/configuration.md b/docs/how-to/operations/configuration.md index 1d9367cf4..7bfe0c936 100644 --- a/docs/how-to/operations/configuration.md +++ b/docs/how-to/operations/configuration.md @@ -303,8 +303,28 @@ lifecycle with retry, outcome classification, and learning capture (see [ADR-034](../../archive/adr/ADR-034-needle-bead-provenance.md) and [PRD](../../archive/prd/prd-bead-provenance-upgrade.md)). +VisionClaw mints its own signing key; never copy one from another service. +Mint it inside the container, onto the persistent `visionclaw-data` volume: + +```bash +docker exec visionclaw_container sh -c 'umask 077 && mkdir -p /app/data/keys && \ + /app/target/dev-runtime/visionclaw-server mint-nostr-key --out /app/data/keys/k_broker.key' +``` + +(On the production image the binary is `/app/visionclaw-server` and the container +is `visionclaw_prod_container`.) The command writes the hex secret to a new file +at mode 0600, refuses to overwrite an existing file, and prints only two lines: +the x-only public key in hex and its `did:nostr:`. The secret is never +printed. Register that public key in the forum relay's `agent_registry`. + ```bash -# Bridge bot private key (64-char hex). Generate with: openssl rand -hex 32 +# Governance / ACSP panel signing key (kinds 31400-31405, decision projection, +# voice mandates). A key FILE wins over any inline key; see the reference. +ACSP_PANEL_NOSTR_KEY_FILE=/app/data/keys/k_broker.key + +# Bead-provenance bridge key (64-char hex), read inline only by +# nostr_bridge.rs / nostr_bead_publisher.rs. Also the last-resort fallback for +# the panel key when no key file and no ACSP_PANEL_NOSTR_PRIVKEY is set. VISIONCLAW_NOSTR_PRIVKEY=<64-char hex secret key> # JSS integrated Nostr relay (default shown — matches docker-compose service name) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c9c8bb3a8..196553567 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -187,8 +187,20 @@ environment block. Relay topology follows ADR-073. | `VISIONCLAW_NOSTR_PRIVKEY` | string | `""` | Private key for the bead provenance bridge. Unset disables the bridge. | | `NOSTR_RELAY_URL` | string | `""` | Source relay (must start `ws://` or `wss://`). | | `FORUM_RELAY_URL` | string | `""` | Forum/destination relay for the bridge and elevation actor. | -| `ACSP_PANEL_NOSTR_PRIVKEY` | string | `""` | Key for the ACSP control-surface panel. | -| `ELEVATION_ACTOR_ENABLED` | boolean | `false` | Enable the elevation actor (requires `FORUM_RELAY_URL` and `ACSP_PANEL_NOSTR_PRIVKEY`). | +| `ACSP_PANEL_NOSTR_KEY_FILE` | path | `""` | File holding the ACSP panel / governance signing key (K_broker) as 64-char hex, made by `visionclaw-server mint-nostr-key --out `. Must be a regular file with no group or other permission bits (0600 or 0400); otherwise the panel signers stay off and log why. Takes precedence over every inline key. | +| `VISIONCLAW_NOSTR_KEY_FILE` | path | `""` | Fallback key-file name for the panel key, consulted when `ACSP_PANEL_NOSTR_KEY_FILE` is unset. Same rules. | +| `ACSP_PANEL_NOSTR_PRIVKEY` | string | `""` | Inline panel key, used only when neither key-file variable is set. | +| `ELEVATION_ACTOR_ENABLED` | boolean | `false` | Enable the elevation actor (requires `FORUM_RELAY_URL` and a panel key). | + +The panel key is read through one loader (`src/services/acsp/key_file.rs`) by the +elevation actor, the decision-elevation actor, the decision-projection client in +`AppState` and the governed voice-intent client. Precedence, first set and +non-empty wins: `ACSP_PANEL_NOSTR_KEY_FILE`, `VISIONCLAW_NOSTR_KEY_FILE`, +`ACSP_PANEL_NOSTR_PRIVKEY`, `VISIONCLAW_NOSTR_PRIVKEY`. A key-file variable that +is set but unusable (missing, too permissive, not a valid key) disables those +signers with an error naming the variable and path. It never falls through to an +inline key. The bead-provenance bridge still reads `VISIONCLAW_NOSTR_PRIVKEY` +inline. --- diff --git a/src/actors/decision_elevation_actor.rs b/src/actors/decision_elevation_actor.rs index 64e826ca1..54f2723cb 100644 --- a/src/actors/decision_elevation_actor.rs +++ b/src/actors/decision_elevation_actor.rs @@ -180,9 +180,14 @@ impl DecisionElevationActor { return None; } let forum_relay_url = std::env::var("FORUM_RELAY_URL").ok()?; - let panel_secret = std::env::var("ACSP_PANEL_NOSTR_PRIVKEY") - .or_else(|_| std::env::var("VISIONCLAW_NOSTR_PRIVKEY")) - .ok()?; + let panel_secret = match crate::services::acsp::key_file::load_panel_secret() { + Ok(Some(s)) => s.into_secret_hex(), + Ok(None) => return None, + Err(e) => { + error!("[DecisionElevation] panel signing key unusable, actor disabled: {e}"); + return None; + } + }; Some(Self { acsp: None, panel_secret, diff --git a/src/actors/elevation_actor.rs b/src/actors/elevation_actor.rs index 61ddb6e94..dde0bb79c 100644 --- a/src/actors/elevation_actor.rs +++ b/src/actors/elevation_actor.rs @@ -196,9 +196,14 @@ impl ElevationActor { return None; } let forum_relay_url = std::env::var("FORUM_RELAY_URL").ok()?; - let panel_secret = std::env::var("ACSP_PANEL_NOSTR_PRIVKEY") - .or_else(|_| std::env::var("VISIONCLAW_NOSTR_PRIVKEY")) - .ok()?; + let panel_secret = match crate::services::acsp::key_file::load_panel_secret() { + Ok(Some(s)) => s.into_secret_hex(), + Ok(None) => return None, + Err(e) => { + error!("[Elevation] panel signing key unusable, actor disabled: {e}"); + return None; + } + }; Some(Self { kg_repo, enrichment_repo, diff --git a/src/app_state.rs b/src/app_state.rs index e0560b6ef..fcd6b450a 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1288,7 +1288,7 @@ impl AppState { info!("[AppState] ElevationActor started (ACSP knowledge-elevation panel live)"); } None => info!( - "[AppState] ElevationActor disabled (dev/staging default ON — set ELEVATION_ACTOR_ENABLED=0 to force off, or in production set ELEVATION_ACTOR_ENABLED=1; also requires FORUM_RELAY_URL + ACSP_PANEL_NOSTR_PRIVKEY)" + "[AppState] ElevationActor disabled (dev/staging default ON — set ELEVATION_ACTOR_ENABLED=0 to force off, or in production set ELEVATION_ACTOR_ENABLED=1; also requires FORUM_RELAY_URL + a panel key: ACSP_PANEL_NOSTR_KEY_FILE or ACSP_PANEL_NOSTR_PRIVKEY)" ), } @@ -1354,14 +1354,18 @@ impl AppState { // gap-close item 2 (ADR-130 Decision 2): connect the shared ACSP client // that projects REST/bridge decisions back to the forum as kind-31403. - // Same identity + relay as ElevationActor (ACSP_PANEL_NOSTR_PRIVKEY / - // VISIONCLAW_NOSTR_PRIVKEY + FORUM_RELAY_URL). Unconfigured or failed + // Same identity + relay as ElevationActor (key via + // services::acsp::key_file::load_panel_secret + FORUM_RELAY_URL). Unconfigured or failed // connect ⇒ None, and the decide path records forum_projection=skipped. let acsp_client = { let relay = std::env::var("FORUM_RELAY_URL").ok(); - let secret = std::env::var("ACSP_PANEL_NOSTR_PRIVKEY") - .or_else(|_| std::env::var("VISIONCLAW_NOSTR_PRIVKEY")) - .ok(); + let secret = match crate::services::acsp::key_file::load_panel_secret() { + Ok(s) => s.map(|s| s.into_secret_hex()), + Err(e) => { + error!("[AppState] panel signing key unusable ({e}) — decision-projection client OFF"); + None + } + }; match (relay, secret) { (Some(relay), Some(secret)) => { match crate::services::acsp::AcspClient::connect(&secret, &relay).await { @@ -1376,7 +1380,7 @@ impl AppState { } } _ => { - info!("[AppState] ACSP decision-projection client OFF (FORUM_RELAY_URL + ACSP_PANEL_NOSTR_PRIVKEY/VISIONCLAW_NOSTR_PRIVKEY unset) — REST/bridge decisions record forum_projection=skipped"); + info!("[AppState] ACSP decision-projection client OFF (FORUM_RELAY_URL or panel key unset: ACSP_PANEL_NOSTR_KEY_FILE / VISIONCLAW_NOSTR_KEY_FILE / ACSP_PANEL_NOSTR_PRIVKEY / VISIONCLAW_NOSTR_PRIVKEY) — REST/bridge decisions record forum_projection=skipped"); None } } diff --git a/src/handlers/enrichment_proposals_handler.rs b/src/handlers/enrichment_proposals_handler.rs index b07d65d6e..fed409d40 100644 --- a/src/handlers/enrichment_proposals_handler.rs +++ b/src/handlers/enrichment_proposals_handler.rs @@ -668,7 +668,7 @@ pub(crate) async fn apply_decision( } None => { warn!( - "[enrichment-decide] DEGRADED: forum projection SKIPPED for case={case_id} — no AcspClient configured (set FORUM_RELAY_URL + ACSP_PANEL_NOSTR_PRIVKEY). The decision is recorded + written locally but is INVISIBLE to the forum broker_decisions." + "[enrichment-decide] DEGRADED: forum projection SKIPPED for case={case_id} — no AcspClient configured (set FORUM_RELAY_URL + ACSP_PANEL_NOSTR_KEY_FILE or ACSP_PANEL_NOSTR_PRIVKEY). The decision is recorded + written locally but is INVISIBLE to the forum broker_decisions." ); "skipped" } diff --git a/src/main.rs b/src/main.rs index bb940402d..052fb07c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,6 +192,14 @@ async fn main() -> std::io::Result<()> { eprintln!("PANIC at {}: {}", location, payload); })); + // One-shot `visionclaw-server mint-nostr-key --out `: mints this + // service's own governance signing key and exits, before `.env` is loaded + // or anything else starts (services::acsp::key_file). + let argv: Vec = std::env::args().collect(); + if let Some(code) = visionclaw_server::services::acsp::key_file::dispatch_cli(&argv) { + std::process::exit(code); + } + dotenv().ok(); // ADR-06 §D11 — Before any other startup work, in a release build, refuse @@ -567,7 +575,7 @@ async fn main() -> std::io::Result<()> { )) } None => { - info!("[main] DecisionElevationActor disabled (set DECISION_ELEVATION_ENABLED=1 + FORUM_RELAY_URL + ACSP_PANEL_NOSTR_PRIVKEY to enable)"); + info!("[main] DecisionElevationActor disabled (set DECISION_ELEVATION_ENABLED=1 + FORUM_RELAY_URL + a panel key (ACSP_PANEL_NOSTR_KEY_FILE or ACSP_PANEL_NOSTR_PRIVKEY) to enable)"); None } }; diff --git a/src/services/acsp/key_file.rs b/src/services/acsp/key_file.rs new file mode 100644 index 000000000..9c15f70e0 --- /dev/null +++ b/src/services/acsp/key_file.rs @@ -0,0 +1,692 @@ +//! VisionClaw's own governance signing key (K_broker): minting and loading. +//! +//! The ACSP producer (kinds 31400-31405), the decision-projection client and +//! the governed voice loop all sign as one panel identity. That identity must +//! belong to VisionClaw alone — never a copy of another service's key — so it +//! is minted here and read through exactly one loader. +//! +//! - [`mint_key_file`] / [`run_mint_cli`] — `visionclaw-server mint-nostr-key +//! --out `: generates a key with `nostr_sdk::Keys::generate()`, writes +//! the hex secret to a *new* file at mode 0600 (`O_CREAT|O_EXCL`; an existing +//! path is refused) and prints only the x-only public key and its did:nostr. +//! - [`load_panel_secret`] — the shared loader. Precedence, first set wins: +//! 1. `ACSP_PANEL_NOSTR_KEY_FILE` (path to a 0600 hex secret file) +//! 2. `VISIONCLAW_NOSTR_KEY_FILE` +//! 3. `ACSP_PANEL_NOSTR_PRIVKEY` (hex secret in the environment) +//! 4. `VISIONCLAW_NOSTR_PRIVKEY` +//! +//! A configured file var that cannot be used (missing, not a regular file, +//! readable by group/other, not a valid hex secret) is an error, never a +//! silent fall-through to the environment value: an operator who pointed at a +//! file meant that key, not whatever is still in `.env`. +//! +//! No cryptography lives here: generation, encoding and parsing are +//! `nostr-sdk`'s. Secret material never reaches a log line, an error message or +//! a `Debug` rendering. + +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use nostr_sdk::prelude::{Keys, SecretKey}; + +/// Subcommand name dispatched from `main` before any server start-up. +pub const MINT_SUBCOMMAND: &str = "mint-nostr-key"; + +/// Key-file variables, highest precedence first. +pub const PANEL_KEY_FILE_VARS: [&str; 2] = + ["ACSP_PANEL_NOSTR_KEY_FILE", "VISIONCLAW_NOSTR_KEY_FILE"]; + +/// Inline secret variables, consulted only when no key-file variable is set. +pub const PANEL_KEY_ENV_VARS: [&str; 2] = ["ACSP_PANEL_NOSTR_PRIVKEY", "VISIONCLAW_NOSTR_PRIVKEY"]; + +/// Upper bound on a key file's size; a hex secret plus newline is 65 bytes. +const MAX_KEY_FILE_BYTES: u64 = 4096; + +/// Public half of a freshly minted key — the only thing the mint prints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MintedKey { + /// x-only (BIP-340) public key, 64 lowercase hex chars. + pub pubkey_hex: String, + /// `did:nostr:`. + pub did: String, +} + +/// Generate a key and write its hex secret to `path`, which must not exist. +/// +/// The file is created with `O_CREAT|O_EXCL` at mode 0600 (re-asserted with +/// `fchmod` so a restrictive umask cannot leave it at 0400/0000 and a +/// permissive one cannot widen it), written, and fsynced. On a write failure +/// the partial file is removed. +pub fn mint_key_file(path: &Path) -> io::Result { + let keys = Keys::generate(); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + let written = (|| { + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.write_all(keys.secret_key().to_secret_hex().as_bytes())?; + file.write_all(b"\n")?; + file.sync_all() + })(); + if let Err(e) = written { + drop(file); + let _ = std::fs::remove_file(path); + return Err(e); + } + let pubkey_hex = keys.public_key().to_hex(); + Ok(MintedKey { + did: format!("did:nostr:{pubkey_hex}"), + pubkey_hex, + }) +} + +const MINT_USAGE: &str = "usage: visionclaw-server mint-nostr-key --out \n\ +\n\ +Mints VisionClaw's own governance signing key. Writes the hex secret to \n\ +(new file, mode 0600; an existing path is refused) and prints the x-only\n\ +public key and its did:nostr. The secret is never printed. Point\n\ +ACSP_PANEL_NOSTR_KEY_FILE at to sign with it.\n"; + +/// Run the `mint-nostr-key` subcommand. `args` excludes the program name and +/// the subcommand itself. Returns the process exit code: 0 minted, 1 refused +/// or failed, 2 usage error. +/// +/// On success stdout carries exactly two lines — the pubkey hex, then the +/// did:nostr — and stderr is empty. +pub fn run_mint_cli(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let mut out_path: Option = None; + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "-h" | "--help" => { + let _ = out.write_all(MINT_USAGE.as_bytes()); + return 0; + } + "--out" => match it.next() { + Some(p) if !p.is_empty() => out_path = Some(PathBuf::from(p)), + _ => { + let _ = writeln!(err, "mint-nostr-key: --out needs a path\n\n{MINT_USAGE}"); + return 2; + } + }, + other if other.starts_with("--out=") && other.len() > "--out=".len() => { + out_path = Some(PathBuf::from(&other["--out=".len()..])); + } + other => { + let _ = writeln!( + err, + "mint-nostr-key: unexpected argument {other:?}\n\n{MINT_USAGE}" + ); + return 2; + } + } + } + let Some(path) = out_path else { + let _ = write!( + err, + "mint-nostr-key: --out is required\n\n{MINT_USAGE}" + ); + return 2; + }; + match mint_key_file(&path) { + Ok(minted) => { + let _ = writeln!(out, "{}\n{}", minted.pubkey_hex, minted.did); + 0 + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + let _ = writeln!( + err, + "mint-nostr-key: refusing to overwrite existing {} — a governance key is never replaced in place", + path.display() + ); + 1 + } + Err(e) => { + let _ = writeln!(err, "mint-nostr-key: cannot create {}: {e}", path.display()); + 1 + } + } +} + +/// If `argv` (program name first) invokes [`MINT_SUBCOMMAND`], run it on the +/// real stdout/stderr and return its exit code; otherwise `None` and the +/// server starts normally. +pub fn dispatch_cli(argv: &[String]) -> Option { + if argv.get(1).map(String::as_str) != Some(MINT_SUBCOMMAND) { + return None; + } + let stdout = io::stdout(); + let stderr = io::stderr(); + Some(run_mint_cli( + &argv[2..], + &mut stdout.lock(), + &mut stderr.lock(), + )) +} + +/// Where the panel secret came from (never the secret itself). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PanelKeySource { + /// Read from the file named by `var`. + File { + /// The variable that named the file. + var: &'static str, + /// The file path. + path: PathBuf, + }, + /// Taken inline from the environment variable `var`. + Env { + /// The variable that held the secret. + var: &'static str, + }, +} + +impl fmt::Display for PanelKeySource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File { var, path } => write!(f, "{var}={}", path.display()), + Self::Env { var } => write!(f, "{var} (inline env)"), + } + } +} + +/// The resolved panel signing secret. `Debug` is redacted. +pub struct PanelSecret { + secret_hex: String, + source: PanelKeySource, +} + +impl PanelSecret { + /// The hex secret, for `SecretKey::from_hex` / `AcspClient::connect`. + pub fn secret_hex(&self) -> &str { + &self.secret_hex + } + + /// Where it was loaded from. + pub fn source(&self) -> &PanelKeySource { + &self.source + } + + /// Consume into the hex secret (for actors that own the string). + pub fn into_secret_hex(self) -> String { + self.secret_hex + } +} + +impl fmt::Debug for PanelSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PanelSecret") + .field("secret_hex", &"") + .field("source", &self.source) + .finish() + } +} + +/// Why a configured key file could not be used. Messages name the variable +/// and path, never the contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PanelKeyError { + /// The file could not be opened or read. + Unreadable { + /// Variable naming the file. + var: &'static str, + /// The path. + path: PathBuf, + /// The I/O error, rendered. + reason: String, + }, + /// The path is not a regular file. + NotRegularFile { + /// Variable naming the file. + var: &'static str, + /// The path. + path: PathBuf, + }, + /// Group or other has any permission bit on the file. + TooPermissive { + /// Variable naming the file. + var: &'static str, + /// The path. + path: PathBuf, + /// The file's permission bits (`mode & 0o777`). + mode: u32, + }, + /// The file is larger than any key file could be. + TooLarge { + /// Variable naming the file. + var: &'static str, + /// The path. + path: PathBuf, + }, + /// The contents are not a 64-hex secp256k1 secret. + InvalidSecret { + /// Variable naming the file. + var: &'static str, + /// The path. + path: PathBuf, + }, +} + +impl fmt::Display for PanelKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unreadable { var, path, reason } => { + write!(f, "{var}={}: cannot read key file: {reason}", path.display()) + } + Self::NotRegularFile { var, path } => { + write!(f, "{var}={}: not a regular file", path.display()) + } + Self::TooPermissive { var, path, mode } => write!( + f, + "{var}={}: key file mode is {mode:04o}, refusing — it must not be readable by group or other (chmod 600 {})", + path.display(), + path.display() + ), + Self::TooLarge { var, path } => write!( + f, + "{var}={}: key file exceeds {MAX_KEY_FILE_BYTES} bytes; not a key file", + path.display() + ), + Self::InvalidSecret { var, path } => write!( + f, + "{var}={}: contents are not a 64-hex Nostr secret key (mint one with `visionclaw-server mint-nostr-key --out `)", + path.display() + ), + } + } +} + +impl std::error::Error for PanelKeyError {} + +/// Resolve the panel signing secret from the process environment. +/// +/// `Ok(None)` means nothing is configured (the governed paths stay off, as +/// before). See the module docs for precedence. +pub fn load_panel_secret() -> Result, PanelKeyError> { + load_panel_secret_with(|k| std::env::var(k).ok()) +} + +/// [`load_panel_secret`] over an arbitrary variable lookup (tests inject one +/// rather than mutating the process environment). Empty values count as +/// unset. +pub fn load_panel_secret_with( + lookup: impl Fn(&str) -> Option, +) -> Result, PanelKeyError> { + let get = |k: &str| lookup(k).filter(|v| !v.is_empty()); + for var in PANEL_KEY_FILE_VARS { + if let Some(path) = get(var) { + let path = PathBuf::from(path); + let secret_hex = read_key_file(var, &path)?; + return Ok(Some(PanelSecret { + secret_hex, + source: PanelKeySource::File { var, path }, + })); + } + } + for var in PANEL_KEY_ENV_VARS { + if let Some(secret_hex) = get(var) { + return Ok(Some(PanelSecret { + secret_hex, + source: PanelKeySource::Env { var }, + })); + } + } + Ok(None) +} + +fn read_key_file(var: &'static str, path: &Path) -> Result { + let unreadable = |e: io::Error| PanelKeyError::Unreadable { + var, + path: path.to_path_buf(), + reason: e.to_string(), + }; + // Check the metadata of the handle we read from, not a separate stat of + // the path, so the checked file is the read file. Symlinks are followed + // (container secret mounts use them); the target's mode is what counts. + let mut file = File::open(path).map_err(unreadable)?; + let meta = file.metadata().map_err(unreadable)?; + if !meta.is_file() { + return Err(PanelKeyError::NotRegularFile { + var, + path: path.to_path_buf(), + }); + } + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(PanelKeyError::TooPermissive { + var, + path: path.to_path_buf(), + mode, + }); + } + if meta.len() > MAX_KEY_FILE_BYTES { + return Err(PanelKeyError::TooLarge { + var, + path: path.to_path_buf(), + }); + } + let mut contents = String::new(); + (&mut file) + .take(MAX_KEY_FILE_BYTES + 1) + .read_to_string(&mut contents) + .map_err(|_| PanelKeyError::InvalidSecret { + var, + path: path.to_path_buf(), + })?; + let secret_hex = contents.trim(); + if SecretKey::from_hex(secret_hex).is_err() { + return Err(PanelKeyError::InvalidSecret { + var, + path: path.to_path_buf(), + }); + } + Ok(secret_hex.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::ToBech32; + use std::collections::HashMap; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k| map.get(k).cloned() + } + + fn key_file(dir: &Path, name: &str, mode: u32) -> (PathBuf, Keys) { + let keys = Keys::generate(); + let path = dir.join(name); + std::fs::write(&path, format!("{}\n", keys.secret_key().to_secret_hex())).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + (path, keys) + } + + fn run(args: &[&str]) -> (i32, String, String) { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = run_mint_cli(&args, &mut out, &mut err); + ( + code, + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) + } + + // ── mint ──────────────────────────────────────────────────────────── + + #[test] + fn mint_creates_file_0600_not_world_readable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + mint_key_file(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "mode {mode:04o}"); + assert_eq!(mode & 0o077, 0, "group/other must have no bits"); + } + + #[test] + fn mint_refuses_to_overwrite_and_leaves_existing_file_intact() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + std::fs::write(&path, "existing").unwrap(); + let err = mint_key_file(&path).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing"); + + let (code, out, stderr) = run(&["--out", path.to_str().unwrap()]); + assert_eq!(code, 1); + assert!(out.is_empty()); + assert!(stderr.contains("refusing to overwrite"), "{stderr}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing"); + } + + #[test] + fn mint_refuses_a_dangling_symlink() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("elsewhere"); + let link = dir.path().join("k_broker.key"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + mint_key_file(&link).unwrap_err().kind(), + io::ErrorKind::AlreadyExists + ); + assert!(!target.exists(), "O_EXCL must not follow the link"); + } + + #[test] + fn printed_pubkey_matches_the_key_in_the_file_and_secret_is_never_printed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + let (code, out, err) = run(&["--out", path.to_str().unwrap()]); + assert_eq!(code, 0, "stderr: {err}"); + assert!(err.is_empty(), "stderr must be empty on success: {err}"); + + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 2, "exactly pubkey + did: {out:?}"); + let secret_hex = std::fs::read_to_string(&path).unwrap().trim().to_string(); + let derived = Keys::new(SecretKey::from_hex(&secret_hex).unwrap()); + assert_eq!(lines[0], derived.public_key().to_hex()); + assert_eq!( + lines[1], + format!("did:nostr:{}", derived.public_key().to_hex()) + ); + + let secret = derived.secret_key(); + for rendering in [ + secret_hex.clone(), + secret_hex.to_uppercase(), + secret.to_bech32().unwrap(), + ] { + assert!(!out.contains(&rendering), "secret leaked to stdout"); + assert!(!err.contains(&rendering), "secret leaked to stderr"); + } + } + + #[test] + fn mint_cli_accepts_out_equals_and_rejects_bad_usage() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k.key"); + let (code, _, _) = run(&[&format!("--out={}", path.display())]); + assert_eq!(code, 0); + assert!(path.exists()); + + assert_eq!(run(&[]).0, 2); + assert_eq!(run(&["--out"]).0, 2); + assert_eq!(run(&["--force", "--out", "x"]).0, 2); + let (code, out, _) = run(&["--help"]); + assert_eq!(code, 0); + assert!(out.contains("mint-nostr-key --out ")); + } + + #[test] + fn dispatch_only_claims_the_mint_subcommand() { + let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(dispatch_cli(&argv(&["visionclaw-server"])), None); + assert_eq!( + dispatch_cli(&argv(&["visionclaw-server", "--allow-skip-auth"])), + None + ); + } + + // ── loader precedence ─────────────────────────────────────────────── + + #[test] + fn nothing_configured_is_none() { + assert!(load_panel_secret_with(env(&[])).unwrap().is_none()); + assert!( + load_panel_secret_with(env(&[("ACSP_PANEL_NOSTR_PRIVKEY", "")])) + .unwrap() + .is_none() + ); + } + + #[test] + fn env_path_unchanged_when_no_file_var_is_set() { + let s = load_panel_secret_with(env(&[ + ("ACSP_PANEL_NOSTR_PRIVKEY", "aa"), + ("VISIONCLAW_NOSTR_PRIVKEY", "bb"), + ])) + .unwrap() + .unwrap(); + // Passed through verbatim, as the old `env::var(..).or_else(..)` did. + assert_eq!(s.secret_hex(), "aa"); + assert_eq!( + s.source(), + &PanelKeySource::Env { + var: "ACSP_PANEL_NOSTR_PRIVKEY" + } + ); + + let s = load_panel_secret_with(env(&[("VISIONCLAW_NOSTR_PRIVKEY", "bb")])) + .unwrap() + .unwrap(); + assert_eq!(s.secret_hex(), "bb"); + assert_eq!( + s.source(), + &PanelKeySource::Env { + var: "VISIONCLAW_NOSTR_PRIVKEY" + } + ); + } + + #[test] + fn a_key_file_beats_both_env_vars() { + let dir = tempfile::tempdir().unwrap(); + let (path, keys) = key_file(dir.path(), "vc.key", 0o600); + let s = load_panel_secret_with(env(&[ + ("VISIONCLAW_NOSTR_KEY_FILE", path.to_str().unwrap()), + ("ACSP_PANEL_NOSTR_PRIVKEY", "copied-house-key"), + ("VISIONCLAW_NOSTR_PRIVKEY", "copied-house-key"), + ])) + .unwrap() + .unwrap(); + assert_eq!(s.secret_hex(), keys.secret_key().to_secret_hex()); + assert_eq!( + s.source(), + &PanelKeySource::File { + var: "VISIONCLAW_NOSTR_KEY_FILE", + path + } + ); + } + + #[test] + fn acsp_key_file_beats_visionclaw_key_file() { + let dir = tempfile::tempdir().unwrap(); + let (acsp, acsp_keys) = key_file(dir.path(), "acsp.key", 0o600); + let (vc, _) = key_file(dir.path(), "vc.key", 0o600); + let s = load_panel_secret_with(env(&[ + ("ACSP_PANEL_NOSTR_KEY_FILE", acsp.to_str().unwrap()), + ("VISIONCLAW_NOSTR_KEY_FILE", vc.to_str().unwrap()), + ])) + .unwrap() + .unwrap(); + assert_eq!(s.secret_hex(), acsp_keys.secret_key().to_secret_hex()); + assert!(matches!( + s.source(), + PanelKeySource::File { + var: "ACSP_PANEL_NOSTR_KEY_FILE", + .. + } + )); + } + + #[test] + fn a_0644_key_file_is_refused_with_a_clear_error_and_no_env_fallback() { + let dir = tempfile::tempdir().unwrap(); + let (path, keys) = key_file(dir.path(), "loose.key", 0o644); + let err = load_panel_secret_with(env(&[ + ("ACSP_PANEL_NOSTR_KEY_FILE", path.to_str().unwrap()), + ("ACSP_PANEL_NOSTR_PRIVKEY", "fallback-must-not-be-used"), + ])) + .unwrap_err(); + assert_eq!( + err, + PanelKeyError::TooPermissive { + var: "ACSP_PANEL_NOSTR_KEY_FILE", + path: path.clone(), + mode: 0o644 + } + ); + let msg = err.to_string(); + assert!(msg.contains("ACSP_PANEL_NOSTR_KEY_FILE"), "{msg}"); + assert!(msg.contains("0644") && msg.contains("chmod 600"), "{msg}"); + assert!(!msg.contains(&keys.secret_key().to_secret_hex())); + } + + #[test] + fn group_readable_and_group_writable_files_are_refused_too() { + let dir = tempfile::tempdir().unwrap(); + for mode in [0o640, 0o620, 0o604, 0o660] { + let (path, _) = key_file(dir.path(), &format!("k{mode:o}.key"), mode); + let err = load_panel_secret_with(env(&[( + "ACSP_PANEL_NOSTR_KEY_FILE", + path.to_str().unwrap(), + )])) + .unwrap_err(); + assert!( + matches!(err, PanelKeyError::TooPermissive { .. }), + "{mode:o}" + ); + } + // 0400 (read-only owner) is fine. + let (path, _) = key_file(dir.path(), "ro.key", 0o400); + assert!(load_panel_secret_with(env(&[( + "ACSP_PANEL_NOSTR_KEY_FILE", + path.to_str().unwrap() + )])) + .unwrap() + .is_some()); + } + + #[test] + fn missing_directory_and_garbage_files_are_errors_without_contents() { + let dir = tempfile::tempdir().unwrap(); + let lookup = |p: &Path| env(&[("ACSP_PANEL_NOSTR_KEY_FILE", p.to_str().unwrap())]); + + let missing = dir.path().join("absent.key"); + assert!(matches!( + load_panel_secret_with(lookup(&missing)).unwrap_err(), + PanelKeyError::Unreadable { .. } + )); + + assert!(matches!( + load_panel_secret_with(lookup(dir.path())).unwrap_err(), + PanelKeyError::NotRegularFile { .. } + )); + + let garbage = dir.path().join("garbage.key"); + std::fs::write(&garbage, "not-a-key-SENTINEL\n").unwrap(); + std::fs::set_permissions(&garbage, std::fs::Permissions::from_mode(0o600)).unwrap(); + let err = load_panel_secret_with(lookup(&garbage)).unwrap_err(); + assert!(matches!(err, PanelKeyError::InvalidSecret { .. })); + assert!(!err.to_string().contains("SENTINEL")); + } + + #[test] + fn a_minted_file_loads_and_debug_is_redacted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + let minted = mint_key_file(&path).unwrap(); + let s = load_panel_secret_with(env(&[( + "ACSP_PANEL_NOSTR_KEY_FILE", + path.to_str().unwrap(), + )])) + .unwrap() + .unwrap(); + let keys = Keys::new(SecretKey::from_hex(s.secret_hex()).unwrap()); + assert_eq!(keys.public_key().to_hex(), minted.pubkey_hex); + let dbg = format!("{s:?}"); + assert!(dbg.contains("")); + assert!(!dbg.contains(s.secret_hex())); + } +} diff --git a/src/services/acsp/mod.rs b/src/services/acsp/mod.rs index 5d0d49be4..2d19e4806 100644 --- a/src/services/acsp/mod.rs +++ b/src/services/acsp/mod.rs @@ -8,9 +8,12 @@ //! - [`events`] — serde-exact wire types + unsigned-event builders //! - [`client`] — `nostr_sdk`-backed signing, publishing and the kind-31403 //! decision return path +//! - [`key_file`] — minting VisionClaw's own panel key (K_broker) and the one +//! loader every panel signer reads it through pub mod client; pub mod events; +pub mod key_file; pub use client::{AcspClient, CaseDecision}; pub use events::{ diff --git a/src/services/voice_intent_client.rs b/src/services/voice_intent_client.rs index 99794719e..613f41a95 100644 --- a/src/services/voice_intent_client.rs +++ b/src/services/voice_intent_client.rs @@ -137,8 +137,10 @@ impl VoiceIntentClient { /// honest gating, never a fabricated dispatch). Requires: /// - `AGENTBOX_VOICE_INTENT_URL` (full URL) or `AGENTBOX_MANAGEMENT_URL` /// (base; `/v1/voice-intent` is appended); - /// - `ACSP_PANEL_NOSTR_PRIVKEY` (64-hex) — the same panel identity the ACSP - /// producer already uses, reused as the voice mandate signer. + /// - the panel key, resolved by + /// [`crate::services::acsp::key_file::load_panel_secret`] — the same + /// panel identity the ACSP producer uses, reused as the voice mandate + /// signer. pub fn from_env() -> Option> { let endpoint = std::env::var("AGENTBOX_VOICE_INTENT_URL") .ok() @@ -149,13 +151,23 @@ impl VoiceIntentClient { .filter(|s| !s.is_empty()) .map(|base| format!("{}{}", base.trim_end_matches('/'), VOICE_INTENT_PATH)) })?; - let secret = std::env::var("ACSP_PANEL_NOSTR_PRIVKEY") - .ok() - .filter(|s| !s.is_empty())?; - let secret_key = match SecretKey::from_hex(&secret) { + let secret = match crate::services::acsp::key_file::load_panel_secret() { + Ok(Some(s)) => s, + Ok(None) => return None, + Err(e) => { + warn!( + "[voice-intent] panel signing key unusable, governed voice loop disabled: {e}" + ); + return None; + } + }; + let secret_key = match SecretKey::from_hex(secret.secret_hex()) { Ok(k) => k, Err(e) => { - warn!("[voice-intent] ACSP_PANEL_NOSTR_PRIVKEY invalid, governed voice loop disabled: {e}"); + warn!( + "[voice-intent] panel secret from {} invalid, governed voice loop disabled: {e}", + secret.source() + ); return None; } }; diff --git a/tests/mint_nostr_key_cli.rs b/tests/mint_nostr_key_cli.rs new file mode 100644 index 000000000..698e0eee9 --- /dev/null +++ b/tests/mint_nostr_key_cli.rs @@ -0,0 +1,55 @@ +//! End-to-end check of `visionclaw-server mint-nostr-key` as a real process: +//! what reaches the operator's terminal is the pubkey and did:nostr only. + +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +use nostr_sdk::prelude::{Keys, SecretKey, ToBech32}; + +fn mint(out: &std::path::Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_visionclaw-server")) + .args(["mint-nostr-key", "--out"]) + .arg(out) + .env_clear() + .output() + .expect("spawn visionclaw-server") +} + +#[test] +fn mint_subcommand_prints_pubkey_and_did_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + let o = mint(&path); + let stdout = String::from_utf8(o.stdout).unwrap(); + let stderr = String::from_utf8(o.stderr).unwrap(); + assert!(o.status.success(), "stderr: {stderr}"); + assert!(stderr.is_empty(), "stderr must be empty: {stderr}"); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + let secret_hex = std::fs::read_to_string(&path).unwrap().trim().to_string(); + let keys = Keys::new(SecretKey::from_hex(&secret_hex).unwrap()); + let pk = keys.public_key().to_hex(); + assert_eq!(stdout, format!("{pk}\ndid:nostr:{pk}\n")); + for leak in [ + secret_hex.clone(), + secret_hex.to_uppercase(), + keys.secret_key().to_bech32().unwrap(), + ] { + assert!(!stdout.contains(&leak) && !stderr.contains(&leak)); + } +} + +#[test] +fn mint_subcommand_refuses_to_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("k_broker.key"); + assert!(mint(&path).status.success()); + let before = std::fs::read(&path).unwrap(); + let o = mint(&path); + assert_eq!(o.status.code(), Some(1)); + assert!(o.stdout.is_empty()); + assert!(String::from_utf8_lossy(&o.stderr).contains("refusing to overwrite")); + assert_eq!(std::fs::read(&path).unwrap(), before); +}