feat(config): encrypted local-settings store replacing .msh (VA-33) #30

Merged
nrupard merged 2 commits from feat/encrypted-settings-store-VA-33 into main 2026-05-21 21:00:24 +02:00
Owner

Implements the VA-29 design (docs/local-settings-store.md). Closes VA-33.

Replaces the plaintext .msh config and its text parser with an at-rest encrypted local-settings store.

What changed

  • Whole-file AEAD store: the settings map serializes to a length-prefixed blob and is sealed with ChaCha20-Poly1305 under an HKDF-SHA-384 key derived from a 256-bit per-install secret (all via ring, already in the dep graph). Layout is magic || version || kdf_id || aead_id || reserved || nonce(12) || ciphertext || tag(16), with the 20-byte header authenticated as AEAD associated data so an algorithm/version downgrade is caught.
  • Locator: the store filename is base32(SHA-384(install_secret)[..20]).vss, a one-way commitment that leaks neither the secret nor the key. The secret lives in a separate 0600 install.secret sidecar (the confirmed default), so bytes copied off-host without it stay undecryptable.
  • Fail-closed: bad tag, truncation, bad magic, or unsupported algorithm all refuse to start; key material is zeroize-wrapped.
  • MshConfig -> SettingsMap. The .msh text reader survives only behind the migrate feature for the one-shot migration. ServerSettings / ConnectionSettings and their tests are unchanged.
  • CLI: --msh <file> -> --settings <dir>, plus --secret-file. New config subcommand: show [--reveal], get, set, seal.
  • Migration: on first run, an existing .vss wins; else a sibling vervain-agent.msh is migrated and rotated to .msh.bak (rename, never delete); else a clear error tells the operator to seal one.
  • install seals its --from seed into the store and provisions the sidecar; the systemd unit runs with --settings; uninstall removes the .vss + sidecar.
  • justfile/compose: ensure-msh{,-host} -> ensure-settings{,-host} (render the plaintext seed, clear any stale sealed store so a re-render takes effect); dev recipes and the app container run with --settings.

Acceptance criteria

  • .vss round-trips; all required + optional keys preserved (sealed_round_trip_preserves_all_keys).
  • ServerSettings / ConnectionSettings tests pass unchanged.
  • One-shot .msh -> .vss migration with .msh.bak rotation, tested (migrates_legacy_msh_and_rotates_to_bak).
  • AEAD open failure fail-closed, tested for bad tag / truncation / header downgrade / bad magic.
  • config show / get / set (and seal) subcommands land.
  • justfile + templates updated; dev recipes run with --settings.
  • No new crate beyond the base32 helper (data-encoding, already in the lock).

Notes

  • The .msh.template filename is kept (not renamed to settings.template as the design sketched) because vervain-agent.msh is the load-bearing name the migration path looks for. The rendered seed is still plaintext dev input, not a secret.
  • Encrypting the sled identity store, TPM/keychain sealing, and Windows DPAPI sidecar storage remain out of scope per the design.

Verification

cargo clippy --workspace --all-targets -- --deny warnings and cargo fmt --all --check are clean. cargo test --workspace passes except agent_completes_secondary_handshake, which fails identically on clean main in the dev container (it asserts PlatformType::Desktop but virtualization detection reports Virtual inside Docker). Pre-existing and unrelated to this change.

