Plan: Repo-Committed Encrypted Secrets (SOPS + age)
On this page
Status
| Step | Description | Status |
|---|---|---|
U0 |
Docs first: this plan page + ADR-064 + nav entries + epic &79 with children #1378–#1386 + the #1031 re-scope to the G-A/G-B/G-C maintainer gates + the security-baseline amendment escalation (claude-quickstart#27). No code changes. Docs-size exception named (migration-gate M0 precedent). Relates #1032. |
Done (2026-08-10) — this MR |
G-A |
Maintainer gate (#1031): dev + CI + offline-recovery keypairs; |
Done (2026-08-10) — executed AGENT-side under explicit maintainer authorization
(#1031 note 3668427845: devstack-only exercise of the mechanism; a production
deployment would never let agent context near key material). Dev = the pre-existing
canopy-shared identity (the detect-existing |
U1 |
Hardened devtools runner ( |
Done (2026-08-10) — this MR (image pins: alpine:3.23 by digest, age 1.2.1-r15 |
U2 |
|
Done (2026-08-10) — this MR (as-built deltas: the bootstrap plaintext rides STDIN only
— no tmpfs file at all; encryption is POLICY-driven via |
U3 |
Recipient management: |
Done (2026-08-10) — this MR (as-built deltas: remove-recipient re-encrypts from
scratch — a fresh encryption mints a fresh sops data key, so |
U4 |
Commit |
Done (2026-08-10) — this MR (as-built deltas: the two planned lints shipped as ONE
|
G-B |
CI-keypair proof (#1031): the |
Done (2026-08-10) — job 15817105321 on main pipeline 2748471196 (the !1328 merge): "keyed decrypt proof OK: the CI identity decrypts the store; 32-byte key verified, value undisclosed" (37 s). Recorded on #1031. |
U5 |
Field-key store reconciliation: resolution-first |
Done (2026-08-10) — this MR (as-built deltas: the pending record lives at the
WORKSPACE ROOT ( |
U6 |
Two-phase adoption across every mutating lifecycle entry point (teardown before publish; crash-resume per window; read-only commands report drift only) + the BLOCKING in-battery rotation lifecycle test. Merge serialized after U5. Closes #1384. |
Done (2026-08-10) — this MR (as-built deltas: the consult ( |
U7 |
CI switchover: |
Done (2026-08-10) — this MR (G-B was proven before pickup: the secrets-policy job
decrypted the committed store with the real |
G-C |
Old-variable retirement (#1031): one real scan-job success observed → delete
|
Not started |
U8 |
Close-out docs sweep (inventory below) + plan archive + epic close + macro-feedback filings. Closes #1386 and #1032. |
Done (2026-08-10) — this MR (the full inventory: local-dev Secrets section with the workflow + hygiene runbooks, developer-guide command rows, configuration-reference, quality-gates sops-policy section, security.adoc, ADR-020 supersession note, .env.example, the secrets-yaml lint remediation now naming the real store workflow, the .dockerignore canary extended with the store surfaces, plan archived, epic closed; follow-up issues filed for the three recorded residuals) |
Epic: &79
Issues: #1378 (U0), #1031 (G-A/G-B/G-C gates), #1379 (U1), #1380 (U2), #1381 (U3),
#1382 (U4), #1383 (U5), #1384 (U6), #1385 (U7), #1386 (U8); program anchor #1032
Branches: feature/1032-u0-plan-adr, feature/1032-u1-devtools,
feature/1032-u2-store-module, feature/1032-u3-recipient-mgmt,
feature/1032-u4-store-commit, feature/1032-u5-reconcile, feature/1032-u6-lifecycle,
feature/1032-u7-ci-switchover, feature/1032-u8-closeout
ADR: ADR-064
Context
The secrets-management decision was deferred twice (canopy-xtask-backports §Out of scope; devstack-field-encryption §Out of scope) while per-secret CI variables accreted; #1032 orders one scheme now. Target state, scope-honest: for authorized maintainers + CI, secrets live encrypted in the public tree, ONE bootstrap secret unlocks them, and rotation is a git commit. Identity-less contributors keep the permanent generate-once local path — that is the open-source contract, not a transition aid.
The maintainer ratified the two design forks on 2026-08-10: SOPS + age with canopy’s
containerized workflow (parity weighted over the pure-Rust age-crate runner-up —
comparison in ADR-064), and one CRAIG_CI_AGE_KEY variable with pinned-container
decryption in CI. The v1 program design was externally reviewed the same day
(~40 findings, 13 stop-ship class) and redesigned around the reviewer’s architecture:
the engine runs independently of root compose; the key source is resolved before
anything is materialized; adoption of a store key is a locked, durable, two-phase
transition consumed only by mutating lifecycle entry points; every store failure under
an identity is fail-closed; CI decryption is pinned, blocking, and least-privilege.
Two load-bearing facts (verified against HEAD b811d90e):
-
The root compose project hard-fails at load time while
devstack/field-key.envis missing (docker-compose.yml:369cases +:1220seed, requiredenv_file;.gitlab-ci.yml:573-577pins that evencompose pullfails) — so the secrets engine must never sit behind root compose, and reconciliation cannot be "decrypt produces the file compose needs to load." -
write_key_file_hardened(xtask/src/field_key.rs:240-260) is noclobber-only — adoption needs an atomic overwrite sibling, and values (never file bytes) are what get compared.
Scope
In scope:
-
The hardened devtools runner +
cargo xtask secretsfamily (init / init --for-ci / decrypt / check / edit / add-recipient / remove-recipient / bootstrap-store / fork-rebootstrap). -
The committed store (
.sops.yaml+secrets/dev.yaml) with exact v1 schema (shared.field_encryption_keyonly) + sops-mode and.sops.yamllints + the blockingsecrets-policyCI job. -
Field-key migration end-to-end: resolution-first reconciliation, versioned marker, two-phase lifecycle adoption, CI
write-field-key.shswitchover toCRAIG_CI_AGE_KEY. -
ADR-064; the #1031 re-scope to gates; the baseline-amendment escalation; close-out docs.
Out of scope:
-
Production key delivery/rotation (orchestrator secret store — ADR-048’s deferred items stay deferred, named in ADR-064 §Open questions).
-
Migrating
devstack-actor-keys.env(deliberately public devstack fixture — contributors' tests parse it), kanidm/ZITADEL bootstrap outputs (gitignored, machine-regenerated), or the Keycloakdevstack-secret-*fixtures. Rationale: ADR-064 §Context. -
The claude-quickstart template’s own adoption decision (suggestion filed; template’s call).
Design
The architecture is ADR-064 §Decision (D1–D7); this section carries only the implementation-shaping detail the ADR does not.
Reconcile decision matrix (U5 — pure StoreDecision, table-tested)
| Identity | Store | Decrypt result | Decision |
|---|---|---|---|
absent |
any |
— |
keep local, silent (contributor regression pin) |
present |
absent |
— |
integrity error once U4 lands (identity-holders must have a store) |
present |
present |
value == local |
keep local; write the versioned marker |
present |
present |
value ≠ local |
|
present |
present |
entry absent / malformed YAML / MAC failure / truncation / unsupported version / recipient drift / unknown error |
HARD ERROR (fail-closed; a corrupt or partial store is never masked by a fallback) |
present |
present |
sops not-a-recipient |
WARN + keep local (the joining-maintainer window; no marker, retries next run) |
HOME/XDG unset |
present |
— |
treat as identity-absent here; hard error inside |
Warm path (zero container runs): marker version ok AND store hashes match AND local-key KCV == marker KCV. Any miss → full reconcile. The marker is written only on successful reconcile, so failures self-heal by retrying.
Two-phase adoption (U6)
PendingAdoption → durable .devstack/pending-adoption.v1.json (format version, store
snapshot hashes, new-key KCV — never key material) → stop containers + wipe encrypted
volumes (existing VolumeAction::Wipe machinery, driven by the outcome) → atomic
overwrite-publish of field-key.env (new banner: "materialized from secrets/dev.yaml —
edit the store, not this file") → final marker → clear pending → bring-up + reseed.
Crash in any window resumes from the pending record (value re-derived from the store).
Consumers = the mutating entry points only, each verified against its real path:
decide_action ordering (xtask/src/devstack.rs:65-96), force + cold-start arms,
reload (xtask/src/cmd/dev.rs:471), restart-service (xtask/src/devstack.rs:249),
refresh marker-loss (xtask/src/devstack_guard.rs:699), containers-down. dev status
reports drift only.
CI shape (U4 + U7)
-
secrets-policy(blocking, protected refs, pinned-digest container): the non-disclosing check withCRAIG_CI_AGE_KEY; path-rules ensure.sops.yaml
secrets/**changes always run it. -
write-field-key.shstore-mode: decrypt only inside the pinned container; output validated (single line, base64, 32 bytes); atomic no-symlink-follow write; prior file retained on failure;unset CRAIG_CI_AGE_KEYafter the step;CRAIG_SECRETS_FILEinput seam AND an output-path seam so local docker:27 proofs can never clobber a real dev key; scheduled scan jobs constrained to protected refs.
Test harness rules (all units)
Throwaway age keypairs only (generated via the runner, captured from stdout); temp
stores under workspace-relative dirs; injected identity/store paths — no HOME/XDG
mutation in-process (subprocess or path injection); the docker-secrets nextest group
is serialized with isolated image tags/workspaces; docker-dependent tests are
#[ignore = "requires docker"] and run under validate’s --run-ignored=all. No unit’s
battery ever needs the maintainer identity or the real store.
Verification
-
Per MR: full gate sweep + battery (pre-push authoritative) + J1–J8 review; this Status table updated in the same MR.
-
Blocking proofs: the U6 in-battery rotation lifecycle test (store edit → mutating command → old volumes wiped → new key live → reseeded) and the G-B
secrets-policyrun with the real CI key. Nothing store-critical rests onallow_failurejobs or manual checkboxes except G-C’s deliberately-human variable retirement. -
The external reviewer’s test inventory is distributed per-unit (see the issue ACs): key replacement/deletion under a stale marker (U5); force / reload / restart-service / marker-loss / containers-down / crash-per-phase (U6); malformed-YAML / MAC / truncation / version / missing-entry / not-recipient classification (U2/U5);
.sops.yaml-only change + recipient parity (U4); concurrent reconcile-vs-edit (U5); add-recipient rollback + rotate-on-remove (U3); bootstrap overwrite/crash-residue
fork-rebootstrap (U2/U3); CI invalid/empty/wrong-length extraction with prior-file retention (U7); image cache invalidation (U1).
Documentation Updates
Rides each unit where noted; the U8 sweep covers the rest:
-
Local Development — field-key section (U5) + the full Secrets section (U8: first-run / daily / joining + leaving recipient / contributor path / rotation runbook / edit limits / terminal-history + CI-log + editor-swap hygiene).
-
Developer Guide — the xtask
secretscommand family (U8; NOT CLI Reference — that is the product CLI reference). -
Known Issues & Lessons Learned, Configuration Reference, Quality Gates & Enforcement (CRAIG) (lint row), Security, Plan: Operational Infrastructure Remediation reconcile,
.env.example,.gitignore(U8). -
CHANGELOG.adoc— U2 (commands, Added), U4 (store + lint + CI job, Added), U7 (pre-1.0 Changed:CRAIG_FIELD_ENCRYPTION_KEY→CRAIG_CI_AGE_KEY; forks runsecrets fork-rebootstrap). -
.claude/CLAUDE.mdstatus — U8 (final MR) only.