ADR-017: Encrypted Secrets at Rest with SOPS + age
On this page
Context
ADR-012 (Accepted 2026-04-23) ratified layered YAML configuration but explicitly excluded secrets from checked-in YAML: "Secrets never in checked-in YAML. Enforced by a CI lint that greps for common secret key names in config/*/.yaml." The reasoning was sound for plaintext YAML but left the at-rest representation of secrets unsolved — environment variables on the deploy host carry the values, and operators manage them outside the repo entirely.
Op-infra plan Step 5 phase 1 (MR !152) shipped canopy-secrets with SecretProvider trait + EnvSecretProvider (env-var-backed) + structured audit logging on every secret access, intending phase 2 to be a HashiCorp Vault backend. The Vault direction was reconsidered after enumerating canopy’s actual secret inventory:
-
~15-25 distinct secret values per deployment (DB connection strings, RabbitMQ URL, JWS signing keys per program, AES-256-GCM SSN encryption key, internal service-to-service API key, Keycloak client secret, FTI-scoped DB URLs in canopy-security)
-
All long-lived (no rotation cadence today)
-
No dynamic secrets (Postgres credentials are static, not Vault-generated per request)
-
No lease lifecycle (consumers don’t poll for renewal)
Vault’s killer features (dynamic secrets, lease management, centralized rotation) are dead weight against this inventory. Operating a HA Vault cluster (3+ nodes, Raft consensus, audit log retention) for static-secret storage that env vars already provide is a meaningful operational burden with no proportional win.
Three alternatives merit consideration alongside Vault:
-
Raw
age— single-recipient or multi-recipient file-level encryption. Tiny tool surface (~30-page spec), modern primitives (X25519 + ChaCha20-Poly1305). Weakness: file-level encryption produces opaque diffs in PR review — any rotation produces a different blob, reviewer cannot tell which secret changed. -
Pure GPG (Saltstack pillar pattern) — armored PGP messages embedded in YAML. Familiar at organizations with existing GPG infrastructure. Weakness: GPG UX is famously brittle (keyring corruption, agent deadlocks, expired subkeys, TTY issues in CI/Docker).
-
AWS Secrets Manager / equivalent cloud-vendor backends — IAM-rooted access, audit via CloudTrail. Strength for AWS-native deployments. Weakness: ties canopy to a specific cloud, which is a deployer-level decision rather than a framework-level one.
A fifth option emerged from discussion: SOPS + age. SOPS (Mozilla → CNCF) is a value-level encryption layer over a chosen backend (age, GPG, AWS-KMS, GCP-KMS, Azure Key Vault, Vault). Files look like normal YAML with only the values encrypted; keys and structure stay plaintext, so PR diffs show which value changed. SOPS+age combines SOPS’s diff-review property with age’s modern primitives and clean onboarding UX.
Per-jurisdiction operational separation is the user’s stated precedent at github.com/georgiacyber/kinetic: code lives in the public canopy repo; deployment configuration including secrets lives in a per-jurisdiction private repo. Canopy ships only secrets/dev.yaml for devstack and the integration test suite, with fake values only. Per-jurisdiction prod secrets are explicitly out of canopy’s scope.
Decision
SOPS + age for encrypted secrets at rest. Single file secrets/dev.yaml in the canopy repo, encrypted to two recipients (the primary developer’s age public key and the CI runner’s age public key). Decryption happens at deploy time (or cargo xtask dev start time in dev); the decrypted values are injected as environment variables and consumed via the existing CANOPY_{SERVICE}__* runtime contract. The EnvSecretProvider from phase 1 keeps working unchanged — at runtime, every service still reads its secrets from env vars and emits the audit log entry via target = "canopy.secrets".
This decision amends ADR-012. ADR-012’s "Secrets never in checked-in YAML" remains correct for plaintext YAML in config/. Encrypted YAML in secrets/ is the new at-rest mechanism for the env-var-injected secrets that ADR-012 left to deployer practice. The CI lint ADR-012 calls for is implemented as secrets-yaml-lint over config/*/.yaml only; secrets/*.yaml is excluded by path.
Out of scope: per-jurisdiction prod secrets, hot rotation without restart, multi-key SSN-encryption-key support (separate follow-up if/when first rotation is needed), Vault HA cluster operation, sealed-secrets operator support.
Rotation Mechanics
Rolling restart is the standard rotation mode. Drain a replica from the load balancer → it finishes in-flight requests → it shuts down → it starts with the new secret value (the freshly-decrypted dev.yaml values injected via the deploy mechanism) → it passes health check → LB routes again. Repeat replica-by-replica.
The constraint is not "all replicas restart simultaneously." The constraint is expand-contract at the credential level: during the rollout, both the old and the new credential must be simultaneously valid at the dependency. This is the same forward-only discipline as ADR-016's schema rotation pattern.
Per-secret-type rotation patterns:
| Secret type | Rotation mechanic |
|---|---|
Database URL / Postgres password |
Add the new password at Postgres ( |
RabbitMQ URL |
Add a parallel user; roll; drop the old user. |
JWS signing keys (per program) |
Pair-aware via existing |
Keycloak client secret |
Keycloak supports multiple client secrets per client. Add the new secret in Keycloak; update SOPS file; roll; remove the old. |
Internal service-to-service API key |
Receiver accepts a list of valid keys during rotation window; roll; remove the old. |
AES-256-GCM SSN encryption key (called-out exception) |
Cannot be rotated by credential change alone — at-rest data encrypted under K_old is not readable by K_new. Two paths: (a) extend |
Consequences
Positive
-
Compliance integrity preserved. The FTI audit hash chain (ADR-014) and
audit_eventschain (ADR-004) stay intact under all secret rotations because secrets don’t touch their schemas. JWS-signed determination history (ADR-002) survives signing-key rotation via theCURRENT/PREVIOUSpair-aware pattern. -
Audit-via-git on rotations. Per-value SOPS encryption means PR diffs show exactly which secret changed. Reviewer can distinguish "rotated SNAP signing key" from "rotated all signing keys" from "swapped SNAP and TANF signing keys." Raw age would produce opaque blobs.
-
No runtime infrastructure to operate. No HA Vault cluster, no AWS dependency, no managed-service contract. The encryption layer is a static binary (
sops) running at deploy time. -
Onboarding friction is bounded. New contributor: install
ageandsops(one each), runcargo xtask secrets initto generate an age keypair, propose adding the public key in.sops.yamlvia PR, an existing recipient runscargo xtask secrets add-recipient. ~5 minutes once the tooling is in place. -
Existing
EnvSecretProvideraudit log continues to function. Phase 1’starget = "canopy.secrets"access log fires on everyprovider.get(key)call regardless of where the env var’s value originated. Pub 1075 §9.4.1.4 access auditing remains satisfied.
Negative
-
One binary to install on developer + deploy machines (
sops). Static Go binary, packaged in major distros, but it is one more thing in the prerequisites list. -
SOPS file format is not an RFC. The format is documented but defined by the
sopsbinary’s behavior. Ifsopsbecomes unmaintained, an in-house decryptor is feasible (the format is small) but is non-zero work. -
Project governance. SOPS was Mozilla until ~2022, transferred to the
getsopsGitHub org, became a CNCF Sandbox project in 2024. Maintenance is ongoing (Linkerd, Flux, ArgoCD all rely on it) but trusts the project’s continuity. -
Expand-contract discipline at the credential level. Operators rotating a secret have to remember to add the new credential at the dependency before updating the SOPS file and rolling. Same discipline as schema migrations. ADR-016’s expand-contract pattern is the model.
-
AES-256-GCM SSN encryption key rotation requires multi-key support. Tracked separately; not solved by this ADR.
Neutral
-
Runtime contract unchanged. Services read
CANOPY_{SERVICE}__*env vars exactly as today. The change is at deploy time, not runtime. Existing tests, existing handlers, existing audit emit sites all continue to work. -
age(Go reference impl) andrage(Rust impl) are interchangeable. Either produces compatible keypairs and SOPS doesn’t care which generated them. Implementer picks whichever their package manager has. -
The developer’s existing GPG commit-signing key is unaffected. GPG continues to sign git commits; age is a separate identity for SOPS encryption only.
Alternatives Considered
-
HashiCorp Vault (the original op-infra Step 5 phase 2 direction). Operational complexity (HA cluster, Raft, audit log retention) disproportionate to canopy’s static-secret inventory; killer features (dynamic secrets, leases) unused. Rejected in favor of SOPS+age. Issue #346 closed as superseded.
-
Raw age (no SOPS layer). Smaller tool surface, smaller spec to depend on. Weakness: file-level encryption produces opaque diffs in PR review. The audit-via-git property — the entire point of secrets-in-source — degrades to "we know the file changed; we don’t know which value." Rejected in favor of SOPS+age. The one-binary cost of
sopsearns reviewable rotation diffs. -
Pure GPG (Saltstack pillar pattern). Familiar to the user from
github.com/georgiacyber/kinetic. Weaknesses: GPG UX is brittle in CI/Docker/headless environments (TTY issues, agent state); no value-level encryption without an additional tool. Rejected in favor of SOPS+age, which provides the same in-git encryption pattern with cleaner UX. -
AWS Secrets Manager / Cloud-vendor SDKs. Native IAM-rooted access, audit via CloudTrail. Strength for AWS-native deployments. Deferred to deployer choice — canopy itself stays cloud-neutral. A jurisdiction deploying to AWS may layer Secrets Manager on top of (or instead of) SOPS+age in their own deployment-config repo; canopy doesn’t enforce one path.
Related ADRs
-
ADR-012 (Layered YAML Configuration) — amended by this ADR; secrets-at-rest gap that ADR-012 left to deployer practice is now closed for the canopy repo.
-
ADR-002 (Black-Box Determination Contract) — JWS signing keys are among the secrets ADR-017 protects; the pair-aware verifier (
CURRENT+PREVIOUS) is what makes rolling-restart rotation safe. -
ADR-004 (Legally-Scoped Data Tenancy) —
audit_eventshash chain that secret rotation must not break. -
ADR-014 (FTI Audit Hash-Chain Integrity) — same risk class; rotation mechanism must not disturb the chain.
-
ADR-016 (Forward-Only Schema Migrations) — the same expand-contract discipline that applies to schema migrations applies to credential rotation.