Implements the VA-29 design (`docs/local-settings-store.md`). Closes VA-33. Replaces the plaintext `.msh` config and its text parser with an at-rest encrypted local-settings store. ## What changed - Whole-file AEAD store: the settings map serializes to a length-prefixed blob and is sealed with ChaCha20-Poly1305 under an HKDF-SHA-384 key derived from a 256-bit per-install secret (all via `ring`, already in the dep graph). Layout is `magic || version || kdf_id || aead_id || reserved || nonce(12) || ciphertext || tag(16)`, with the 20-byte header authenticated as AEAD associated data so an algorithm/version downgrade is caught. - Locator: the store filename is `base32(SHA-384(install_secret)[..20]).vss`, a one-way commitment that leaks neither the secret nor the key. The secret lives in a separate `0600` `install.secret` sidecar (the confirmed default), so bytes copied off-host without it stay undecryptable. - Fail-closed: bad tag, truncation, bad magic, or unsupported algorithm all refuse to start; key material is `zeroize`-wrapped. - `MshConfig` -> `SettingsMap`. The `.msh` text reader survives only behind the `migrate` feature for the one-shot migration. `ServerSettings` / `ConnectionSettings` and their tests are unchanged. - CLI: `--msh <file>` -> `--settings <dir>`, plus `--secret-file`. New `config` subcommand: `show [--reveal]`, `get`, `set`, `seal`. - Migration: on first run, an existing `.vss` wins; else a sibling `vervain-agent.msh` is migrated and rotated to `.msh.bak` (rename, never delete); else a clear error tells the operator to seal one. - `install` seals its `--from` seed into the store and provisions the sidecar; the systemd unit runs with `--settings`; uninstall removes the `.vss` + sidecar. - justfile/compose: `ensure-msh{,-host}` -> `ensure-settings{,-host}` (render the plaintext seed, clear any stale sealed store so a re-render takes effect); dev recipes and the app container run with `--settings`. ## Acceptance criteria - [x] `.vss` round-trips; all required + optional keys preserved (`sealed_round_trip_preserves_all_keys`). - [x] `ServerSettings` / `ConnectionSettings` tests pass unchanged. - [x] One-shot `.msh` -> `.vss` migration with `.msh.bak` rotation, tested (`migrates_legacy_msh_and_rotates_to_bak`). - [x] AEAD open failure fail-closed, tested for bad tag / truncation / header downgrade / bad magic. - [x] `config show / get / set` (and `seal`) subcommands land. - [x] justfile + templates updated; dev recipes run with `--settings`. - [x] No new crate beyond the base32 helper (`data-encoding`, already in the lock). ## Notes - The `.msh.template` filename is kept (not renamed to `settings.template` as the design sketched) because `vervain-agent.msh` is the load-bearing name the migration path looks for. The rendered seed is still plaintext dev input, not a secret. - Encrypting the sled identity store, TPM/keychain sealing, and Windows DPAPI sidecar storage remain out of scope per the design. ## Verification `cargo clippy --workspace --all-targets -- --deny warnings` and `cargo fmt --all --check` are clean. `cargo test --workspace` passes except `agent_completes_secondary_handshake`, which fails identically on clean `main` in the dev container (it asserts `PlatformType::Desktop` but virtualization detection reports `Virtual` inside Docker). Pre-existing and unrelated to this change.
feat(config): encrypted local-settings store replacing .msh (VA-33)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m59s
81b1f27448
Implements docs/local-settings-store.md. Replaces the plaintext .msh config and the line-oriented text parser with an at-rest encrypted store: the settings map is serialized to a length-prefixed blob and sealed with ChaCha20-Poly1305 under a key derived (HKDF-SHA-384) from a 256-bit per-install secret. The store filename is a SHA-384 commitment to that secret (base32, `<stem>.vss`), and the secret lives in a separate 0600 `install.secret` sidecar so the bytes stay undecryptable when copied off-host without it. The 20-byte header (magic, version, algorithm ids, nonce) is authenticated as AEAD associated data, so an algorithm/version downgrade is detected. Decryption is fail-closed on a bad tag, truncation, bad magic, or unsupported algorithm.

vervain-agent-config: `MshConfig` becomes `SettingsMap`; the text parser (`FromStr`, `parse_msh_text`) is retained only behind the `migrate` feature for the one-shot migration; adds `serialize`/`deserialize`, `open_file`/`seal_to_file`, and the `store` module (ring HKDF + ChaCha20-Poly1305 + SystemRandom, zeroize-wrapped key material). `ServerSettings` / `ConnectionSettings` and their tests are unchanged.

vervain-agent: new `settings` module resolves the store directory, loads (existing `.vss` wins, else migrates a sibling `vervain-agent.msh` and rotates it to `.msh.bak`, else errors), and seals from a plaintext seed. CLI: `--msh <file>` becomes `--settings <dir>` plus `--secret-file`; adds a `config` subcommand (`show [--reveal]`, `get`, `set`, `seal`). `install` seals its seed (`--from`) into the store and provisions the sidecar; the systemd unit runs with `--settings`; uninstall removes the `.vss` + sidecar.

justfile/compose: `ensure-msh{,-host}` become `ensure-settings{,-host}`, which render the plaintext seed and clear any stale sealed store so a re-render takes effect; the agent migrates the seed to a `.vss` on startup. Dev recipes and the app container run with `--settings`.

The `.msh.template` filename is kept (not renamed to `settings.template` as the design sketched) because `vervain-agent.msh` is the load-bearing name the migration path looks for; the rendered seed is still plaintext dev input, not a secret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(config): address VA-33 review (read-only check, no orphan secret)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m25s
8f2f1eafcc
- `--check` now uses a read-only load path (`settings::load_readonly`) that opens an existing `.vss` or parses a not-yet-migrated `.msh` in place, without creating a secret, sealing a `.vss`, or rotating the legacy file. A validation run no longer mutates disk.
- `settings::load` reads the secret read-only first and only creates `install.secret` when there is a seed to seal, so a failed (no-config) load leaves no orphan secret file.
- `seal_to_file` removes any stale `<stem>.vss.tmp` before writing (so a looser mode from an interrupted seal is never reused) and fsyncs the file and its parent directory so the rename is durable.
- `install` uninstall cleanup now also removes `*.vss.tmp` leftovers.
- Dropped the unused `StoreError::MalformedPayload` variant.
- Renamed the local `settings` binding in `main` to `server_settings` so it no longer shadows the `settings` module.

Tests: added `failed_load_leaves_no_orphan_secret`, `load_readonly_does_not_mutate_disk`, `load_readonly_reads_sealed_store`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nrupard deleted branch feat/encrypted-settings-store-VA-33 2026-05-21 21:00:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-agent!30
No description provided.