ADR 0027 — Encrypt config secrets at rest¶
Historical paths. Since 0.74.0 the Matter stack is a dependency, not a subtree: it lives in the go-fabric module and
internal/north/matter/no longer exists in this repository. The paths below are left as they were when the decision was made — a record rewritten to match today's tree stops being a record. For where each piece lives now, seeSPECIFICATION.md§6.
- Status: accepted
- Date: 2026-06-05
- Related: ADR 0002 — multi-CCU first class,
internal/config(cfg:"secret"field classification),internal/configstore(DB-tier config assembly),SPECIFICATION.md§2 (constraints)
Context¶
Runtime-mutable configuration lives in SQLite (config_sections JSON snapshots + the centrals table). Secret-classed fields — cfg:"secret": CCU passwords, MQTT password, OIDC client_secret, REST auth.users/tokens, Matter attestation material — could be stored there in plaintext:
- The SPA's
PUT /config/sections/{section}persists whatever JSON it receives verbatim; the read endpoint masks secrets for display, but the stored value is not encrypted. - The
centralstable keeps apassword_plaincolumn (used whenallow_plaintext_secretsis on, and as the seed target for a YAML-supplied CCU password).
The recommended path has always been env-var resolution (secrets never touch the DB), but operators can and do enter passwords in the SPA, so plaintext secrets reach the database. A leaked DB file, backup, or config export then leaks those secrets.
This is compounded by ADR-0027's sibling change in the same workstream: first-run seeding copies a full config.yaml into the DB so a new environment can be stood up from one file. Without encryption that seed would write YAML secrets to the DB in plaintext.
Decision¶
Encrypt secret-classed config values at rest in the database.
Threat model¶
- Protects: DB files, backups, snapshots, and
config exportoutput shared or stolen without the master key. - Does NOT protect: an attacker who also holds the master key, or who can read the running process's memory. This is at-rest confidentiality, not a secrets-management system.
Cipher¶
- AES-256-GCM (
crypto/aes+cipher.NewGCM, pure Go — the same primitive family already used underinternal/north/matter/secure). Random 12-byte nonce per value. - Stored form:
enc:v1:<base64(nonce ‖ ciphertext ‖ tag)>. Theenc:v1:prefix distinguishes ciphertext from plaintext (enabling lazy migration) and versions the scheme for future algorithm changes.
Master key — hybrid resolution¶
OPENCCU_LOOM_SECRET_KEY— base64-encoded 32 bytes. Operator-managed (12-factor); one key protects every DB secret.- Otherwise an auto-generated key file
<data_dir>/secret.key(32 random bytes, mode0600), created on first run. Zero-config default that protects DB backups/exports out of the box.
Resilient fallback¶
If no key is available and none can be created (e.g. a read-only data_dir with no env key), the daemon logs a WARNING and falls back to plaintext storage rather than failing to boot. Encryption is a hardening layer, not a boot dependency.
Scope of encrypted fields¶
All cfg:"secret" leaf fields, discovered by reflection over the cfg tag (the same convention internal/config/classify.go already walks) — so new secret fields are covered automatically:
- string fields and
map[string]stringvalues are sealed directly. - integer fields (e.g. the Matter commissioning
passcode, auint32) are sealed as their decimal-string form: the JSON leaf becomes anenc:v1:string on write and is decoded back to a number on read. The non-destructive transform only changes the leaf's type when it is actually encrypted, so an unsealed value (no key) stays a number.
Empty values are left empty (never encrypted), so env-only secrets stay absent from the DB and continue to resolve from their env var at load time. The centrals.password_plain column is sealed the same way.
Out of scope: non-integer numeric secrets (floats) — none exist in the config tree today; they are skipped rather than truncated.
Integration¶
- Sealing/opening is applied at the persistence boundary via thin decorators around the section and centrals stores, so the SPA write path, the first-run seed, and the CLI import are all covered without per-call-site changes.
- Reads decrypt after load; a value without the
enc:v1:prefix passes through unchanged, so pre-existing plaintext rows keep working and are re-encrypted lazily on their next write. - Env-var overrides (
resolveEnvSecrets) and the SPA's display masking (maskSecrets) operate on the decrypted, in-memory config and are unaffected.
Consequences¶
- DB dumps / backups /
config exportno longer leak secrets unless the master key leaks too. - Operators who relied on plaintext-in-DB lose nothing functionally; the value is transparently sealed on write and opened on read.
- Key loss ⇒ sealed secrets become unrecoverable and must be re-entered (via the SPA or a fresh seed). This is the cost of at-rest encryption and is documented in the example configs.
- A constant operating concern: the auto-generated
secret.keymust be included in any backup that also contains the DB, or restored secrets will not decrypt. The example configs call this out. - Key rotation and envelope/passphrase-wrapped keys are deliberately deferred; v1 uses a single raw key. Rotation would re-encrypt all sealed rows under a new
enc:v2:scheme.