Skip to content

feat(crypto)!: derive every at-rest key from one ENCRYPTION_KEY - #171

Open
fjaeckel wants to merge 3 commits into
mainfrom
claude/license-credential-image-uploads-2g1j9a
Open

feat(crypto)!: derive every at-rest key from one ENCRYPTION_KEY#171
fjaeckel wants to merge 3 commits into
mainfrom
claude/license-credential-image-uploads-2g1j9a

Conversation

@fjaeckel

@fjaeckel fjaeckel commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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 in document_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_KEY is 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

  • A TOTP seed without the 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_nonce is NOT NULL, so "stored in the clear" is not a state the schema can represent.
  • A service built without a key fails enrolment closed rather than degrading to plaintext seeds.

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.

Cleared Why
2FA enrolments, seeds, recovery codes Sealed under TOTP_ENCRYPTION_KEY; an enrolment that cannot be verified locks the account out with no self-service route back
All refresh tokens A session minted after a second factor represents an authentication that has just been invalidated — and so does a stolen one
All backup destinations + run history credentials_enc is now opaque bytes; the row cannot be repaired, only re-created
Licence/credential files (migration 60) Nothing inside the database can encrypt them; the alternative was a permanent second storage format

Passkeys 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 setting ENCRYPTION_KEY silently started a scheduler and a set of outbound-connecting providers.

⚠️ BREAKING CHANGE

  • ENCRYPTION_KEY is required — the API will not start without it (openssl rand -base64 32).
  • TOTP_ENCRYPTION_KEY and BACKUP_CREDENTIALS_KEY are 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.
  • Every user re-enrols in 2FA, signs in again, and re-creates any backup destinations.
  • Losing ENCRYPTION_KEY loses 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 vet under the default / integration / e2e build tags, make fmt, make migrate-check (61 versions, all pairs present), full make 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; envBool parsing 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-integration and 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 lint fails here on a pre-existing toolchain mismatch (golangci-lint built against Go 1.25, go.mod targets 1.26.5).

Docs

UPGRADING.md (new, linked from docs/README.md), FEATURES.md, DATA_MODEL.md, ARCHITECTURE.md, API.md, PACKAGES.md, AUTHENTICATION.md, OIDC.md, root README.md, .env.example, the OpenAPI descriptions for /features and cloudBackupsConfigured, and the security-audit skill's crypto rules.


Generated by Claude Code

claude added 3 commits August 9, 2026 03:27
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants