fix(daemon): bound nunciod's own keyring reads #424

Merged
KofTwentyTwo merged 1 commits from fix/bound-daemon-keyring into dev 2026-08-15 01:13:58 +00:00
KofTwentyTwo commented 2026-08-15 01:06:23 +00:00 (Migrated from github.com)

Closes #421.

The problem

#420 bounded the CLI's keyring read. nunciod's own reads were left unbounded and explicitly reported as unfixed.

The daemon mostly mints its key material, so it does not normally hit the cross-binary ACL prompt — but "does not normally" is not "cannot". A keychain locked by screen-lock, a migrated or restored keychain whose ACL no longer trusts the binary, a re-signed or rebuilt nunciod, or a Linux Secret Service with no unlocked collection all block the read. When that happened, the daemon hung at startup with no error and no log line: the worst possible failure shape, and exactly what the CLI was just fixed for.

The change

Two daemon reads are now bounded:

  • gRPC bearer token (main.rs, at startup) — a blocked read now ends in the same clean, logged startup failure the daemon already used for other keyring errors. Only the hang case newly reaches it; the failure shape is unchanged.
  • Webhook signing key (outbox.rs, provisioned lazily on first CALL WEBHOOK) — a blocked read now fails that one dispatch honestly instead of parking the outbox worker.

Both errors name which key material could not be read and suggest the likely cause (locked keychain, or an ACL that no longer trusts this binary). Never a silent stall, never a fabricated default.

The bounded read itself moved into nuncio_store::vault::get_or_create_key_bytes_bounded, so the CLI and daemon share one implementation rather than two copies. The CLI's behaviour and its user-facing messages are unchanged — it keeps its own NUNCIO_GRPC_TOKEN phrasing for the timeout case.

It still runs on a detached std::thread behind a tokio::time::timeout, not spawn_blocking: a platform keyring read cannot be cancelled, so bounding it means abandoning the thread — and Tokio joins its blocking pool during runtime shutdown, which would merely relocate the hang to process exit. That reasoning is now written down next to the helper.

Deliberately not done

  • No environment-variable token override for the daemon. #420 kept that CLI-only on purpose; nunciod mints and owns the authoritative token and must never accept one from its environment.
  • No refactor of the CLI's working path beyond what sharing the helper required.

Security

Errors carry only the vault account name (a purpose, e.g. grpc-bearer-token), never key material. The no-secrets canary (crates/nunciod/tests/no_secrets_canary_test.rs) still passes; the new tests assert independently that a sentinel key never appears in any error string.

Tests

  • crates/nuncio-store/src/vault.rs — a vault whose reads never return produces a TimedOut error inside the bound, naming the key material and the cause; the normal mint/reload path returns exactly what the unbounded read does; a genuine vault failure is reported as a vault failure, not a timeout.
  • crates/nunciod/tests/daemon_keyring_bound_test.rs — the same three properties for both daemon reads, plus a check that the working path still mints and reloads a stable token.

Gate

cargo fmt --all -- --check, cargo check-all, cargo test-all all green; RUST_TEST_THREADS=2 cargo test-all run three times with zero FAILED lines.

Closes #421. ## The problem #420 bounded the **CLI's** keyring read. `nunciod`'s own reads were left unbounded and explicitly reported as unfixed. The daemon mostly *mints* its key material, so it does not normally hit the cross-binary ACL prompt — but "does not normally" is not "cannot". A keychain locked by screen-lock, a migrated or restored keychain whose ACL no longer trusts the binary, a re-signed or rebuilt `nunciod`, or a Linux Secret Service with no unlocked collection all block the read. When that happened, the daemon hung at startup with no error and no log line: the worst possible failure shape, and exactly what the CLI was just fixed for. ## The change Two daemon reads are now bounded: - **gRPC bearer token** (`main.rs`, at startup) — a blocked read now ends in the same clean, logged startup failure the daemon already used for other keyring errors. Only the hang case newly reaches it; the failure shape is unchanged. - **Webhook signing key** (`outbox.rs`, provisioned lazily on first `CALL WEBHOOK`) — a blocked read now fails that one dispatch honestly instead of parking the outbox worker. Both errors name which key material could not be read and suggest the likely cause (locked keychain, or an ACL that no longer trusts this binary). Never a silent stall, never a fabricated default. The bounded read itself moved into `nuncio_store::vault::get_or_create_key_bytes_bounded`, so the CLI and daemon share one implementation rather than two copies. The CLI's behaviour and its user-facing messages are unchanged — it keeps its own `NUNCIO_GRPC_TOKEN` phrasing for the timeout case. It still runs on a detached `std::thread` behind a `tokio::time::timeout`, not `spawn_blocking`: a platform keyring read cannot be cancelled, so bounding it means abandoning the thread — and Tokio joins its blocking pool during runtime shutdown, which would merely relocate the hang to process exit. That reasoning is now written down next to the helper. ## Deliberately not done - **No environment-variable token override for the daemon.** #420 kept that CLI-only on purpose; `nunciod` mints and owns the authoritative token and must never accept one from its environment. - No refactor of the CLI's working path beyond what sharing the helper required. ## Security Errors carry only the vault *account name* (a purpose, e.g. `grpc-bearer-token`), never key material. The no-secrets canary (`crates/nunciod/tests/no_secrets_canary_test.rs`) still passes; the new tests assert independently that a sentinel key never appears in any error string. ## Tests - `crates/nuncio-store/src/vault.rs` — a vault whose reads never return produces a `TimedOut` error inside the bound, naming the key material and the cause; the normal mint/reload path returns exactly what the unbounded read does; a genuine vault failure is reported as a vault failure, not a timeout. - `crates/nunciod/tests/daemon_keyring_bound_test.rs` — the same three properties for both daemon reads, plus a check that the working path still mints and reloads a stable token. ## Gate `cargo fmt --all -- --check`, `cargo check-all`, `cargo test-all` all green; `RUST_TEST_THREADS=2 cargo test-all` run three times with zero `FAILED` lines.
Sign in to join this conversation.