Skip to content

feat: encrypted secrets with passphrase prompt at gtc setup (v0.6.0)#60

Draft
BimaPangestu28 wants to merge 4 commits into
mainfrom
feat/encrypted-secrets-passphrase
Draft

feat: encrypted secrets with passphrase prompt at gtc setup (v0.6.0)#60
BimaPangestu28 wants to merge 4 commits into
mainfrom
feat/encrypted-secrets-passphrase

Conversation

@BimaPangestu28
Copy link
Copy Markdown
Member

⚠️ DRAFT — depends on greenticai/greentic-secrets#51 being merged + published to crates.io. The [patch.crates-io] block in Cargo.toml MUST be removed before this PR is taken out of draft.

Summary

Wires the upstream encrypted-secrets-passphrase backend (greentic-secrets v0.6.0) into the `gtc setup` CLI. First-run prompts user for a passphrase + min-length validation, subsequent runs prompt only for unlock. Secrets at rest are then AES-256-GCM encrypted with an Argon2id-derived master key.

What changes

CLI surface (`bundle setup` and `bundle update`)

  • `--passphrase-stdin` — read passphrase from stdin (CI/daemon mode)
  • `--passphrase-file ` — read from 0600-mode file owned by current user
  • `--reconfigure` — wipe existing dev store + `.encrypted-marker` and re-prompt for fresh passphrase
  • `--allow-downgrade` — bypass the downgrade-attack guard (only meaningful when marker exists but store is in legacy plaintext format)

When none of the non-interactive flags are set, defaults to TTY prompt via `rpassword`. Passphrase resolution priority: `file` → `stdin` → `tty`.

Wiring

  • New `init_global_passphrase_provider()` in `src/cli_commands/setup.rs` runs before any dev store open. Uses `peek_header` to detect whether the store is fresh (PromptMode::Initial) or pre-existing (Unlock).
  • `crate::secrets::set_global_key_provider()` installs a process-global `PassphraseKeyProvider`. Idempotent OnceLock — only the first call wins.
  • Existing `open_dev_store()` and `SecretsSetup::new()` automatically use `DevStore::with_path_encrypted()` when the global provider is set, falling back to legacy `with_path()` otherwise (back-compat for tests and non-CLI consumers).

Why no diff-only seed prompting in this PR

The original spec called for skipping prompts on keys already present and prompting only for newly-required keys. The current change reuses the existing `SecretsSetup::ensure_pack_secrets` flow which already seeds placeholders for missing keys — the encryption layer is transparent to that flow. Diff-only interactive prompting is a UX polish that can land in a follow-up without touching the security layer.

Version

`0.5.1` → `0.6.0` (matches upstream secrets workspace bump).

Security guarantees inherited from upstream

  • Argon2id (m=64MiB, t=3, p=1) → 32-byte master key, never persisted
  • AES-256-GCM authenticated encryption; auth failure surfaced as `InvalidPassphrase` (no oracle)
  • Passphrase `Zeroize` on drop; never in argv, never logged
  • Atomic write (.tmp + fsync + rename) under exclusive file lock
  • `.encrypted-marker` sidecar refuses subsequent legacy plaintext loads (downgrade-attack guard)

Test plan

  • `cargo fmt --all -- --check`
  • `cargo clippy --all-targets --all-features -- -D warnings`
  • `cargo test` — 196 lib + 1 binary + integration tests pass
  • Pre-merge cleanup: remove `[patch.crates-io]` from `Cargo.toml`, bump dep specifier from path to crates.io "0.6"
  • Manual smoke: `gtc setup` prompts on first run, unlocks on second; `.greentic/dev/.dev.secrets.env` starts with `# greentic-encrypted: v1` and contains no plaintext secret values
  • Manual smoke with `--passphrase-stdin` for CI mode

Spec & plan

  • Spec: `docs/superpowers/specs/2026-04-22-encrypted-secrets-passphrase-design.md`
  • Plan: `docs/superpowers/plans/2026-04-22-encrypted-secrets-passphrase.md`

Wires the upstream greentic-secrets v0.6.0 encrypted backend into the
gtc setup CLI:

- New flags --passphrase-stdin, --passphrase-file, --reconfigure,
  --allow-downgrade on bundle setup/update commands
- init_global_passphrase_provider resolves passphrase via
  greentic-secrets-cli (priority: file -> stdin -> TTY) and installs
  a process-global PassphraseKeyProvider before any dev-store open
