Files
pad/deploy
xarmian 6f468d37b6 fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668) (#189)
* fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668)

store/encryption.go silently accepted an empty key and stored TOTP
seeds in plaintext; cmd/pad/main.go only logged a WARN. Operators who
never saw the warning (or saw it and ignored it) ran for months with
sensitive data at rest in the clear.

Change: encryption is now mandatory. Resolution order inside
Config.EnsureEncryptionKey:

 1. PAD_ENCRYPTION_KEY env var (EncryptionKeySource = "env").
 2. encryption_key in config.toml (source = "config").
 3. <DataDir>/encryption.key file (source = "file").
 4. Generate a fresh 32-byte AES-256 key, persist it to the file
    above with 0600 permissions, continue (source = "generated").

Generation step never fails silently — mkdir + write errors propagate
out of main.go and abort startup.

main.go:
 - drop the "if cfg.EncryptionKey != '' { enable } else { warn }" fork.
 - call cfg.EnsureEncryptionKey(), fail startup on error, log at WARN
   when a key is freshly generated so operators notice the new file.

Tests (internal/config/encryption_key_test.go):
 - generates when missing (file permissions 0600, 32-byte key).
 - loads existing file (strips trailing newline).
 - respects already-configured values (no file write).
 - idempotent across restarts (same key across two Config objects
   sharing a DataDir).

Parent: PLAN-643 (OSS Security Hardening).

* fix(config): refuse to auto-generate key in clustered deployments per Codex P1

Codex caught that auto-generating a per-process key on a Postgres-
backed multi-replica deployment would give each replica its own key —
cross-instance decryption of shared DB rows would fail with GCM auth
errors.

Change: EnsureEncryptionKey now takes an allowGenerate bool. main.go
passes (dbDriver != 'postgres'): single-instance SQLite deployments
get the zero-config auto-generation path; Postgres deployments must
set PAD_ENCRYPTION_KEY explicitly. Operators who DO share a volume
across replicas can pre-seed the file and it still loads (the
generate step is the only thing gated).

Tests:
- TestEnsureEncryptionKey_RefusesToGenerateWhenClustered — allowGenerate=false
  + no existing file → error, no file written.
- TestEnsureEncryptionKey_ClusteredWithPreSeededFileStillLoads — the
  file path works in clustered mode when the file is already present.
- Existing idempotency test updated to exercise the mixed case (first
  boot generates, second boot loads with allowGenerate=false).

* fix(config): atomic encryption key file creation per Codex P2

Codex caught that the check-then-write sequence for encryption.key had
a race: two processes starting together could both pass the os.ReadFile
IsNotExist check, generate different keys, and race the write.
Whichever process wrote first would end up with an in-memory key that
no longer matched the persisted file, and future restarts of THAT
process would decrypt with the 'wrong' key.

Switch to os.OpenFile with O_CREATE|O_EXCL: on EEXIST we re-read the
file and converge on whichever key won the race. Every racing process
ends up with the same key or a clear startup error.

Test: TestEnsureEncryptionKey_ConcurrentStartIsRaceSafe fires 16
goroutines at a shared DataDir and asserts they all observe the same
key. Also runs clean under -race.

* fix(config): fully-written key guaranteed via temp+hardlink per Codex P2

Codex caught that O_CREATE|O_EXCL + ReadFile-on-EEXIST still had a
window where a loser could read an empty/partial file between the
winner's create and its first write. Hex/length validation would then
fail startup with a confusing error.

Switch to temp-file + os.Link:
 1. Write the full key to a uniquely-named temp file (fully closed).
 2. os.Link(temp, keyPath) atomically creates the final file as a
    hardlink to the complete temp inode. EEXIST means a loser; the
    file they'd read is another process's already-complete temp.
 3. defer os.Remove(tmpPath) cleans up in every path.

The race-safety test now also covers the 'read partial' case
implicitly — if any goroutine loaded an empty/partial key the hex
decode in main.go would fail in production; the test asserts all 16
goroutines observe the same non-empty key.

* fix(config): reject world/group-readable encryption.key per Codex P2

Codex flagged that the file-load path blindly accepted any mode on
encryption.key. On a multi-user host, a pre-seeded file chmod'd to
0644 would hand the AES key to every local user, defeating the whole
purpose of encrypting TOTP seeds at rest.

Stat the file and reject any mode where group or other bits are set
(0077 mask). Error message points the operator at the fix (chmod 600).

Skipped on Windows where Unix permission bits aren't enforced.

Test: TestEnsureEncryptionKey_RejectsWorldReadableFile pre-seeds the
file at 0644 and verifies startup fails with the chmod hint.

* fix(config): always allow key auto-gen; warn on Postgres per Codex P1

Codex caught that gating auto-generation on 'not postgres' broke the
first-boot experience for every Postgres deployment that wasn't already
provisioning PAD_ENCRYPTION_KEY — which includes our own
docker-compose.yml and deploy/k8s/configmap.yaml. Server would exit
with 'encryption key required' before even starting.

Revert the gate: EnsureEncryptionKey(true) always, for every driver.
In exchange, log a WARN specifically on Postgres when we generate a
key, pointing operators at the multi-replica concern.

Trade-off accepted: single-instance Postgres just works; multi-replica
operators get a visible warning and clear failure mode (GCM auth
errors on first cross-replica read) if they don't act on it. Better
than a startup crash for the single-replica majority.

* fix(config): Postgres requires explicit PAD_ENCRYPTION_KEY; provision it in deployments

Codex was right twice — both concerns are real, and this commit
resolves them together:

 1. Restore the Postgres gate: EnsureEncryptionKey(false) when
    dbDriver == "postgres". Multi-replica deployments must share a
    key; auto-generating per pod would fail cross-replica decryption.

 2. Update the shipped Postgres deployments to provision a shared
    PAD_ENCRYPTION_KEY so first-boot works out of the box:
    - docker-compose.yml: PAD_ENCRYPTION_KEY via ${VAR:?err} shell
      substitution (fails "docker compose up" with a clear message
      if missing, matching the POSTGRES_PASSWORD pattern).
    - .env.example: document PAD_ENCRYPTION_KEY as REQUIRED on
      Postgres with an "openssl rand -hex 32" hint.
    - deploy/k8s/secret.yaml: add PAD_ENCRYPTION_KEY with a
      CHANGE_ME placeholder, explain why the replicas: 2 deployment
      requires a shared key.

SQLite deployments continue to auto-generate on first boot (the
TASK-668 happy path), so single-user installs stay zero-config.
2026-04-22 08:45:56 -04:00
..