feat(crypto)!: derive every at-rest key from one ENCRYPTION_KEY - #171
Open
fjaeckel wants to merge 3 commits into
Open
feat(crypto)!: derive every at-rest key from one ENCRYPTION_KEY#171fjaeckel wants to merge 3 commits into
fjaeckel wants to merge 3 commits into
Conversation
At-rest encryption had accumulated one optional key per use — TOTP_ENCRYPTION_KEY, BACKUP_CREDENTIALS_KEY — each with a fallback that stored the data in the clear and logged a warning, and licence/credential files had no key at all: their bytes sat verbatim in document_files.data, so a database dump, a volume snapshot or a stray backup handed over a pilot's identity documents. Replaced with one required secret. ENCRYPTION_KEY is now mandatory at startup, and every use derives its own subkey from it with HKDF-SHA256 (cryptoutil.DeriveKey): 2FA secrets, backup destination credentials, and stored document files. No two purposes share key bytes and none of them is the master, so recovering one subkey reveals nothing about the others. No plaintext paths are left anywhere. A TOTP seed without the enc:v1: marker is refused rather than read as a legacy value — accepting it would let anyone who can write to the column choose a victim's second factor — and document_files .data_nonce is NOT NULL, so "stored in the clear" is not a state the schema can represent. Document files are additionally bound to their row: the GCM tag covers the file's id, owner and content type, none of which live in the ciphertext, so a blob cannot be moved onto another pilot's licence or relabelled by someone with write access to the table. Migration 61 clears what the removed keys used to protect, rather than leaving it to a hand-run snippet in the upgrade notes: 2FA enrolments and recovery codes, every refresh token, and every backup destination. It runs once, in a transaction, before the API serves its first request — so no pilot can hit the window where their enrolment exists but cannot be verified, and an operator who never reads the release notes is covered too. Sessions go with the enrolments: a refresh token minted after a second factor represents an authentication that has just been invalidated. Passkeys are untouched — they were never encrypted with any of these keys. Cloud backups gain an explicit CLOUD_BACKUPS_ENABLED switch. The subsystem used to start whenever its key was present; with one shared key that would mean setting ENCRYPTION_KEY silently started a scheduler and a set of outbound-connecting providers. It stays off by default, as before. What this defends is the database considered apart from the application: dumps, backups, snapshots, replicas, and read access won by any route that does not also yield the API's environment. It does not defend against a compromised API process, which necessarily holds the key. BREAKING CHANGE: ENCRYPTION_KEY is required and the API will not start without it. TOTP_ENCRYPTION_KEY and BACKUP_CREDENTIALS_KEY are removed, and the server refuses to start while either is still set rather than ignoring it. Migration 61 disables 2FA for every account and drops the stored seeds and recovery codes, so all users re-enrol; it deletes every refresh token, so all users sign in again; and it deletes every backup destination and its run history, so those must be re-created with their credentials re-entered. Migration 60 deletes licence/credential files stored before it — nothing inside the database can encrypt them, and the alternative was a permanent second storage format for rows still in the clear. That feature is days old and unreleased, so those rows are test uploads. docs/UPGRADING.md has the operator steps and what to tell users. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JQSaBtPbbikrmjvhUFrJW1
G101 matches on identifier names, so PurposeTOTPSecrets and PurposeBackupCredentials look like hardcoded credentials to it. They are public labels that name what a subkey is for; the same three strings ship in every build and none is usable without the master key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JQSaBtPbbikrmjvhUFrJW1
The column was VARCHAR(64), sized for a 32-character plaintext base32 seed. An encrypted one is 87 characters — the enc:v1: marker plus base64 of nonce, ciphertext and GCM tag — and Postgres rejects the write rather than truncating it, so enrolment answered 500. Nothing caught it below e2e: encryption was previously optional, and the service tests use an in-memory repository that has no column widths. With encryption now mandatory every enrolment writes an 87-character value, so the column becomes TEXT and a test pins the stored format's size against the schema requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JQSaBtPbbikrmjvhUFrJW1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
At-rest encryption had drifted into one optional key per use —
TOTP_ENCRYPTION_KEY,BACKUP_CREDENTIALS_KEY— each with a fallback that stored the data in the clear and logged a warning. Licence/credential files had no key at all: their bytes sat verbatim indocument_files.data, so a database dump, a volume snapshot or a stray backup handed over a pilot's identity documents.This replaces all of it with one required secret.
ENCRYPTION_KEYis mandatory at startup, and every use derives its own subkey from it with HKDF-SHA256 (cryptoutil.DeriveKey): 2FA secrets, backup destination credentials, stored document files. No two purposes share key bytes and none of them is the master, so recovering one subkey reveals nothing about the others.What it defends: the database considered apart from the application — dumps, backups, snapshots, replicas, and read access won by any route that does not also yield the API's environment.
What it does not: a compromised API process, which necessarily holds the key.
No plaintext paths left
enc:v1:marker is refused, not read as a legacy value. Accepting it would let anyone who can write to the column — a restored dump, a stray admin query, SQL injection — choose a victim's second factor.document_files.data_nonceisNOT NULL, so "stored in the clear" is not a state the schema can represent.Document files are additionally bound to their row: the GCM tag covers the file's id, owner and content type, none of which live in the ciphertext. A blob cannot be moved onto another pilot's licence or relabelled by someone with write access to the table.
Migration 61 does the cleanup
Rather than leaving it to a hand-run SQL snippet in the release notes, migration 61 clears everything the removed keys used to protect. It runs once, in a transaction, before the API serves its first request — so no pilot can hit the window where their enrolment exists but cannot be verified, and an operator who never reads the notes is covered too.
TOTP_ENCRYPTION_KEY; an enrolment that cannot be verified locks the account out with no self-service route backcredentials_encis now opaque bytes; the row cannot be repaired, only re-createdPasskeys are untouched — WebAuthn credentials were never encrypted with any of these keys, so there is no reason to make anyone re-register a security key.
Cloud backups get an explicit switch
CLOUD_BACKUPS_ENABLED, off by default. The subsystem used to start whenever its key was present; with one shared key that would mean settingENCRYPTION_KEYsilently started a scheduler and a set of outbound-connecting providers.ENCRYPTION_KEYis required — the API will not start without it (openssl rand -base64 32).TOTP_ENCRYPTION_KEYandBACKUP_CREDENTIALS_KEYare removed, and the server refuses to start while either is still set rather than ignoring it and letting the operator find out from a locked-out pilot.ENCRYPTION_KEYloses everything sealed under it. There is no reset.Operator steps and what to tell users: docs/UPGRADING.md (new).
Testing
Green locally:
go build,go vetunder the default /integration/e2ebuild tags,make fmt,make migrate-check(61 versions, all pairs present), fullmake test.New coverage: pairwise domain separation across all three purposes; AAD binding (moved ciphertext, relabelled content type); wrong-key reads; a nonce-less row refused; an unprefixed TOTP seed refused; 2FA unavailable rather than unencrypted without a key;
envBoolparsing for the new opt-in switch. Existing tests that assumed plaintext storage now enrol through the service instead of hand-planting a seed.Not runnable in this environment:
make test-integrationand the e2e suite need Docker — CI covers both. Repository integration tests for the nonce column ship with the change, and every stack that boots the API (docker-compose.yml,.e2e,.perf,scripts/verify-multi-replica-webauthn.sh) now supplies a key; the multi-replica script shares one key across both replicas, since different keys would derive different subkeys.make lintfails here on a pre-existing toolchain mismatch (golangci-lint built against Go 1.25,go.modtargets 1.26.5).Docs
UPGRADING.md(new, linked fromdocs/README.md),FEATURES.md,DATA_MODEL.md,ARCHITECTURE.md,API.md,PACKAGES.md,AUTHENTICATION.md,OIDC.md, rootREADME.md,.env.example, the OpenAPI descriptions for/featuresandcloudBackupsConfigured, and the security-audit skill's crypto rules.Generated by Claude Code