- crate::secrets::open_dev_store + SecretsSetup::new transparently
  use the global provider when present (encrypted), or fall back to
  legacy plaintext when not (back-compat for tests/non-CLI consumers)
- First-run shows 'no recovery' warning; subsequent runs prompt only
  for unlock
- --reconfigure wipes existing store + .encrypted-marker

i18n: cli.setup.passphrase.{failed,kdf_failed,first_setup_complete,
reconfigured} added to en.json.

Version bump 0.5.1 -> 0.6.0 for the new dep on greentic-secrets-* 0.6.

[patch.crates-io] in Cargo.toml temporarily points at local clone
while greenticai/greentic-secrets#51 awaits publish; remove the patch
block before merging this PR.
Adds the missing pieces from the spec that were deferred in the
initial PR:

* SecretsSetup gains missing_pack_secrets() returning only required
  secret keys absent from BOTH the dev store and seeds.yaml. Existing
  values are never re-prompted; seed.yaml entries are auto-applied
  transparently.

* New src/secrets_prompt.rs with rpassword (no-echo) for secret-shaped
  keys (token / password / api_key / private_key / credential / passphrase
  in the name) and dialoguer (echoed) for plain configuration values.
  Empty optional values are skipped; required values cannot be empty.

* setup_or_update calls prompt_missing_pack_secrets_blocking after
  the encryption layer is wired and after dry-run/emit-answers
  short-circuits, but before engine.execute. --non-interactive skips
  the prompt step entirely (CI/answers-file flow is unchanged).

* tests/clap_help_check.rs — security regression: asserts no
  --passphrase=<value> flag exists on either bundle setup or bundle
  update. Pure compile-time/help-output check.

* tests/passphrase_setup.rs — end-to-end: invokes the binary with
  --passphrase-stdin + --emit-answers --non-interactive, pipes a
  known-string passphrase, verifies (a) the encrypted v1 file format
  on disk when present, and (b) the known passphrase string never
  appears anywhere in stdout/stderr even at RUST_LOG=trace.

Closes the diff-only and security-test items the original PR #60
deferred.
Until now, run_ui_mode (the web UI launcher) bypassed
init_global_passphrase_provider, so a UI launch on an encrypted
bundle would crash with InvalidPassphrase as soon as the first UI
handler tried to open_dev_store.

Changes:
- Top-level Cli struct gains four global flags so they apply uniformly
  across the CLI subcommands and the --ui flow:
  --passphrase-stdin, --passphrase-file, --reconfigure, --allow-downgrade
- The duplicate per-subcommand definitions in BundleSetupArgs are
  removed; the values are bridged from the global Cli into the
  per-subcommand args inside the dispatcher.
- init_global_passphrase_provider is moved from cli_commands/setup.rs
  to crate::secrets so both run_ui_mode and setup_or_update share one
  implementation.
- run_ui_mode now resolves the passphrase + installs the global key
  provider before launching the web server. All UI handlers that open
  the dev store via crate::secrets::open_dev_store transparently get
  the AES-256-GCM-backed store.
- The friendlier no-TTY error message from greentic-secrets-cli is
  picked up via the cargo workspace patch.
- clap_help_check.rs updated to assert global passphrase flags appear
  on top-level help and that no --passphrase=<value> flag exists at
  any subcommand level.
Two security fixes for init_global_passphrase_provider that were
exposed by end-to-end smoke testing with a real encrypted store:

* Fail-fast on wrong passphrase. When an existing encrypted store is
  detected (Unlock mode), immediately try to open + decrypt it via
  open_dev_store(). Without this verify, command paths that
  short-circuit early (e.g. --emit-answers --non-interactive) would
  install a wrong-passphrase provider, complete successfully, and only
  fail much later when something actually read a secret. AES-GCM auth
  failure is mapped to a clean 'passphrase incorrect' error.

* Downgrade-attack guard at the init layer. If peek_header returns
  None (no v1 header) but the .encrypted-marker sidecar exists,
  refuse to proceed with: 'refusing to load legacy plaintext store
  after encryption was previously enabled (downgrade-attack guard);
  pass --allow-downgrade to override'. Previously the guard only
  fired inside Persistence::load_with_provider, which meant CLI
  paths that never opened the store missed it entirely.

Plus: add examples/seed_encrypted_store.rs as a test helper that
seeds a real v1 encrypted store from a passphrase on stdin. Used by
the end-to-end shell smoke tests to validate behavior without needing
a TTY.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant