feat(system): seal backup archives with an install HMAC #36

Merged
nalum merged 1 commit from feat/archive-seal into main 2026-08-13 11:41:12 +00:00
Owner

Stacked on #35 (fix/request-body-caps).

Backup archives were plain gzip+JSON — a doctored backup restored by a well-meaning admin imported attacker password hashes, API keys, and a known jwt_secret with nothing to warn anyone.

Seal: top-level "seal" field in the JSON envelope, hex(HMAC-SHA256) over the envelope with the seal absent. In-envelope (not a proto side-channel) because every restore path is file-based (web blob download, CLI os.WriteFile) — a detached seal would be lost on exactly the flows that matter. formatVersion stays 1; old importers ignore the field.

Key: new archive_seal_key in SystemSettings, crypto/rand-minted at boot, independent of jwt_secret so rotation never orphans seals. Scrubbed by the sanitize map and stripped from every archive (a key riding the archive could re-seal tampering). Import re-stamps the install's own key — the key names the install, not the data.

Verdicts (warn, never block): ImportDataResponse.seal_verdict = VALID / UNSEALED / MISMATCH; the audit label carries it; CLI and web setup-restore word their notices by verdict (mismatch is expected when restoring onto a fresh install). Cross-install restores and pre-seal archives keep working.

ADR-0022/0023 amended. make check, integration suite, buf lint + breaking all green.

Fixes #22

🤖 Generated with Claude Code

Stacked on #35 (`fix/request-body-caps`). Backup archives were plain gzip+JSON — a doctored backup restored by a well-meaning admin imported attacker password hashes, API keys, and a known `jwt_secret` with nothing to warn anyone. **Seal:** top-level `"seal"` field in the JSON envelope, hex(HMAC-SHA256) over the envelope with the seal absent. In-envelope (not a proto side-channel) because every restore path is file-based (web blob download, CLI `os.WriteFile`) — a detached seal would be lost on exactly the flows that matter. `formatVersion` stays 1; old importers ignore the field. **Key:** new `archive_seal_key` in SystemSettings, crypto/rand-minted at boot, independent of `jwt_secret` so rotation never orphans seals. Scrubbed by the sanitize map and stripped from every archive (a key riding the archive could re-seal tampering). Import re-stamps the install's own key — the key names the install, not the data. **Verdicts (warn, never block):** `ImportDataResponse.seal_verdict` = VALID / UNSEALED / MISMATCH; the audit label carries it; CLI and web setup-restore word their notices by verdict (mismatch is expected when restoring onto a fresh install). Cross-install restores and pre-seal archives keep working. ADR-0022/0023 amended. `make check`, integration suite, buf lint + breaking all green. Fixes #22 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(system): seal backup archives with an install HMAC
All checks were successful
check / web (push) Successful in 1m32s
check / go (push) Successful in 1m48s
9b3dd4ab65
A doctored backup restored by a well-meaning admin imports attacker
password hashes, API keys and a known jwt_secret with nothing to warn
anyone. Backup archives now carry a top-level "seal" field in the JSON
envelope: hex(HMAC-SHA256) over the envelope marshalled without the
seal, keyed by a dedicated archive_seal_key minted at boot from
crypto/rand (the VAPID precedent). The key is deliberately independent
of jwt_secret so secret rotation never orphans old seals, is scrubbed
structurally like the other secrets, is stripped from every archive
(a key that rode the backup could re-seal the tampering), and is
re-stamped onto the restored settings row so an install keeps
verifying its own older archives across a restore.

The seal lives inside the envelope rather than in a proto field
because every restore path is file-based — web downloads a blob, the
CLI writes a file — and a detached seal would be lost exactly where it
matters. Older importers ignore the unknown JSON field, so
formatVersion stays 1; pre-seal archives simply have none.

Import warns, never blocks: a missing seal (pre-seal archive) and a
mismatching one (cross-install restore, or tampering) both restore and
come back as a SealVerdict on ImportDataResponse, in the server log
and in the import's audit label. The CLI prints the warning and the
web setup restore words its notice by verdict; the web admin
wipe-restore signs out immediately by design, so its verdict lands in
the restored install's audit log rather than a banner (recorded gap —
rule 1). Portable and user archives stay unsealed: ImportData can
never restore them. MCP has no import surface; Android carries no
Admin tier — no changes there beyond regenerated codegen. ADR-0022
and ADR-0023 gain matching amendment notes.

Fixes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nalum force-pushed feat/archive-seal from 9b3dd4ab65
All checks were successful
check / web (push) Successful in 1m32s
check / go (push) Successful in 1m48s
to 21dde444d6
Some checks failed
tag / tag (push) Has been cancelled
check / go (push) Successful in 2m19s
android / build (push) Successful in 6m6s
check / web (push) Successful in 1m36s
2026-08-13 11:33:32 +00:00
Compare
nalum changed target branch from fix/request-body-caps to main 2026-08-13 11:33:57 +00:00
nalum scheduled this pull request to auto merge when all checks succeed 2026-08-13 11:34:11 +00:00
nalum merged commit 21dde444d6 into main 2026-08-13 11:41:12 +00:00
nalum deleted branch feat/archive-seal 2026-08-13 11:41:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app!36
No description provided.