# Secrets Management

Identity: `ai.stravica.ops.secrets` — CLI alias `secrets`.

Dave uses SOPS with age encryption for secrets management. Secrets are encrypted at rest and stored in `.vault/` within the repository. The encrypted file is safe to commit to git — only the holder of the private key can decrypt it.

## Status

Implemented: `init`, `get`, `set`, `list`, `delete`, `rotate`, `status`, `backup`, `vaults`, `find` (alias `which`), `verify`. No npm dependencies — shells out to `sops` and `age` via `node:child_process.spawn`.

**Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20.** A plain `secrets set` on a scoped vault writes canonical FIRST and workspace SECOND whenever a canonical clone for the scope is discoverable on this host (via `~/.config/stravica-pa/defaults.json`'s `canonicalClone` key or the `~/stravica-canonical/*/vault/<scope>.enc.json` estate convention). Absence of a canonical clone is a stderr NOTICE, never a failure. Explicit `--canonical-clone PATH` still wins over auto-discovery (and bootstraps a missing file); `--no-canonical` opts out entirely and suppresses the notice. `verify --values --against PATH` does a byte-level comparison of DECRYPTED values between the resolved vault and a reference vault at PATH — never a decrypt-only pass. See "Value-compare verification" and "Canonical-first writes" below.

`get`/`set` run without `--scope`/`--file` now resolve a **default scope** — `$SECRETS_DEFAULT_SCOPE` env var, then `"defaultScope"` in `~/.config/stravica-pa/defaults.json`, then the calling PA's own `agents/<name>/identity.json`, then the legacy single-file vault as a last resort. Every such invocation prints a one-line stderr notice naming the target vault BEFORE the operation runs, including the legacy-fallback case ("no scope specified and no default configured") — see `secrets --help` → "Default scope" for the full precedence and example notices.

## Prerequisites

Two tools are required:

- **age** — modern encryption tool
  - macOS: `brew install age`
  - Debian/Ubuntu: `sudo apt install age`
  - Manual: https://github.com/FiloSottile/age/releases/latest

- **SOPS** — encrypted file editor
  - macOS: `brew install sops`
  - Debian/Ubuntu: download from https://github.com/getsops/sops/releases
  - Manual: https://github.com/getsops/sops/releases/latest

## Invoke

The CLI shim is `core/tools/secrets/bin/secrets`. You can run it directly with `node`:

```bash
node core/tools/secrets/bin/secrets <verb> [ARGS]
```

Or via the per-tool symlink convention (see tooling standard §14):

```bash
secrets <verb> [ARGS]
```

## Commands

```bash
# Initialise the vault (generate keys, create config, empty encrypted store)
node core/tools/secrets/bin/secrets init

# Store a secret
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123"

# Retrieve a secret
node core/tools/secrets/bin/secrets get API_KEY

# List all secret names (values are not shown)
node core/tools/secrets/bin/secrets list

# Remove a secret
node core/tools/secrets/bin/secrets delete API_KEY

# Re-encrypt all secrets with the current recipients
node core/tools/secrets/bin/secrets rotate

# Check vault health (exit 0 when initialised, 1 otherwise)
node core/tools/secrets/bin/secrets status

# Open a browser with a QR code of the master key for backup
node core/tools/secrets/bin/secrets backup

# Force a specific canonical file (wins over auto-discovery; bootstraps
# a missing file). Aborts (exit 2) before touching the workspace copy if
# the canonical write fails (e.g. the existing canonical store can't be
# decrypted). Auto-discovery is on by default since 0.1.20 — this flag
# is only for pointing at a specific target.
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --canonical-clone /path/to/canonical/vault/scope.enc.json

# Opt out of canonical dual-write entirely (suppresses the absence
# notice too). Mutually exclusive with --canonical-clone.
node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical

# Byte-level comparison of DECRYPTED values against a reference vault.
# --values is required — there is no decrypt-only verification mode
# (decrypt-only false-passes a vault that decrypts cleanly but holds the
# wrong content, e.g. a clobbered/stale copy with placeholder values).
node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
```

### Full-scope reference form (get/set/delete/find)

Barry ruling 2026-07-15 (`w-2026-07-15-023`): every secret is referred
to by its full scope path at every non-interactive reference site. The
scope is named at the call site so provenance is visible and same-name
keys in different scopes cannot silently collide (a real problem today,
`secrets find CLOUDFLARE_WORKERS_ACCOUNT_ID_STRAVICA` returns matches
in both `hq-estate` and `wsd`).

```bash
secrets get hq-estate/CLERK_AUTH_KEY
secrets set clients-clive/BUNNY_API_KEY_CLIVE -
secrets delete seo-ops/OPENAI_API_KEY
secrets find hq-estate/GITHUB_PAT_MAX_READ
```

Each is equivalent to the corresponding `--scope <name> <verb> KEY`
form. The two may be combined when they agree; a mismatch is a hard
error (exit 1). `--file` is not combinable with the reference form (use
one or the other). Bare-key calls still work and remain the default for
interactive use, but emit a one-line deprecation notice on stderr
naming the preferred `scope/KEY` form for the target vault.

### Presence check (`get KEY --exists`)

`secrets get KEY --exists` (alias `--dry-run`) probes for the key
without ever decrypting. Prints `present` and exits 0 when the key
resolves in the target vault; prints `absent` and exits 1 when it does
not. Combines with `--scope`, `--file`, and the `scope/KEY` reference
form. Use it as the migration proof primitive when moving a reference
site to the full-scope form: run `secrets get scope/KEY --exists` and
compare against the pre-migration equivalent to prove identity, without
ever handling the secret value itself.

### Canonical-first writes

Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
a plain `secrets set` on a scoped vault writes canonical FIRST and the
workspace SECOND whenever a canonical clone for the scope is discoverable
on this host. The abort-before-workspace ordering is identical to the
explicit-flag path it grew out of: if the canonical write fails for any
reason (existing canonical store undecryptable, sops encrypt failure),
the command exits 2 and the workspace copy is never touched.

**Resolution order** (first hit wins; every miss recorded in the absence
notice's tried-tier detail):

1. **Explicit `--canonical-clone PATH`** — always wins, bootstraps a
   missing file (this is the flag's historical behaviour).
2. **`"canonicalClone"` in `~/.config/stravica-pa/defaults.json`** — the
   same file (already read for `workspaceRoot` and `defaultScope`); the
   CLI never writes it. Two shapes supported:
   - **Object (scope → file map):**
     ```json
     { "canonicalClone": { "seo-ops": "/Users/thefoot/stravica-canonical/stravica-operator-barry/vault/seo-ops.enc.json" } }
     ```
   - **String (root dir)** — derives `<root>/vault/<scope>.enc.json`,
     which fits the estate's operator-repo layout:
     ```json
     { "canonicalClone": "/Users/thefoot/stravica-canonical/stravica-operator-barry" }
     ```
3. **Estate convention** — `~/stravica-canonical/<repo>/vault/<scope>.enc.json`
   (glob shape `~/stravica-canonical/*/vault/…enc.json`). This reuses the
   root the rest of the estate already uses (see
   `library-sync-cycle`'s `--repos-root` default and
   `dex-cutover-driver`'s `reposRoot`). Exactly one match wins; ambiguity
   (multiple matches) or zero matches skips this tier.

**Absence** is a stderr notice, never a failure — a host with no
canonical layout keeps writing to workspace only:

```
notice: no canonical clone configured for scope 'seo-ops' on this host — workspace-only write (tried: …)
```

The `tried:` tail names every tier that missed and why, so an operator on
a host that ought to have a canonical knows exactly where to configure it
(or that `--no-canonical` is the intended answer for a host that never
had one).

**Flags:**

- `--canonical-clone PATH` — force a specific canonical file (wins over
  auto-discovery; bootstraps a missing file). Backwards-compatible with
  every prior release.
- `--no-canonical` — opt out of dual-write entirely, and suppress the
  absence notice. Mutually exclusive with `--canonical-clone`.

This closes the "device-side vault mutation only round-trips halfway"
class BY DEFAULT rather than opt-in: any workspace write that a
canonical layout could catch, it now does — no flag required.

Legacy vaults (no scope) never trigger canonical resolution — the notice
would be pure noise for a fallback that has no natural canonical mirror.

### Value-compare verification (`verify --values --against`)

Any vault verification that stops at "the store decrypts" is a false-pass
waiting to happen: an always-take-canonical materialisation can silently
overwrite a device's real secret values with stale or placeholder
content, and the decrypt still succeeds (same recipients) — only a
byte-level VALUE comparison against a known-good reference catches it.
`secrets verify` therefore has no decrypt-only mode at all; `--values` is
required, and omitting it is a usage error (exit 1), not a lesser check.

`--against PATH` names the reference vault (any vault file, resolved the
same way `--file` resolves elsewhere). Both vaults are decrypted and
compared key-by-key; the report never prints a decrypted value, only the
per-key result:

- `same` — both sides hold byte-identical values
- `differs` — both sides have the key, values differ
- `missing-in-reference` — key exists in the working vault only
- `missing-in-working` — key exists in the reference vault only

Exit 0 only when every key is `same`; exit 2 otherwise (or if either side
fails to decrypt at all).

## Exit codes

| Code | Meaning |
|---|---|
| `0` | OK |
| `1` | Usage / not-initialised / not-found |
| `2` | Operational failure (sops decrypt or encrypt failed, bad JSON) |
| `3` | Transport failure (spawn failed) |

## Operational discipline

- The private key must never be committed. It lives only at `~/.config/dave/age-key.txt`, with mode `600` on a `700` key directory.
- All sops/age invocations are spawned with arguments as an array — no shell interpolation surface.
- Tests must never touch the real `~/.config/dave/` or repo `.vault/`. The test helpers mint a disposable temp directory for every case.

## Project layout

```
core/tools/secrets/
  bin/secrets            # node shebang shim — one line
  package.json           # name: ai.stravica.ops.secrets, type: module, no deps
  README.md              # this file
  src/
    cli.mjs              # verb routing + exit-code translation
    vault.mjs            # domain + sops/age spawn chokepoint
    commands/
      init.mjs           # generate keypair, write recipients/config, empty store
      get.mjs            # print a single secret value
      set.mjs            # upsert and re-encrypt
      list.mjs           # sorted key names
      delete.mjs         # remove and re-encrypt
      rotate.mjs         # decrypt + re-encrypt with current recipients
      status.mjs         # vault health report
      backup.mjs         # render QR-code HTML and open it in the default browser
      backup-template.mjs  # HTML + embedded alphanumeric QR generator
      vaults.mjs         # enumerate every visible vault (names only, no decryption)
      find.mjs           # report which vault(s) contain a key by name (alias: which)
      verify.mjs         # byte-level DECRYPTED-value comparison against a reference vault
  test/
    helpers.mjs          # temp-vault isolation
    *.test.mjs           # node:test coverage per subcommand + CLI routing
```

## Security model

- The **private key** (`~/.config/dave/age-key.txt`) never enters the repository. It is gitignored and lives only on the machine where secrets need to be decrypted.
- The **public key** (`.vault/recipients.txt`) is committed. It allows anyone with repo access to encrypt new secrets, but only the private key holder can decrypt them.
- The **encrypted secrets file** (`.vault/secrets.enc.json`) is committed. It is safe to store in git because its contents are encrypted with the age public key.
- SOPS handles encryption/decryption transparently. The `.vault/.sops.yaml` config tells SOPS which key to use.

## Key rotation

If you need to rotate keys (e.g. a machine was compromised):

1. Generate a new keypair: `age-keygen -o ~/.config/dave/age-key.txt`
2. Update `.vault/recipients.txt` with the new public key
3. Update `.vault/.sops.yaml` with the new public key
4. Run `node core/tools/secrets/bin/secrets rotate` to re-encrypt all secrets with the new key

Note: you need the old private key present to decrypt before re-encrypting. If the old key is lost, the secrets cannot be recovered.

## Multi-machine setup

To use secrets on a second machine:

1. Clone the repository (public key and encrypted secrets are included)
2. Copy your private key to `~/.config/dave/age-key.txt` on the new machine
3. Secrets are immediately accessible via `node core/tools/secrets/bin/secrets get`

The private key must be transferred securely (e.g. via a password manager, secure file transfer, or physical media). Never send it over unencrypted channels.

## Backup

Back up `~/.config/dave/age-key.txt` securely. If you lose it, your encrypted secrets are unrecoverable. The encrypted file in git is useless without the private key.

The `backup` command renders the master key as a QR code in a temporary HTML file and opens it in the default browser. The file self-destructs after 30 seconds. No browser-automation dependency — the QR generator is a small pure-JS module embedded in the generated HTML.

## Tests

```bash
node --test core/tools/secrets/test/*.test.mjs
```

The integration tests require `sops` and `age` on PATH and skip gracefully when either is missing. The CLI-routing tests always run.

## The tooling standard

This tool conforms to the standard in [`core/knowledge/standards/tooling.md`](../../knowledge/standards/tooling.md). Notable points:

- Zero npm dependencies. Node stdlib only.
- All external effects (sops, age, fs writes in the vault path) route through `src/vault.mjs`.
- Commands return numeric exit codes or throw `VaultError` with an `exitCode` property. The CLI wrapper translates to `process.exit`.
- JSON property names are camelCase.
