diff --git a/.claude/skills/security-audit/SKILL.md b/.claude/skills/security-audit/SKILL.md index 70b82ca..19fd016 100644 --- a/.claude/skills/security-audit/SKILL.md +++ b/.claude/skills/security-audit/SKILL.md @@ -95,6 +95,9 @@ Before reviewing, list the feature's actual surface — do not guess from the na - Randomness for anything security-bearing: `crypto/rand`, **and check the error return**. - Encryption at rest: `pkg/cryptoutil` (AES-256-GCM, fresh nonce per encrypt). Do not hand-roll. + A new use derives its own subkey from `ENCRYPTION_KEY` (`DeriveKey`, HKDF) rather than + reusing another purpose's key or adding a third secret. Ciphertext in a table column binds + to its row with `EncryptWithAAD` so a blob cannot be moved between rows or owners. - Tokens are stored as SHA-256 hashes, are single-use, and expire. Passwords use `pkg/hash` (bcrypt cost 12). - New config secret? Validate it at startup and **fail closed** (`cmd/api/secrets.go` is the diff --git a/.env.example b/.env.example index 000c792..6334bbe 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # NinerLog API — Environment Variables # ============================================================================= # Copy this file to .env and adjust values for your environment. -# All variables have sensible defaults for local development. +# Everything has a sensible local-development default except ENCRYPTION_KEY, +# which has to be generated — the API will not start without it. # ----------------------------------------------------------------------------- # Database @@ -24,6 +25,25 @@ REFRESH_SECRET=change-this-refresh-secret-in-production JWT_EXPIRES_IN=15m REFRESH_EXPIRES_IN=7d +# ----------------------------------------------------------------------------- +# Encryption at rest — REQUIRED, CHANGE THIS IN PRODUCTION +# ----------------------------------------------------------------------------- +# One 32-byte key, base64-encoded: openssl rand -base64 32 +# +# The API will not start without it. Everything the server encrypts at rest +# derives its own subkey from this one secret (HKDF): licence/credential files, +# 2FA secrets, cloud backup credentials. No two of them share key bytes, and +# none of them is this key. +# +# Treat it exactly like the database password, and KEEP A COPY. Data sealed with +# it cannot be recovered without it — not from a database dump, not by us. There +# is no reset. +# +# TOTP_ENCRYPTION_KEY and BACKUP_CREDENTIALS_KEY are gone. The server refuses to +# start while either is still set, because data sealed with them cannot be read +# under the new scheme; see docs/UPGRADING.md before removing them. +ENCRYPTION_KEY= + # ----------------------------------------------------------------------------- # CORS (comma-separated origins) # ----------------------------------------------------------------------------- @@ -48,10 +68,12 @@ MIGRATIONS_PATH=db/migrations # Licence / credential reference files # ----------------------------------------------------------------------------- # Photos, scans and PDFs attached to a licence or credential (max 5 MB, 5 per -# document; JPEG, PNG and PDF only). Set to false to close the feature entirely -# — uploads *and* downloads then answer 403. Stored files are kept and reappear -# if re-enabled. DOCUMENT_IMAGES_ENABLED is the previous name for this knob and -# is still honoured; the new name wins when both are set. +# document; JPEG, PNG and PDF only). Requires ENCRYPTION_KEY above — stored +# files are encrypted at rest and there is no plaintext fallback. +# Set to false to close the feature entirely — uploads *and* downloads then +# answer 403. Stored files are kept and reappear if re-enabled. +# DOCUMENT_IMAGES_ENABLED is the previous name for this knob and is still +# honoured; the new name wins when both are set. # DOCUMENT_FILES_ENABLED=false # # Per-user budget for READING files (listing a document's files and downloading @@ -60,6 +82,20 @@ MIGRATIONS_PATH=db/migrations # shared 'expensive' bucket. # FILE_READ_RATE_LIMIT_PER_MINUTE=90 +# ----------------------------------------------------------------------------- +# Cloud backups (optional, off by default) +# ----------------------------------------------------------------------------- +# Scheduled backups of a pilot's data to their own S3, SFTP or WebDAV storage. +# Destination credentials are encrypted at rest under a subkey of +# ENCRYPTION_KEY. This used to be switched on by the presence of a separate +# backup key; with one shared key it needs its own switch, or setting that key +# would silently start a scheduler and outbound connections. +# CLOUD_BACKUPS_ENABLED=true +# +# The e2e backup targets are containers on a private network, and the SSRF guard +# blocks private ranges by default. Only for test stacks. +# BACKUP_ALLOW_PRIVATE_NETWORKS=true + # ----------------------------------------------------------------------------- # SMTP / Email (optional — emails are logged to stdout when SMTP_HOST is empty) # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index cd51cbb..b38e888 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,17 @@ test/e2e/ # End-to-end tests See `.env.example` for a complete list of configuration options including database connection, JWT secrets, CORS settings, SMTP configuration, and TLS settings. +**Encryption at rest.** `ENCRYPTION_KEY` (32 bytes, base64 — `openssl rand -base64 32`) +is **required**: the API will not start without it. It protects everything the database +stores on the application's behalf — licence and credential files, 2FA secrets, cloud +backup credentials — with each use deriving its own subkey, so one secret covers all of +them without any two sharing key bytes. Keep a copy alongside the database password: +sealed data cannot be recovered without it, and there is no reset. Upgrading from a +release with `TOTP_ENCRYPTION_KEY` or `BACKUP_CREDENTIALS_KEY`? Read +[docs/UPGRADING.md](docs/UPGRADING.md) first — those are removed, the server refuses to +start while they are still set, and a migration clears every 2FA enrolment, session and +backup destination, because none of them can be decrypted any more. + **Single sign-on (optional).** Setting `OIDC_ISSUER` switches the deployment to OIDC mode, where an external identity provider owns all accounts and NinerLog's own password, registration, 2FA and passkey endpoints are disabled. It is off by default. See diff --git a/api-spec/openapi.yaml b/api-spec/openapi.yaml index 24888ca..108cba3 100644 --- a/api-spec/openapi.yaml +++ b/api-spec/openapi.yaml @@ -6620,10 +6620,15 @@ components: properties: enabled: type: boolean - description: When false, every /files endpoint answers 403 — uploads and downloads alike + description: >- + When false, every /files endpoint answers 403 — uploads and + downloads alike. False when the operator switched the feature + off, and also when no encryption key is configured: stored + files are encrypted at rest and the feature does not run + without one. maxBytes: type: integer - description: Maximum size of a single image in bytes + description: Maximum size of a single file in bytes, measured before encryption example: 5242880 maxPerDocument: type: integer @@ -8283,10 +8288,14 @@ components: description: Whether ADMIN_EMAIL is set cloudBackupsConfigured: type: boolean - description: Whether cloud backups are enabled (BACKUP_CREDENTIALS_KEY is set) + description: Whether cloud backups are enabled (CLOUD_BACKUPS_ENABLED=true) documentFilesEnabled: type: boolean - description: Whether licence/credential reference files are enabled (DOCUMENT_FILES_ENABLED is not "false") + description: >- + Whether licence/credential reference files are enabled + (DOCUMENT_FILES_ENABLED is not "false" and ENCRYPTION_KEY is set — + stored files are encrypted at rest and the feature does not run + without a key) unverifiedCleanupEnabled: type: boolean description: | @@ -9676,7 +9685,9 @@ components: DocumentFilesDisabled: description: >- Document files are switched off on this server - (DOCUMENT_FILES_ENABLED=false). Applies to reading as well as + (DOCUMENT_FILES_ENABLED=false, or no ENCRYPTION_KEY is configured — + stored files are encrypted at rest and the feature does not run + without a key). Applies to reading as well as uploading: serving stored files is the bandwidth half of the abuse surface the switch exists to close. Already-stored files are retained and become reachable again if the operator re-enables the feature. diff --git a/cmd/api/env_test.go b/cmd/api/env_test.go index cde2122..cad6218 100644 --- a/cmd/api/env_test.go +++ b/cmd/api/env_test.go @@ -44,3 +44,41 @@ func TestEnvIntNarrow(t *testing.T) { }) } } + +// envBool gates subsystems that make outbound connections, so "not the string +// false" is not good enough: a typo or a "0" must not read as "on". +func TestEnvBool(t *testing.T) { + const key = "NINERLOG_TEST_ENV_BOOL" + + cases := []struct { + name string + set bool + val string + def bool + want bool + }{ + {name: "unset keeps default", def: false, want: false}, + {name: "unset keeps a true default", def: true, want: true}, + {name: "empty keeps default", set: true, val: "", def: false, want: false}, + {name: "true enables", set: true, val: "true", def: false, want: true}, + {name: "1 enables", set: true, val: "1", def: false, want: true}, + {name: "false disables", set: true, val: "false", def: true, want: false}, + {name: "0 disables", set: true, val: "0", def: true, want: false}, + + // The difference from envBoolWithLegacy, which would read both of these + // as "on" because they are not the exact string "false". + {name: "no keeps default", set: true, val: "no", def: false, want: false}, + {name: "typo keeps default", set: true, val: "ture", def: false, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.set { + t.Setenv(key, tc.val) + } + if got := envBool(key, tc.def); got != tc.want { + t.Errorf("envBool(%q=%q, def=%v) = %v, want %v", key, tc.val, tc.def, got, tc.want) + } + }) + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 3e85e7e..1ae7339 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -85,10 +85,6 @@ func envIntNarrow(key string, def int) int { return int(v) } -// envDuration reads a Go duration (e.g. "24h", "60s") from the environment, -// keeping the default when the variable is unset, unparseable, or non-positive. -// Same fail-safe reasoning as envInt: a misconfigured retention window must not -// silently become zero. // envBoolWithLegacy reads a boolean feature switch, honouring a previous name // for the same knob. Only the exact string "false" disables — an unset or // unparseable value leaves the default in place, so a typo never silently @@ -106,6 +102,32 @@ func envBoolWithLegacy(key, legacyKey string, def bool) bool { return def } +// envBool reads a boolean switch, keeping the default when the variable is +// unset or unparseable. +// +// Unlike envBoolWithLegacy — which exists for opt-out knobs, where only the +// exact string "false" disables — this parses properly, because it is used for +// opt-in switches. "0", "no" and a typo must not all read as "on"; a subsystem +// that starts making outbound connections should do so because someone wrote +// "true", not because they wrote something that merely was not "false". +func envBool(key string, def bool) bool { + raw := os.Getenv(key) + if raw == "" { + return def + } + v, err := strconv.ParseBool(raw) + if err != nil { + slog.Warn("Ignoring invalid environment value, using default", + "key", key, "value", raw, "default", def) + return def + } + return v +} + +// envDuration reads a Go duration (e.g. "24h", "60s") from the environment, +// keeping the default when the variable is unset, unparseable, or non-positive. +// Same fail-safe reasoning as envInt: a misconfigured retention window must not +// silently become zero. func envDuration(key string, def time.Duration) time.Duration { raw := os.Getenv(key) if raw == "" { @@ -225,18 +247,48 @@ func main() { licenseRepo := postgres.NewLicenseRepository(db) flightRepo := postgres.NewFlightRepository(db) flightBaselineRepo := postgres.NewFlightBaselineRepository(db) - // TOTP secrets are encrypted at rest when TOTP_ENCRYPTION_KEY (base64, - // 32 bytes) is set. Without it, secrets are stored as plaintext; warn so - // operators enable encryption in production. - var totpAEAD *cryptoutil.AEAD - if totpKey := os.Getenv("TOTP_ENCRYPTION_KEY"); totpKey != "" { - totpAEAD, err = cryptoutil.NewFromBase64(totpKey) - if err != nil { - fatal("invalid TOTP_ENCRYPTION_KEY", "error", err) + // ENCRYPTION_KEY is the single operator-facing secret behind every piece of + // data this server encrypts at rest. Each use derives its own subkey from + // it (HKDF, see cryptoutil.DeriveKey), so one key in the environment + // protects several independent things without any two of them sharing key + // bytes — recovering the subkey that reads licence scans reveals nothing + // about the one that reads 2FA secrets, and neither reveals the master. + // + // It is required, not optional. Every previous arrangement here had a + // degraded mode where a missing key meant "store it in the clear anyway", + // and a warning nobody reads is not a security control. One key, mandatory, + // no plaintext path. + // + // It is never generated or defaulted: a key the server invents is a key it + // cannot remember across a restart, and losing the key loses everything + // sealed under it. Generate one with `openssl rand -base64 32` and keep it + // wherever the database password lives — to anyone holding a stolen backup + // the two are worth exactly the same. + masterKey, err := cryptoutil.DecodeKey(os.Getenv("ENCRYPTION_KEY")) + if err != nil { + fatal("ENCRYPTION_KEY is required and must be 32 random bytes, base64-encoded", + "error", err, "hint", "generate one with `openssl rand -base64 32`") + } + + // The per-purpose key variables this replaced are refused rather than + // ignored. Silently disregarding one would leave an operator believing + // their 2FA secrets or backup credentials are still readable when the + // server can no longer decrypt them, and they would find out from a locked + // out pilot. Failing at startup puts the problem where it can be fixed. + for _, removed := range []struct{ name, effect string }{ + {"TOTP_ENCRYPTION_KEY", "2FA secrets sealed with it cannot be read; affected users must re-enrol"}, + {"BACKUP_CREDENTIALS_KEY", "backup destination credentials sealed with it cannot be read; those destinations must be re-created"}, + } { + if os.Getenv(removed.name) != "" { + fatal(removed.name+" is no longer supported — all keys now derive from ENCRYPTION_KEY", + "effect", removed.effect, + "hint", "unset "+removed.name+" once the affected data has been dealt with; see docs/UPGRADING.md") } - slog.Info("TOTP secrets encrypted at rest") - } else { - slog.Warn("TOTP_ENCRYPTION_KEY not set — 2FA secrets are stored unencrypted") + } + + totpAEAD, err := cryptoutil.DeriveAEAD(masterKey, cryptoutil.PurposeTOTPSecrets) + if err != nil { + fatal("could not derive the TOTP encryption key", "error", err) } // Initialize services. The two-factor service is built first: the auth @@ -399,21 +451,36 @@ func main() { // feature grew beyond images. It is still honoured so an operator who // already switched the feature off does not silently get it switched back // on by an upgrade; the new name wins when both are set. + // + // Stored files are encrypted at rest under a subkey of ENCRYPTION_KEY. + // There is no unencrypted mode: these are scans of identity documents, and + // a database dump that hands them over in the clear is exactly what the + // encryption exists to prevent. documentFilesEnabled := envBoolWithLegacy("DOCUMENT_FILES_ENABLED", "DOCUMENT_IMAGES_ENABLED", true) + documentFileAEAD, err := cryptoutil.DeriveAEAD(masterKey, cryptoutil.PurposeDocumentFile) + if err != nil { + fatal("could not derive the document file encryption key", "error", err) + } documentFileService := service.NewDocumentFileService( - postgres.NewDocumentFileRepository(db), licenseRepo, credentialRepo, documentFilesEnabled) + postgres.NewDocumentFileRepository(db), licenseRepo, credentialRepo, documentFilesEnabled, documentFileAEAD) apiHandler.SetDocumentFileService(documentFileService) startedAt := time.Now() apiHandler.SetStartedAt(startedAt) apiHandler.SetCORSOrigins(corsOrigins) - // Cloud backup service (optional — enabled only when BACKUP_CREDENTIALS_KEY is set). + // Cloud backup service (optional — CLOUD_BACKUPS_ENABLED=true). + // + // This used to be switched on by the presence of its own key. With every + // key now derived from ENCRYPTION_KEY that would mean setting one secret + // silently started a scheduler and a set of outbound-connecting providers, + // so the subsystem gets an explicit switch instead. It stays off by + // default, which is what "no backup key configured" meant before. var backupScheduler *cloudbackup.Scheduler - if backupKey := os.Getenv("BACKUP_CREDENTIALS_KEY"); backupKey != "" { - aead, err := cryptoutil.NewFromBase64(backupKey) + if envBool("CLOUD_BACKUPS_ENABLED", false) { + aead, err := cryptoutil.DeriveAEAD(masterKey, cryptoutil.PurposeBackupCredentials) if err != nil { - fatal("invalid BACKUP_CREDENTIALS_KEY", "error", err) + fatal("could not derive the backup credentials encryption key", "error", err) } backupDestRepo := postgres.NewBackupDestinationRepository(db) backupRunRepo := postgres.NewBackupRunRepository(db) @@ -443,7 +510,7 @@ func main() { backupScheduler = cloudbackup.NewScheduler(backupSvc, 0, nil) slog.Info("Cloud backups enabled (S3, SFTP, WebDAV providers)") } else { - slog.Info("Cloud backups disabled (set BACKUP_CREDENTIALS_KEY to enable)") + slog.Info("Cloud backups disabled (set CLOUD_BACKUPS_ENABLED=true to enable)") } // Setup router diff --git a/db/migrations/000039_create_backup_destinations.up.sql b/db/migrations/000039_create_backup_destinations.up.sql index 6ab0e87..497cc4c 100644 --- a/db/migrations/000039_create_backup_destinations.up.sql +++ b/db/migrations/000039_create_backup_destinations.up.sql @@ -1,7 +1,8 @@ -- Cloud backup destinations: per-user S3-compatible (and future provider) targets. -- The pilot owns the destination; we store only the minimum credential needed -- to upload a single file under a single prefix, encrypted at rest with a --- server-held AES-256-GCM key (BACKUP_CREDENTIALS_KEY env var). +-- server-held AES-256-GCM key (derived from the ENCRYPTION_KEY env var; see +-- migration 60, which introduced the shared derivation). CREATE TABLE backup_destinations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/db/migrations/000060_encrypt_document_files.down.sql b/db/migrations/000060_encrypt_document_files.down.sql new file mode 100644 index 0000000..1ed91a8 --- /dev/null +++ b/db/migrations/000060_encrypt_document_files.down.sql @@ -0,0 +1,19 @@ +-- Remove at-rest encryption for licence/credential files. +-- +-- DESTRUCTIVE, and unavoidably so. Every remaining row holds AES-GCM +-- ciphertext, and nothing in the database can turn it back into a file — the +-- key is in the application's environment. Dropping the nonce column on its own +-- would leave those blobs in `data` for the application to serve to a browser +-- as if they were JPEGs: not a rollback, a silent corruption of every stored +-- scan. So the rows go with the column. +-- +-- To roll back and keep the files, download them through the API first, or +-- restore a dump taken before the upgrade. + +DELETE FROM document_files; + +ALTER TABLE document_files DROP CONSTRAINT document_files_data_nonce_size; +ALTER TABLE document_files DROP COLUMN data_nonce; + +COMMENT ON COLUMN document_files.data IS 'Raw file bytes, served only over an authenticated request; PDFs are always served as an attachment'; +COMMENT ON COLUMN document_files.byte_size IS NULL; diff --git a/db/migrations/000060_encrypt_document_files.up.sql b/db/migrations/000060_encrypt_document_files.up.sql new file mode 100644 index 0000000..0300374 --- /dev/null +++ b/db/migrations/000060_encrypt_document_files.up.sql @@ -0,0 +1,49 @@ +-- Encrypt stored licence/credential files at rest. +-- +-- These are scans of identity documents, and until now `data` held the file +-- verbatim: anyone holding a database dump, a volume snapshot or a stray +-- pg_dump in an object store held the pilot's licence. The API process already +-- carries an operator-supplied key (ENCRYPTION_KEY), so the bytes are now +-- sealed with AES-256-GCM under a subkey derived for this purpose alone, and +-- the column stores ciphertext. +-- +-- What this defends: the database considered separately 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 defend: a +-- compromised API process, which necessarily holds the key. +-- +-- DESTRUCTIVE: any file stored before this runs is DELETED. +-- +-- Nothing can encrypt those rows from inside a migration — the key lives in the +-- application's environment, not the database's — and the alternative is a +-- nullable nonce meaning "this one is still in the clear", i.e. a permanent +-- second storage format and a decrypt path that has to guess which it is +-- holding. This feature is days old and no released version ships it, so the +-- rows being dropped are test uploads. Making the column NOT NULL now buys a +-- storage layer with exactly one shape, which is worth more than those rows. +-- Re-upload after deploying. +-- +-- The nonce lives in its own column rather than being prefixed onto `data`. +-- Both work; a separate column means the encryption state of a row is visible +-- in a query rather than hidden in the first twelve bytes of a BYTEA, and it +-- leaves `data` holding exactly one thing. +-- +-- The authentication tag also covers the row's id, owner and content type, +-- which are NOT stored in the ciphertext. Copying one row's blob onto another +-- therefore produces something that no longer decrypts: an attacker with write +-- access to this table can destroy a file, but cannot move one pilot's scan +-- onto another pilot's licence. + +DELETE FROM document_files; + +ALTER TABLE document_files ADD COLUMN data_nonce BYTEA NOT NULL; + +-- AES-GCM's 96-bit nonce. A wrong-length one is a bug somewhere above, and it +-- is cheaper to refuse the write than to discover it when the file will not +-- open. +ALTER TABLE document_files ADD CONSTRAINT document_files_data_nonce_size + CHECK (octet_length(data_nonce) = 12); + +COMMENT ON COLUMN document_files.data_nonce IS 'AES-256-GCM nonce for data; every row is encrypted, so this is never null'; +COMMENT ON COLUMN document_files.data IS 'File bytes as AES-256-GCM ciphertext (key derived from ENCRYPTION_KEY); served only over an authenticated request, PDFs always as an attachment'; +COMMENT ON COLUMN document_files.byte_size IS 'Size of the PLAINTEXT file in bytes, not of the stored ciphertext'; diff --git a/db/migrations/000061_reset_data_sealed_with_removed_keys.down.sql b/db/migrations/000061_reset_data_sealed_with_removed_keys.down.sql new file mode 100644 index 0000000..fd383ec --- /dev/null +++ b/db/migrations/000061_reset_data_sealed_with_removed_keys.down.sql @@ -0,0 +1,13 @@ +-- Irreversible by nature. +-- +-- The up migration cleared 2FA enrolments, recovery codes, sessions and backup +-- destination credentials because none of them could be decrypted any more. +-- There is nothing to restore: the values are gone from the database and the +-- keys that could have read them are gone from the environment. +-- +-- Rolling the schema back is therefore a no-op rather than an error, so a +-- `migrate down` across this version is not blocked by a step that cannot +-- succeed. Users re-enrol in 2FA and re-create their backup destinations either +-- way; restore a pre-upgrade dump if that is not acceptable. + +SELECT 1; diff --git a/db/migrations/000061_reset_data_sealed_with_removed_keys.up.sql b/db/migrations/000061_reset_data_sealed_with_removed_keys.up.sql new file mode 100644 index 0000000..96187a1 --- /dev/null +++ b/db/migrations/000061_reset_data_sealed_with_removed_keys.up.sql @@ -0,0 +1,57 @@ +-- Clear every secret that the removed per-purpose keys used to protect. +-- +-- Migration 60 moved all at-rest encryption onto subkeys of ENCRYPTION_KEY. +-- Anything sealed under the keys it replaced -- TOTP_ENCRYPTION_KEY, +-- BACKUP_CREDENTIALS_KEY -- can no longer be decrypted, and leaving those rows +-- in place is worse than clearing them: a pilot with a dead 2FA enrolment +-- cannot log in at all and has no self-service route back, and a backup +-- destination with unreadable credentials fails silently on its schedule. +-- +-- Doing it here rather than at first boot, or in a hand-run SQL snippet in the +-- upgrade notes: +-- +-- * it runs exactly 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; +-- * it runs on every deployment, including the ones whose operator never +-- reads the upgrade notes, which are the deployments that need it most; +-- * an operator who skips a manual step gets locked-out users, and the +-- symptom (login fails at the second factor) does not point at its cause. +-- +-- DESTRUCTIVE. Everything below is unrecoverable by design -- that is the point, +-- since none of it can be read any more. + +-- 2FA enrolments. Every account is dropped back to password-only and must +-- re-enrol; the clients handle a disabled enrolment as the ordinary state it is. +-- +-- Deliberately not conditional on whether a given secret was encrypted. A +-- deployment that never set TOTP_ENCRYPTION_KEY stored its secrets in the clear +-- and those would technically still verify, but "your seed survived because it +-- was the one stored unencrypted" is not a rule worth keeping in the schema, and +-- the read path no longer accepts an unprefixed secret at all. +-- +-- Recovery codes go with the enrolment they belong to. They are the way back in +-- when an authenticator is lost, so leaving them behind a disabled enrolment +-- would leave single-use credentials sitting in the table with nothing to +-- unlock. +UPDATE users +SET two_factor_enabled = FALSE, + two_factor_secret = NULL, + recovery_codes = NULL +WHERE two_factor_enabled + OR two_factor_secret IS NOT NULL + OR recovery_codes IS NOT NULL; + +-- Sessions minted while those enrolments were live. A refresh token issued +-- after a second factor represents an authentication we have just invalidated, +-- so it must not outlive it: without this, every already-signed-in pilot keeps a +-- session whose 2FA step can no longer be repeated, and an attacker holding a +-- stolen refresh token keeps one too. Everyone signs in again with their +-- password. +DELETE FROM refresh_tokens; + +-- Backup destinations. credentials_enc / credentials_nonce were sealed with +-- BACKUP_CREDENTIALS_KEY and are now opaque bytes; the row cannot be repaired, +-- only re-created with the credentials typed in again. Runs cascade with the +-- destination, taking the history of backups that can no longer be reproduced. +DELETE FROM backup_destinations; diff --git a/db/migrations/000062_widen_two_factor_secret.down.sql b/db/migrations/000062_widen_two_factor_secret.down.sql new file mode 100644 index 0000000..8e854ec --- /dev/null +++ b/db/migrations/000062_widen_two_factor_secret.down.sql @@ -0,0 +1,18 @@ +-- Narrow users.two_factor_secret back to VARCHAR(64). +-- +-- Every encrypted seed is 87 characters, so the ALTER would fail on any live +-- enrolment. Rolling back to a schema that cannot hold what the application +-- writes means 2FA cannot work anyway, so the enrolments that would block it +-- are cleared first and those users re-enrol — the same trade migration 61 +-- makes, for the same reason. + +UPDATE users +SET two_factor_enabled = FALSE, + two_factor_secret = NULL, + recovery_codes = NULL +WHERE two_factor_secret IS NOT NULL + AND length(two_factor_secret) > 64; + +ALTER TABLE users ALTER COLUMN two_factor_secret TYPE VARCHAR(64); + +COMMENT ON COLUMN users.two_factor_secret IS 'Base32-encoded TOTP secret (encrypted at rest recommended)'; diff --git a/db/migrations/000062_widen_two_factor_secret.up.sql b/db/migrations/000062_widen_two_factor_secret.up.sql new file mode 100644 index 0000000..707966b --- /dev/null +++ b/db/migrations/000062_widen_two_factor_secret.up.sql @@ -0,0 +1,23 @@ +-- Make users.two_factor_secret wide enough for the value that is actually +-- stored in it. +-- +-- The column was sized for a plaintext base32 TOTP seed: 32 characters, so +-- VARCHAR(64) looked generous. An encrypted seed is not 32 characters. It is +-- the "enc:v1:" marker plus base64 of (12-byte nonce ‖ 32-byte ciphertext ‖ +-- 16-byte GCM tag) — 87 characters, which Postgres rejects outright rather than +-- truncating. +-- +-- The mismatch was invisible until now because encryption was optional and the +-- service-level tests use an in-memory repository with no column widths: only a +-- deployment that had actually set a TOTP key ever wrote an 87-character value, +-- and it got a database error at enrolment. With encryption mandatory, every +-- enrolment writes one, so this has to be fixed here rather than left to the +-- next person to trip over. +-- +-- TEXT rather than a bigger VARCHAR: Postgres stores them identically, and a +-- number chosen to fit today's ciphertext is just the same bug waiting for the +-- next format change. + +ALTER TABLE users ALTER COLUMN two_factor_secret TYPE TEXT; + +COMMENT ON COLUMN users.two_factor_secret IS 'TOTP secret, AES-256-GCM encrypted at rest under a subkey of ENCRYPTION_KEY and marked with the enc:v1: prefix'; diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 2f1fc78..ec6fc2f 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -156,8 +156,12 @@ services: NOTIFICATION_CHECK_INTERVAL: "24h" # No periodic airport refetch in test containers AIRPORT_REFRESH_INTERVAL: "off" - # Cloud backups: 32-byte base64 key (deterministic for e2e only) - BACKUP_CREDENTIALS_KEY: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + # At-rest encryption: 32-byte base64 key (deterministic for e2e only). + # Required — every subkey the server uses derives from it, and it refuses + # to start without one. + ENCRYPTION_KEY: "ZTJlLW9ubHktZW5jcnlwdGlvbi1rZXktMzItYnl0ZSE=" + # Cloud backups are opt-in; the e2e suite exercises them. + CLOUD_BACKUPS_ENABLED: "true" # The e2e backup targets (seaweedfs-test, sftp, webdav) are Docker # containers on a private network. The SSRF guard blocks private ranges # by default, so opt in here to let the e2e suite reach them. diff --git a/docker-compose.perf.yaml b/docker-compose.perf.yaml index 67037ee..274da95 100644 --- a/docker-compose.perf.yaml +++ b/docker-compose.perf.yaml @@ -37,6 +37,8 @@ services: PORT: "3000" JWT_SECRET: "perf-test-jwt-secret-key-minimum-32-chars" REFRESH_SECRET: "perf-test-refresh-secret-key-minimum-32" + # Required at startup; deterministic here because this stack is disposable. + ENCRYPTION_KEY: "cGVyZi1vbmx5LWVuY3J5cHRpb24ta2V5LTMyYnl0ZXM=" CORS_ORIGIN: "*" ADMIN_EMAIL: "admin@ninerlog-perf.com" DISABLE_RATE_LIMIT: "true" diff --git a/docker-compose.yml b/docker-compose.yml index 91b959e..631e001 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,13 @@ services: JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m} REFRESH_EXPIRES_IN: ${REFRESH_EXPIRES_IN:-7d} + # Encryption at rest — REQUIRED, the API will not start without it. + # Every subkey derives from this one secret: licence/credential files, + # 2FA secrets, backup credentials. Generate with + # `openssl rand -base64 32` and keep a copy somewhere safe — data sealed + # with it cannot be recovered without it, and there is no reset. + ENCRYPTION_KEY: "${ENCRYPTION_KEY:?required; generate one with openssl rand -base64 32}" + # CORS CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173,http://localhost:80} diff --git a/docs/API.md b/docs/API.md index 0cbde68..af98414 100644 --- a/docs/API.md +++ b/docs/API.md @@ -367,6 +367,10 @@ Reference photos, scans and PDFs attached to a licence or a credential: the blobs is the bandwidth half of the abuse surface the switch exists to close. Stored rows are retained and become reachable again if it is switched back on. (`DOCUMENT_IMAGES_ENABLED`, the name this shipped under before PDFs, is still honoured.) +- **Stored bytes are encrypted at rest** under a subkey of `ENCRYPTION_KEY`, which the + server requires at startup. A file it cannot decrypt — a key changed underneath stored + data — answers `500`, not `404`: it is there and it is yours, and pretending otherwise + would send a pilot looking for a scan they never lost. - Listings return metadata only (`contentType`, `byteSize`, `width`, `height`, `filename`, `caption`); `width`/`height` are null for formats without intrinsic dimensions such as PDF. The payload only ever comes back from a single file's own URL. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f9359b..7557af9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,10 +125,10 @@ All composition happens in `cmd/api/main.go`. The startup sequence is: | Metrics (`/metrics`) | `METRICS_ENABLED` not disabled | Prometheus handler + DB-stats collector | | OIDC single sign-on | `OIDC_ISSUER` set | Mode switch: the provider owns all accounts, and password login, registration, email verification, password reset, TOTP and passkeys are disabled. Discovery is lazy and retried, so the provider may boot after the API. Login state and handoff codes live in Postgres with an expired-state reaper. See [OIDC.md](./OIDC.md) | | WebAuthn / passkeys | `WEBAUTHN_RP_ID` set and OIDC off | Relying-party id/name/origins from env; ceremony state in Postgres (`WEBAUTHN_SESSION_TTL`, `WEBAUTHN_MAX_OPEN_CEREMONIES`) plus an expired-session reaper | -| Cloud backups | backup credentials key set | Registers S3/SFTP/WebDAV providers + scheduler | +| Cloud backups | `CLOUD_BACKUPS_ENABLED=true` | Registers S3/SFTP/WebDAV providers + scheduler; destination credentials encrypted under a subkey of `ENCRYPTION_KEY` | | pprof profiling | `PPROF_ENABLED=true` | Debug profiling server | | Airport DB refresh | `AIRPORT_REFRESH_INTERVAL` ≠ `off`/`0` | Refetches and re-merges both airport datasets on a timer | -| Licence/credential files | `DOCUMENT_FILES_ENABLED` not `false` (legacy `DOCUMENT_IMAGES_ENABLED` still honoured) | Reference photos, scans and PDFs on licences and credentials. Off closes every `/files` endpoint (reads included) with 403 and is reported by `GET /features`; stored rows are untouched | +| Licence/credential files | `DOCUMENT_FILES_ENABLED` not `false` (legacy `DOCUMENT_IMAGES_ENABLED` still honoured) | Reference photos, scans and PDFs on licences and credentials, stored AES-256-GCM encrypted under a subkey of `ENCRYPTION_KEY`. Off closes every `/files` endpoint (reads included) with 403 and is reported by `GET /features`; stored rows are untouched | ## Cross-cutting concerns diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 6bbe13e..3953e01 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -381,6 +381,13 @@ Requires authentication. ## Two-Factor Authentication (TOTP) +TOTP seeds are **AES-256-GCM encrypted at rest** under a subkey of `ENCRYPTION_KEY` +(`cryptoutil.PurposeTOTPSecrets`), stored with an `enc:v1:` marker. There is no +unencrypted form: a seed is a bearer credential for someone's second factor, so a value +arriving without the marker is refused rather than read as a legacy plaintext secret. +Migration 61 cleared the enrolments written before this was mandatory — see +[UPGRADING.md](./UPGRADING.md). + ### Setup ``` diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index f3ca318..dff9307 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -50,8 +50,10 @@ flowchart TD The account holder. Notable fields: - Identity: `Email`, `PasswordHash` (bcrypt; never serialized), `Name`. -- Verification & security: `EmailVerified`, `TwoFactorEnabled`, `TwoFactorSecret`, - `RecoveryCodes`, `FailedLoginAttempts`, `LockedUntil`, `Disabled`, `LastLoginAt`. +- Verification & security: `EmailVerified`, `TwoFactorEnabled`, `TwoFactorSecret` + (AES-256-GCM under a subkey of `ENCRYPTION_KEY`, `enc:v1:`-marked; migration 61 cleared + every enrolment written before that was mandatory), `RecoveryCodes`, + `FailedLoginAttempts`, `LockedUntil`, `Disabled`, `LastLoginAt`. `LastLoginAt` is written by every path that issues a session — password login, the 2FA second factor, passkeys, OIDC, and the sign-up verification link — but not by a token refresh. See @@ -112,7 +114,7 @@ the German radio certificates (`RADIO_BZF2`, `RADIO_BZF1`, `RADIO_AZF` — three certificates, not levels of one, and none of them expires), and `OTHER`. These feed expiry notifications. -### DocumentFile (`internal/models/document_file.go`, migrations 57, 59) +### DocumentFile (`internal/models/document_file.go`, migrations 57, 59, 60) Reference photos and scans attached to a licence **or** a credential — never both, never neither (`document_files_one_subject`). Two nullable FKs rather than a polymorphic @@ -120,9 +122,9 @@ neither (`document_files_one_subject`). Two nullable FKs rather than a polymorph away for real. Migration 59 renamed the table from `document_images` when PDFs were added; the data, indexes and foreign keys carried over unchanged. -- `data BYTEA` holds the raw bytes. Postgres TOASTs the payload out of line, and every - query except the single-file download uses an explicit column list that omits it, so a - listing never reads it. +- `data BYTEA` holds the encrypted bytes. Postgres TOASTs the payload out of line, and + every query except the single-file download uses an explicit column list that omits it, + so a listing never reads it. - Bounded by design: at most 5 MB (`byte_size` CHECK) and 5 files per document. The per-document cap is enforced by counting and inserting inside one transaction that first takes `SELECT … FOR UPDATE` on the owning licence/credential row, so concurrent uploads @@ -149,6 +151,14 @@ is never rendered inside the application's own origin. See The whole feature is switchable: `DOCUMENT_FILES_ENABLED=false` makes every file endpoint answer 403 without touching the stored rows. +`data` holds AES-256-GCM ciphertext, with the nonce in `data_nonce` (migration 60). That +column is `NOT NULL`: every row is encrypted, so the read path has no "maybe this one is +plaintext" branch to get wrong. `byte_size`, `width` and `height` describe the plaintext +file — the client shows them and the 5 MB cap is expressed in them — so they are not +inflated by the authentication tag. The key is a subkey of `ENCRYPTION_KEY`, which the +server requires at startup. Migration 60 deletes rows written before it; see +[UPGRADING.md](./UPGRADING.md). + ### Contact (`internal/models/contact.go`, migration 15) Reusable people (instructors, fellow crew). Referenced by flights so names don't have to diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8e865cb..783c957 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -109,6 +109,18 @@ migration and troubleshooting: [OIDC.md](./OIDC.md). bandwidth half of the problem. Stored files are retained and reappear if it is switched back on. `GET /features` reports the current state and limits so clients hide the UI instead of discovering the `403`. + + Stored bytes are **AES-256-GCM encrypted at rest** under a subkey of `ENCRYPTION_KEY` + (`pkg/cryptoutil.PurposeDocumentFile`), so a database dump, a volume snapshot or a + stolen backup does not hand over a pilot's licence. The authentication tag covers the + row's id, owner and content type, none of which are stored in the ciphertext, so a blob + cannot be moved to another row or relabelled by anyone with write access to the table. + This does **not** defend against a compromised API process, which necessarily holds the + key. + + There is no unencrypted mode. `data_nonce` is `NOT NULL`, so "stored in the clear" is + not a state the schema can represent, and a row whose bytes will not decrypt answers + `500` rather than being served as if it were the file. - **Contacts** (`internal/service/contact.go`) — reusable people (crew/instructors) with search, so names aren't retyped per flight. - **Baseline** (`internal/service/flight.go` + `FlightBaseline`) — carried-over totals from @@ -202,15 +214,16 @@ currency lapses. ## Cloud backups -Optional (enabled when the backup credentials encryption key is configured). Pilots can -back up their data to their own storage on a schedule. +Optional (`CLOUD_BACKUPS_ENABLED=true`). Pilots can back up their data to their own +storage on a schedule. - **Providers** (`internal/service/cloudbackup/provider`) — pluggable `Provider` interface with `s3`, `sftp`, and `webdav` implementations registered into a provider registry in `main.go`. S3 uses `minio-go`, SFTP uses `pkg/sftp`, WebDAV uses `gowebdav`. - **Destinations** (`destinations.go`) — CRUD; provider config plus schedule and retention - count. Credentials are **AES-256-GCM encrypted** at rest (`pkg/cryptoutil`); the key - comes from the environment, never the database. + count. Credentials are **AES-256-GCM encrypted** at rest under a subkey of + `ENCRYPTION_KEY` (`pkg/cryptoutil`); the key comes from the environment, never the + database. - **Runs** (`runner.go`, `BackupRun`) — execute a backup, record outcome, enforce retention; `jsonbuilder.go` serializes the user's data set. - **Scheduler** (`scheduler.go`) — a goroutine that triggers due backups; manual runs via diff --git a/docs/OIDC.md b/docs/OIDC.md index 4376b56..bfb3f0e 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -172,11 +172,16 @@ unparseable or non-positive value is a startup error, not a silent fallback. ### Variables that keep working `ADMIN_EMAIL`, `CORS_ORIGIN`, `FRONTEND_URL`, SMTP settings (still used for -notifications and instructor signing emails), `BACKUP_CREDENTIALS_KEY`, -`METRICS_ENABLED`, and the rate-limit knobs are all unaffected. - -`WEBAUTHN_RP_ID`, `WEBAUTHN_*` and `TOTP_ENCRYPTION_KEY` are ignored in OIDC -mode; the subsystems they configure are not started. +notifications and instructor signing emails), `CLOUD_BACKUPS_ENABLED`, +`ENCRYPTION_KEY`, `METRICS_ENABLED`, and the rate-limit knobs are all +unaffected. `ENCRYPTION_KEY` is required in OIDC mode too: it protects +licence/credential files and backup credentials, which are logbook features +rather than authentication ones. + +`WEBAUTHN_RP_ID` and `WEBAUTHN_*` are ignored in OIDC mode; the subsystems +they configure are not started. The same goes for the TOTP subkey derived from +`ENCRYPTION_KEY` — the provider owns second factors — but the key itself is +still required for everything else that uses it. --- diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md index 2746e7a..c76d35c 100644 --- a/docs/PACKAGES.md +++ b/docs/PACKAGES.md @@ -55,7 +55,7 @@ Reusable utilities with minimal dependencies, safe to use from any layer. | --- | --- | | `pkg/jwt` | `Manager` — minting and validating JWT access/refresh tokens. | | `pkg/hash` | bcrypt password hashing/verification and SHA-256 token hashing. | -| `pkg/cryptoutil` | AES-256-GCM (`AEAD`) for encrypting stored backup credentials; key helpers (`New`, `NewFromBase64`, `GenerateKey`, `GenerateKeyBase64`). | +| `pkg/cryptoutil` | AES-256-GCM (`AEAD`) for data encrypted at rest — backup credentials, TOTP secrets, licence/credential files. `Encrypt`/`Decrypt`, and `EncryptWithAAD`/`DecryptWithAAD` to bind a ciphertext to the row it belongs to. `DeriveKey`/`DeriveAEAD` cut a per-purpose subkey out of `ENCRYPTION_KEY` with HKDF-SHA256, so one operator secret serves several uses without any two sharing key bytes. Key helpers: `New`, `NewFromBase64`, `DecodeKey`, `GenerateKey`, `GenerateKeyBase64`. | | `pkg/duration` | Convert/format flight durations: minutes ↔ decimal hours, `HH:MM`, parsing. See [DOMAIN.md](./DOMAIN.md#time-and-duration-handling). | | `pkg/email` | SMTP sender (`smtp.go`) with localized templates (`templates_en.go`, `templates_de.go`) and email metrics (`metrics.go`). Recipients go through the SMTP envelope, not message headers (anti-injection). The send path runs the SMTP conversation command by command so a refusal can be attributed to the recipient or to our own setup; `delivery.go` defines those outcomes and the `DeliveryRecorder` interface that lets `internal/service` persist them without `pkg/email` depending on a database. | | `pkg/solar` | Sunrise/sunset/twilight (`Calculate`, `CivilTwilight`, `IsNight`) wrapping `go-solar`; powers the day/night flight split. | diff --git a/docs/README.md b/docs/README.md index c597e3c..b546d2a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ Developer documentation for the NinerLog API backend. Start with the | [METRICS.md](./METRICS.md) | Prometheus metrics and observability | | [PERFORMANCE.md](./PERFORMANCE.md) | Performance budgets, benchmarks, profiling | | [RUNNING_TESTS.md](./RUNNING_TESTS.md) | Running unit/integration/e2e tests | +| [UPGRADING.md](./UPGRADING.md) | Breaking changes and what an operator has to do about them | ## Keeping docs accurate diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md new file mode 100644 index 0000000..5301c45 --- /dev/null +++ b/docs/UPGRADING.md @@ -0,0 +1,103 @@ +# Upgrading + +Breaking changes, newest first, with what an operator has to do about them. + +## One `ENCRYPTION_KEY` for everything encrypted at rest + +**Breaking.** `TOTP_ENCRYPTION_KEY` and `BACKUP_CREDENTIALS_KEY` are removed. +Every key the server uses at rest is now derived from a single `ENCRYPTION_KEY`, +and that key is **required** — the API will not start without it. + +Before, each use brought its own optional key, and a missing one meant "store it +in the clear and log a warning". That produced three secrets to manage, two +plaintext fallbacks nobody noticed they were relying on, and no key at all for +licence and credential files. Now there is one secret, mandatory, with each use +deriving its own subkey via HKDF-SHA256, so no two uses share key bytes and none +of them is the master. + +### What to do + +1. **Generate a key** and put it in the environment: + + ```bash + openssl rand -base64 32 + ``` + + ``` + ENCRYPTION_KEY= + ``` + + Keep a copy wherever the database password lives. Data sealed with it cannot + be recovered without it — not from a database dump, not by us, and there is no + reset. + +2. **Unset `TOTP_ENCRYPTION_KEY` and `BACKUP_CREDENTIALS_KEY`.** The server + refuses to start while either is still set, rather than ignoring it and + letting you discover the consequence from a locked-out pilot. The data they + protected is cleared automatically — see below, and tell your users first. + +3. **If you use cloud backups**, set `CLOUD_BACKUPS_ENABLED=true`. The subsystem + used to switch itself on when 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, so it has an explicit switch now. It is off by + default, which is what "no backup key configured" meant before. + +### What breaks, and what does not + +Migration 61 does the cleanup for you, in one transaction, before the API serves +its first request. You do not have to run any SQL by hand — but you do have to +tell your users, because they will notice. + +**Every 2FA enrolment is cleared.** `two_factor_enabled` goes false and the +secret and recovery codes are dropped, for all accounts. Users sign in with their +password and re-enrol from scratch. + +This is deliberately unconditional. Secrets that were encrypted under +`TOTP_ENCRYPTION_KEY` cannot be read under the new scheme, and an account whose +enrolment exists but cannot be verified is an account that cannot log in at all, +with no self-service route back — so leaving those rows in place is worse than +clearing them. Secrets from an installation that never set that key were stored +in the clear and could technically have survived, but "your seed lived because it +was the one that was not encrypted" is not a rule worth keeping, and the read +path no longer accepts an unencrypted secret at all. + +**Every session is ended.** All refresh tokens are deleted. A session minted +after a second factor represents an authentication that has just been +invalidated, so it must not outlive it — and neither should a stolen refresh +token. Everyone signs in again. + +**Every backup destination is deleted**, along with its run history. The stored +credentials were sealed with `BACKUP_CREDENTIALS_KEY` and are now opaque bytes; +the row cannot be repaired, only re-created with the credentials entered again. + +**Licence and credential files** stored before this release are deleted by +migration 60. Nothing inside the database can encrypt them — the key lives in the +application's environment — and the alternative was a permanent second storage +format for rows still in the clear. The feature is days old and no released +version ships it, so this is test data; re-upload after deploying. + +**Passkeys are untouched.** WebAuthn credentials were never encrypted with any of +the removed keys, so there is nothing wrong with them and no reason to make +anyone re-register a security key. Users who sign in with a passkey are not +affected beyond having to do it again once, since their session was ended. + +**Passwords, flights, licences, aircraft and every other record** are likewise +unaffected. + +Migration 62 widens `users.two_factor_secret` from `VARCHAR(64)` to `TEXT`. The +column was sized for a 32-character plaintext seed; an encrypted one is 87 +characters, and Postgres rejects rather than truncates. Nothing to do — it is +listed here because it is the reason 2FA enrolment works after the upgrade. + +### Rolling back + +Migration 61 has no meaningful down step: the cleared values are gone from the +database and the keys that could have read them are gone from the environment, so +there is nothing to restore. It rolls back as a no-op rather than failing, so a +`migrate down` across it is not blocked. + +Migration 60's down step deletes stored licence/credential files, for the reason +above: after a rollback nothing can read AES-GCM ciphertext out of `data`, and +serving those bytes to a browser as if they were a JPEG would be worse than +losing them. Download anything you want to keep first, or restore a dump taken +before the upgrade. diff --git a/internal/api/generated/spec.go b/internal/api/generated/spec.go index 447ad3f..04bab94 100644 --- a/internal/api/generated/spec.go +++ b/internal/api/generated/spec.go @@ -20,769 +20,771 @@ import ( // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/api/generated/types.go b/internal/api/generated/types.go index bffc605..85ec484 100644 --- a/internal/api/generated/types.go +++ b/internal/api/generated/types.go @@ -1433,13 +1433,13 @@ type AdminConfig struct { // Example: ["s3","sftp","webdav"] CloudBackupProviders []string `json:"cloudBackupProviders"` - // CloudBackupsConfigured Whether cloud backups are enabled (BACKUP_CREDENTIALS_KEY is set) + // CloudBackupsConfigured Whether cloud backups are enabled (CLOUD_BACKUPS_ENABLED=true) CloudBackupsConfigured bool `json:"cloudBackupsConfigured"` // CorsOrigins Configured CORS allowed origins CorsOrigins []string `json:"corsOrigins"` - // DocumentFilesEnabled Whether licence/credential reference files are enabled (DOCUMENT_FILES_ENABLED is not "false") + // DocumentFilesEnabled Whether licence/credential reference files are enabled (DOCUMENT_FILES_ENABLED is not "false" and ENCRYPTION_KEY is set — stored files are encrypted at rest and the feature does not run without a key) DocumentFilesEnabled *bool `json:"documentFilesEnabled,omitempty"` // EmailSuppressedCount Addresses currently suppressed after a permanent delivery failure. @@ -3082,10 +3082,10 @@ type Features struct { // AllowedContentTypes Example: ["image/jpeg","image/png","application/pdf"] AllowedContentTypes []string `json:"allowedContentTypes"` - // Enabled When false, every /files endpoint answers 403 — uploads and downloads alike + // Enabled When false, every /files endpoint answers 403 — uploads and downloads alike. False when the operator switched the feature off, and also when no encryption key is configured: stored files are encrypted at rest and the feature does not run without one. Enabled bool `json:"enabled"` - // MaxBytes Maximum size of a single image in bytes + // MaxBytes Maximum size of a single file in bytes, measured before encryption // // Example: 5242880 MaxBytes int `json:"maxBytes"` diff --git a/internal/api/handlers/admin_dashboard.go b/internal/api/handlers/admin_dashboard.go index 3dcd0ba..e3934f4 100644 --- a/internal/api/handlers/admin_dashboard.go +++ b/internal/api/handlers/admin_dashboard.go @@ -206,7 +206,7 @@ func (h *APIHandler) GetAdminConfig(c *gin.Context) { // Admin email configured? adminEmailConfigured := h.adminEmail != "" - // Cloud backups configured? (set when BACKUP_CREDENTIALS_KEY is provided) + // Cloud backups configured? (set when CLOUD_BACKUPS_ENABLED=true) cloudBackupsConfigured := h.backupService != nil cloudBackupProviders := []string{} if cloudBackupsConfigured { diff --git a/internal/api/handlers/admin_email_escalation_test.go b/internal/api/handlers/admin_email_escalation_test.go index 8c26f5c..44b4171 100644 --- a/internal/api/handlers/admin_email_escalation_test.go +++ b/internal/api/handlers/admin_email_escalation_test.go @@ -31,7 +31,7 @@ func TestIsAdminUser_RequiresVerifiedEmail(t *testing.T) { } func TestUpdateCurrentUser_EmailChangeRequiresPassword(t *testing.T) { - h, userRepo := setupTestHandler() // adminEmail = "admin@test.com" + h, userRepo := setupTestHandler(t) // adminEmail = "admin@test.com" w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -63,7 +63,7 @@ func TestUpdateCurrentUser_EmailChangeRequiresPassword(t *testing.T) { } func TestUpdateCurrentUser_EmailChangeClearsVerifiedAndDeniesAdmin(t *testing.T) { - h, userRepo := setupTestHandler() // adminEmail = "admin@test.com" + h, userRepo := setupTestHandler(t) // adminEmail = "admin@test.com" w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -112,7 +112,7 @@ func TestUpdateCurrentUser_EmailChangeClearsVerifiedAndDeniesAdmin(t *testing.T) // openapi_types.Email and would break every response serializing this user — // including the admin user list. func TestUpdateCurrentUser_RejectsNonRoundTrippableEmail(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/internal/api/handlers/bulk_ops_test.go b/internal/api/handlers/bulk_ops_test.go index 246d714..59bce3c 100644 --- a/internal/api/handlers/bulk_ops_test.go +++ b/internal/api/handlers/bulk_ops_test.go @@ -18,7 +18,7 @@ import ( // rollback while the summary reported only what landed first. Refusing an // oversized backup up front avoids half-applying it. func TestImportDataJSON_RejectsOversizedBackup(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -56,7 +56,7 @@ func TestImportDataJSON_RejectsOversizedBackup(t *testing.T) { } func TestImportDataJSON_AcceptsBackupWithinCaps(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -81,7 +81,7 @@ func TestImportDataJSON_AcceptsBackupWithinCaps(t *testing.T) { // Each entity list has its own ceiling, so a backup can't smuggle a huge // aircraft or credential list past the flight cap. func TestImportDataJSON_CapsEachEntityList(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("POST", "/auth/register", diff --git a/internal/api/handlers/document_file.go b/internal/api/handlers/document_file.go index efa31ac..3a6c741 100644 --- a/internal/api/handlers/document_file.go +++ b/internal/api/handlers/document_file.go @@ -210,6 +210,13 @@ func (h *APIHandler) sendDocumentFileError(c *gin.Context, err error) { case errors.Is(err, service.ErrDocumentFileLimitReached): h.sendError(c, http.StatusConflict, fmt.Sprintf("This document already has the maximum of %d images", models.MaxDocumentFilesPerSubject)) + case errors.Is(err, service.ErrDocumentFileUnreadable): + // The row exists and belongs to the caller; the server just cannot open + // it, which means a key problem on this side. A 500 is the honest + // answer — nothing the client sends will change it — and the message + // says so rather than implying the upload was somehow at fault. + h.sendError(c, http.StatusInternalServerError, + "This file cannot be read with the server's current encryption key") case errors.Is(err, service.ErrDocumentFileEmpty), errors.Is(err, service.ErrDocumentFileUnsupported), errors.Is(err, service.ErrDocumentFileCorrupt), diff --git a/internal/api/handlers/export_pdf_cap_test.go b/internal/api/handlers/export_pdf_cap_test.go index 8036c15..5f265c1 100644 --- a/internal/api/handlers/export_pdf_cap_test.go +++ b/internal/api/handlers/export_pdf_cap_test.go @@ -16,7 +16,7 @@ import ( // owned by that user. func seedPDFExportHandler(t *testing.T, n int) (*APIHandler, uuid.UUID) { t.Helper() - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) userID := uuid.New() userRepo.users[userID] = &models.User{ID: userID, Email: "pdf-export-test@example.com"} diff --git a/internal/api/handlers/handlers_test.go b/internal/api/handlers/handlers_test.go index 06fcbc0..d36ddf3 100644 --- a/internal/api/handlers/handlers_test.go +++ b/internal/api/handlers/handlers_test.go @@ -14,6 +14,7 @@ import ( "github.com/fjaeckel/ninerlog-api/internal/models" "github.com/fjaeckel/ninerlog-api/internal/repository" "github.com/fjaeckel/ninerlog-api/internal/service" + "github.com/fjaeckel/ninerlog-api/pkg/cryptoutil" "github.com/fjaeckel/ninerlog-api/pkg/email" "github.com/fjaeckel/ninerlog-api/pkg/jwt" "github.com/gin-gonic/gin" @@ -380,7 +381,24 @@ func (m *mockFlightRepo) GetCurrencyData(_ context.Context, _ uuid.UUID, _ time. // ---- Test setup helpers ---- -func setupTestHandler() (*APIHandler, *mockUserRepo) { +// handlerTestTOTPAEAD builds the 2FA cipher the way main does. 2FA has no +// unencrypted mode any more, so a handler test that wires the service without a +// key would fail at enrolment rather than exercising the handler. +func handlerTestTOTPAEAD(t *testing.T) *cryptoutil.AEAD { + t.Helper() + master, err := cryptoutil.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + aead, err := cryptoutil.DeriveAEAD(master, cryptoutil.PurposeTOTPSecrets) + if err != nil { + t.Fatalf("derive key: %v", err) + } + return aead +} + +func setupTestHandler(t *testing.T) (*APIHandler, *mockUserRepo) { + t.Helper() gin.SetMode(gin.TestMode) userRepo := newHandlerMockUserRepo() @@ -388,7 +406,7 @@ func setupTestHandler() (*APIHandler, *mockUserRepo) { passwordRepo := &mockPasswordResetRepo{} jwtMgr := jwt.NewManager("test-access", "test-refresh", 15*time.Minute, 7*24*time.Hour) - twoFactorSvc := service.NewTwoFactorService(userRepo, jwtMgr, nil) + twoFactorSvc := service.NewTwoFactorService(userRepo, jwtMgr, handlerTestTOTPAEAD(t)) authSvc := service.NewAuthService(userRepo, refreshRepo, passwordRepo, &mockEmailVerificationRepo{}, jwtMgr, twoFactorSvc) credSvc := service.NewCredentialService(newMockCredentialRepo()) @@ -419,7 +437,7 @@ func authenticatedContext(w *httptest.ResponseRecorder, userID uuid.UUID) *gin.C // ---- Auth handler tests ---- func TestRegisterUser_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) body := `{"email":"test@example.com","password":"password1234","name":"Test User"}` w := httptest.NewRecorder() @@ -444,7 +462,7 @@ func TestRegisterUser_Success(t *testing.T) { } func TestRegisterUser_Success_WithSMTPConfigured(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) h.SetEmailSender(email.NewSender(&email.SMTPConfig{ Host: "127.0.0.1", Port: "1", @@ -471,7 +489,7 @@ func TestRegisterUser_Success_WithSMTPConfigured(t *testing.T) { } func TestRegisterUser_DuplicateEmail(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) body := `{"email":"test@example.com","password":"password1234","name":"Test User"}` @@ -495,7 +513,7 @@ func TestRegisterUser_DuplicateEmail(t *testing.T) { } func TestRegisterUser_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -510,7 +528,7 @@ func TestRegisterUser_InvalidBody(t *testing.T) { } func TestRegisterUser_ShortPassword(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) body := `{"email":"test@example.com","password":"short","name":"Test User"}` w := httptest.NewRecorder() @@ -526,7 +544,7 @@ func TestRegisterUser_ShortPassword(t *testing.T) { } func TestLoginUser_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) // Register first regBody := `{"email":"login@example.com","password":"password1234","name":"Login User"}` @@ -550,7 +568,7 @@ func TestLoginUser_Success(t *testing.T) { } func TestLoginUser_InvalidCredentials(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) body := `{"email":"nobody@example.com","password":"password1234"}` w := httptest.NewRecorder() @@ -566,7 +584,7 @@ func TestLoginUser_InvalidCredentials(t *testing.T) { } func TestLoginUser_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -581,7 +599,7 @@ func TestLoginUser_InvalidBody(t *testing.T) { } func TestRefreshToken_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -596,7 +614,7 @@ func TestRefreshToken_InvalidBody(t *testing.T) { } func TestRefreshToken_InvalidToken(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) body := `{"refreshToken":"invalid-token"}` w := httptest.NewRecorder() @@ -614,7 +632,7 @@ func TestRefreshToken_InvalidToken(t *testing.T) { // ---- User handler tests ---- func TestGetCurrentUser_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -629,7 +647,7 @@ func TestGetCurrentUser_Unauthorized(t *testing.T) { } func TestGetCurrentUser_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c := authenticatedContext(w, uuid.New()) @@ -643,7 +661,7 @@ func TestGetCurrentUser_NotFound(t *testing.T) { } func TestGetCurrentUser_Success(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) userID := uuid.New() userRepo.users[userID] = &models.User{ @@ -664,7 +682,7 @@ func TestGetCurrentUser_Success(t *testing.T) { } func TestUpdateCurrentUser_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -679,7 +697,7 @@ func TestUpdateCurrentUser_Unauthorized(t *testing.T) { } func TestChangePassword_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -694,7 +712,7 @@ func TestChangePassword_Unauthorized(t *testing.T) { } func TestDeleteCurrentUser_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -711,7 +729,7 @@ func TestDeleteCurrentUser_Unauthorized(t *testing.T) { // ---- Credential handler tests ---- func TestListCredentials_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -725,7 +743,7 @@ func TestListCredentials_Unauthorized(t *testing.T) { } func TestListCredentials_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -740,7 +758,7 @@ func TestListCredentials_Success(t *testing.T) { } func TestCreateCredential_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -758,7 +776,7 @@ func TestCreateCredential_InvalidBody(t *testing.T) { // ---- Aircraft handler tests ---- func TestListAircraft_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -772,7 +790,7 @@ func TestListAircraft_Unauthorized(t *testing.T) { } func TestListAircraft_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -787,7 +805,7 @@ func TestListAircraft_Success(t *testing.T) { } func TestCreateAircraft_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -803,7 +821,7 @@ func TestCreateAircraft_InvalidBody(t *testing.T) { } func TestCreateAircraft_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -820,7 +838,7 @@ func TestCreateAircraft_Unauthorized(t *testing.T) { // ---- Contact handler tests ---- func TestListContacts_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -834,7 +852,7 @@ func TestListContacts_Unauthorized(t *testing.T) { } func TestListContacts_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -849,7 +867,7 @@ func TestListContacts_Success(t *testing.T) { } func TestCreateContact_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -867,7 +885,7 @@ func TestCreateContact_InvalidBody(t *testing.T) { // ---- Airport handler tests ---- func TestGetAirport_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) // Set empty airport DB airports.SetTestDB(map[string]airports.AirportInfo{}) defer airports.SetTestDB(nil) @@ -884,7 +902,7 @@ func TestGetAirport_NotFound(t *testing.T) { } func TestGetAirport_Found(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) airports.SetTestDB(map[string]airports.AirportInfo{ "EDDF": {ICAO: "EDDF", Name: "Frankfurt Airport", Latitude: 50.0333, Longitude: 8.5706}, }) @@ -908,7 +926,7 @@ func TestGetAirport_Found(t *testing.T) { } func TestGetAirport_CaseInsensitive(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) airports.SetTestDB(map[string]airports.AirportInfo{ "EDDF": {ICAO: "EDDF", Name: "Frankfurt Airport", Latitude: 50.0333, Longitude: 8.5706}, }) @@ -928,7 +946,7 @@ func TestGetAirport_CaseInsensitive(t *testing.T) { // ---- sendError tests ---- func TestSendError_BasicMessage(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -947,7 +965,7 @@ func TestSendError_BasicMessage(t *testing.T) { } func TestSendError_WithDetails(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -965,7 +983,7 @@ func TestSendError_WithDetails(t *testing.T) { // ---- getUserIDFromContext tests ---- func TestGetUserIDFromContext_FromMiddleware(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -981,7 +999,7 @@ func TestGetUserIDFromContext_FromMiddleware(t *testing.T) { } func TestGetUserIDFromContext_NoAuth(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -994,7 +1012,7 @@ func TestGetUserIDFromContext_NoAuth(t *testing.T) { } func TestGetUserIDFromContext_FromBearerToken(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() token, _ := h.jwtManager.GenerateAccessToken(userID) @@ -1016,7 +1034,7 @@ func TestGetUserIDFromContext_FromBearerToken(t *testing.T) { // ---- Contact CRUD handler tests ---- func TestCreateContact_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() body := `{"name":"John Doe","email":"john@example.com"}` @@ -1033,7 +1051,7 @@ func TestCreateContact_Success(t *testing.T) { } func TestGetContact_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1048,7 +1066,7 @@ func TestGetContact_NotFound(t *testing.T) { } func TestDeleteContact_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1063,7 +1081,7 @@ func TestDeleteContact_NotFound(t *testing.T) { } func TestUpdateContact_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1079,7 +1097,7 @@ func TestUpdateContact_InvalidBody(t *testing.T) { } func TestSearchContacts_EmptyQuery(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1094,7 +1112,7 @@ func TestSearchContacts_EmptyQuery(t *testing.T) { } func TestSearchContacts_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1108,7 +1126,7 @@ func TestSearchContacts_Unauthorized(t *testing.T) { } func TestGetContact_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1122,7 +1140,7 @@ func TestGetContact_Unauthorized(t *testing.T) { } func TestUpdateContact_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1137,7 +1155,7 @@ func TestUpdateContact_Unauthorized(t *testing.T) { } func TestDeleteContact_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1151,7 +1169,7 @@ func TestDeleteContact_Unauthorized(t *testing.T) { } func TestCreateContact_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1168,7 +1186,7 @@ func TestCreateContact_Unauthorized(t *testing.T) { // ---- Credential CRUD handler tests ---- func TestGetCredential_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1183,7 +1201,7 @@ func TestGetCredential_NotFound(t *testing.T) { } func TestGetCredential_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1197,7 +1215,7 @@ func TestGetCredential_Unauthorized(t *testing.T) { } func TestDeleteCredential_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1212,7 +1230,7 @@ func TestDeleteCredential_NotFound(t *testing.T) { } func TestDeleteCredential_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1226,7 +1244,7 @@ func TestDeleteCredential_Unauthorized(t *testing.T) { } func TestUpdateCredential_InvalidBody2(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1242,7 +1260,7 @@ func TestUpdateCredential_InvalidBody2(t *testing.T) { } func TestUpdateCredential_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1259,7 +1277,7 @@ func TestUpdateCredential_Unauthorized(t *testing.T) { // ---- Notification handler tests ---- func TestGetNotificationPreferences_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1273,7 +1291,7 @@ func TestGetNotificationPreferences_Unauthorized(t *testing.T) { } func TestUpdateNotificationPreferences_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1288,7 +1306,7 @@ func TestUpdateNotificationPreferences_Unauthorized(t *testing.T) { } func TestUpdateNotificationPreferences_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) addNotificationService(h) userID := uuid.New() @@ -1305,7 +1323,7 @@ func TestUpdateNotificationPreferences_InvalidBody(t *testing.T) { } func TestGetNotificationHistory_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1319,7 +1337,7 @@ func TestGetNotificationHistory_Unauthorized(t *testing.T) { } func TestGetNotificationPreferences_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) addNotificationService(h) userID := uuid.New() @@ -1335,7 +1353,7 @@ func TestGetNotificationPreferences_Success(t *testing.T) { } func TestGetNotificationHistory_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) addNotificationService(h) userID := uuid.New() @@ -1353,7 +1371,7 @@ func TestGetNotificationHistory_Success(t *testing.T) { // ---- 2FA handler tests ---- func TestSetup2FA_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1367,7 +1385,7 @@ func TestSetup2FA_Unauthorized(t *testing.T) { } func TestVerify2FA_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1382,7 +1400,7 @@ func TestVerify2FA_Unauthorized(t *testing.T) { } func TestDisable2FA_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1397,7 +1415,7 @@ func TestDisable2FA_Unauthorized(t *testing.T) { } func TestLogin2FA_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1414,17 +1432,26 @@ func TestLogin2FA_InvalidBody(t *testing.T) { // A 2FA account only gets its session here — LoginUser answered the password // with a challenge — so this is where the login is recorded. func TestLogin2FA_RecordsLastLogin(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) - key, err := totp.Generate(totp.GenerateOpts{Issuer: "NinerLog", AccountName: "2fa@example.com"}) - if err != nil { - t.Fatalf("totp.Generate() error = %v", err) - } - secret := key.Secret() - user := &models.User{Email: "2fa@example.com", Name: "Two Factor", TwoFactorEnabled: true, TwoFactorSecret: &secret} + // Enrol through the service rather than writing a seed into the repo by + // hand: stored secrets are encrypted, and a hand-planted plaintext one is + // refused on read. + user := &models.User{Email: "2fa@example.com", Name: "Two Factor"} if err := userRepo.Create(context.Background(), user); err != nil { t.Fatalf("Create() error = %v", err) } + secret, _, err := h.twoFactorService.SetupTOTP(context.Background(), user.ID) + if err != nil { + t.Fatalf("SetupTOTP() error = %v", err) + } + enrolCode, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("GenerateCode() error = %v", err) + } + if _, err := h.twoFactorService.VerifyAndEnable(context.Background(), user.ID, enrolCode); err != nil { + t.Fatalf("VerifyAndEnable() error = %v", err) + } twoFactorToken, err := h.jwtManager.Generate2FAToken(user.ID) if err != nil { @@ -1454,7 +1481,7 @@ func TestLogin2FA_RecordsLastLogin(t *testing.T) { // ---- User statistics handler tests ---- func TestGetMyStatistics_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1470,7 +1497,7 @@ func TestGetMyStatistics_Unauthorized(t *testing.T) { // ---- Bulk delete handler tests ---- func TestDeleteAllFlights_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1484,7 +1511,7 @@ func TestDeleteAllFlights_Unauthorized(t *testing.T) { } func TestDeleteAllUserData_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1500,7 +1527,7 @@ func TestDeleteAllUserData_Unauthorized(t *testing.T) { // ---- Currency handler tests ---- func TestGetAllCurrencyStatus_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1516,7 +1543,7 @@ func TestGetAllCurrencyStatus_Unauthorized(t *testing.T) { // ---- RecalculateFlights handler tests ---- func TestRecalculateFlights_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1530,7 +1557,7 @@ func TestRecalculateFlights_Unauthorized(t *testing.T) { } func TestRecalculateFlights_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1553,7 +1580,7 @@ func TestRecalculateFlights_Success(t *testing.T) { // ---- UpdateCurrentUser handler tests ---- func TestUpdateCurrentUser_Success(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) userID := uuid.New() userRepo.users[userID] = &models.User{ @@ -1576,7 +1603,7 @@ func TestUpdateCurrentUser_Success(t *testing.T) { } func TestUpdateCurrentUser_InvalidBody(t *testing.T) { - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) userID := uuid.New() userRepo.users[userID] = &models.User{ID: userID, Email: "test@test.com", Name: "Test"} @@ -1596,7 +1623,7 @@ func TestUpdateCurrentUser_InvalidBody(t *testing.T) { // ---- GetMyStatistics success test ---- func TestGetMyStatistics_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1613,7 +1640,7 @@ func TestGetMyStatistics_Success(t *testing.T) { // ---- Flight handler tests ---- func TestCreateFlight_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1628,7 +1655,7 @@ func TestCreateFlight_Unauthorized(t *testing.T) { } func TestCreateFlight_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1644,7 +1671,7 @@ func TestCreateFlight_InvalidBody(t *testing.T) { } func TestGetFlight_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1658,7 +1685,7 @@ func TestGetFlight_Unauthorized(t *testing.T) { } func TestGetFlight_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1673,7 +1700,7 @@ func TestGetFlight_NotFound(t *testing.T) { } func TestDeleteFlight_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1687,7 +1714,7 @@ func TestDeleteFlight_Unauthorized(t *testing.T) { } func TestDeleteFlight_NotFound(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1702,7 +1729,7 @@ func TestDeleteFlight_NotFound(t *testing.T) { } func TestUpdateFlight_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1717,7 +1744,7 @@ func TestUpdateFlight_Unauthorized(t *testing.T) { } func TestUpdateFlight_InvalidBody(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1733,7 +1760,7 @@ func TestUpdateFlight_InvalidBody(t *testing.T) { } func TestListFlights_Unauthorized(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1747,7 +1774,7 @@ func TestListFlights_Unauthorized(t *testing.T) { } func TestListFlights_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) userID := uuid.New() w := httptest.NewRecorder() @@ -1764,7 +1791,7 @@ func TestListFlights_Success(t *testing.T) { // ---- UpdateNotificationPreferences success test ---- func TestUpdateNotificationPreferences_Success(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) addNotificationService(h) userID := uuid.New() @@ -1782,7 +1809,7 @@ func TestUpdateNotificationPreferences_Success(t *testing.T) { } func TestUpdateNotificationPreferences_InvalidCheckHour(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) addNotificationService(h) userID := uuid.New() diff --git a/internal/api/handlers/logout_test.go b/internal/api/handlers/logout_test.go index 7ab1c91..f75eabd 100644 --- a/internal/api/handlers/logout_test.go +++ b/internal/api/handlers/logout_test.go @@ -15,7 +15,7 @@ import ( // stayed valid in the database for its full lifetime, so a retained copy could // resurrect the session after the user believed it had ended. func TestLogout_RevokesRefreshToken(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -80,7 +80,7 @@ func TestLogout_RevokesRefreshToken(t *testing.T) { // Revocation is idempotent and must not reveal whether a token existed. func TestLogout_UnknownTokenStillReturns204(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/internal/api/handlers/maps.go b/internal/api/handlers/maps.go index 5c6217d..b34e561 100644 --- a/internal/api/handlers/maps.go +++ b/internal/api/handlers/maps.go @@ -213,7 +213,7 @@ func (h *APIHandler) SetCORSOrigins(origins []string) { } // SetBackupService stores the cloud backup service (optional — only set when -// BACKUP_CREDENTIALS_KEY is configured at startup). +// CLOUD_BACKUPS_ENABLED=true at startup). func (h *APIHandler) SetBackupService(s *cloudbackup.Service) { h.backupService = s } diff --git a/internal/api/handlers/oidc_mode_test.go b/internal/api/handlers/oidc_mode_test.go index 5d5b6a9..34f2633 100644 --- a/internal/api/handlers/oidc_mode_test.go +++ b/internal/api/handlers/oidc_mode_test.go @@ -27,7 +27,7 @@ import ( // every endpoint under test refuses before any network call. func oidcTestHandler(t *testing.T) (*APIHandler, *mockUserRepo) { t.Helper() - h, userRepo := setupTestHandler() + h, userRepo := setupTestHandler(t) svc, err := service.NewOIDCService(service.OIDCConfig{ Issuer: "https://idp.example.com", @@ -135,7 +135,7 @@ func TestOIDCMode_LocalCredentialEndpointsAreClosed(t *testing.T) { func TestOIDCMode_LocalCredentialEndpointsStayOpenInLocalMode(t *testing.T) { // The mirror image: the gate must not fire on a normal deployment. - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = jsonRequest("POST", "/api/v1/auth/register", @@ -222,7 +222,7 @@ func TestOIDCMode_AccountDeletionConfirmsWithEmail(t *testing.T) { } func TestAuthProviders_ReportsLocalModeByDefault(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/auth/providers", nil) @@ -292,7 +292,7 @@ func TestAuthProviders_ReportsOIDCMode(t *testing.T) { } func TestOIDCEndpointsReport503WhenNotConfigured(t *testing.T) { - h, _ := setupTestHandler() + h, _ := setupTestHandler(t) for _, tc := range []struct { name string invoke func(*gin.Context) diff --git a/internal/api/handlers/signature_handlers_test.go b/internal/api/handlers/signature_handlers_test.go index 34b0b8d..3108d57 100644 --- a/internal/api/handlers/signature_handlers_test.go +++ b/internal/api/handlers/signature_handlers_test.go @@ -135,7 +135,7 @@ func setupSignatureTestHandlerSharedRepo(t *testing.T) (h *APIHandler, userID uu jwtMgr := jwt.NewManager("test-access", "test-refresh", 15*time.Minute, 7*24*time.Hour) h = &APIHandler{ - authService: service.NewAuthService(userRepo, newHandlerMockRefreshTokenRepo(), &mockPasswordResetRepo{}, &mockEmailVerificationRepo{}, jwtMgr, service.NewTwoFactorService(userRepo, jwtMgr, nil)), + authService: service.NewAuthService(userRepo, newHandlerMockRefreshTokenRepo(), &mockPasswordResetRepo{}, &mockEmailVerificationRepo{}, jwtMgr, service.NewTwoFactorService(userRepo, jwtMgr, handlerTestTOTPAEAD(t))), flightService: flightSvc, flightSignatureService: sigSvc, jwtManager: jwtMgr, diff --git a/internal/models/document_file.go b/internal/models/document_file.go index 639e725..4b15ced 100644 --- a/internal/models/document_file.go +++ b/internal/models/document_file.go @@ -90,6 +90,12 @@ func ContentTypeIsInlineSafe(ct string) bool { // credential. Data carries the raw bytes and is only populated on the // single-image download path — list and create responses leave it nil so a // listing never drags megabytes through the service layer. +// +// Data is plaintext everywhere above the repository: the service seals it on +// the way in and opens it on the way out, so handlers and every other reader +// see the file itself. DataNonce is the storage layer's business — it carries +// the AES-GCM nonce for the stored ciphertext, and is nil for a row written +// before at-rest encryption was introduced. type DocumentFile struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"userId"` @@ -105,7 +111,8 @@ type DocumentFile struct { Filename *string `json:"filename,omitempty"` Caption *string `json:"caption,omitempty"` - Data []byte `json:"-"` + Data []byte `json:"-"` + DataNonce []byte `json:"-"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/internal/repository/postgres/document_file.go b/internal/repository/postgres/document_file.go index 74dea89..684e645 100644 --- a/internal/repository/postgres/document_file.go +++ b/internal/repository/postgres/document_file.go @@ -59,7 +59,10 @@ func scanDocumentFile(scan func(dest ...any) error, withData bool) (*models.Docu &img.CreatedAt, &img.UpdatedAt, } if withData { - dest = append(dest, &img.Data) + // The nonce travels with the payload and only with the payload: a + // metadata listing has no use for it, and reading it there would be a + // second reason to touch the row's TOASTed columns. + dest = append(dest, &img.Data, &img.DataNonce) } if err := scan(dest...); err != nil { return nil, err @@ -123,12 +126,21 @@ func (r *documentFileRepository) Create(ctx context.Context, image *models.Docum return repository.ErrDocumentFileLimit } + // The id is supplied by the caller rather than defaulted by the database. + // It has to be: the stored bytes are sealed against it, so it must exist + // before the payload does. A caller that leaves it unset gets one here so + // this stays usable without the encryption path. + if image.ID == uuid.Nil { + image.ID = uuid.New() + } + insertQuery := fmt.Sprintf(` - INSERT INTO document_files (user_id, %s, content_type, byte_size, width, height, filename, caption, data) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, created_at, updated_at + INSERT INTO document_files (id, user_id, %s, content_type, byte_size, width, height, filename, caption, data, data_nonce) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING created_at, updated_at `, col) if err := tx.QueryRowContext(ctx, insertQuery, + image.ID, image.UserID, subjectID, image.ContentType, @@ -138,7 +150,8 @@ func (r *documentFileRepository) Create(ctx context.Context, image *models.Docum image.Filename, image.Caption, image.Data, - ).Scan(&image.ID, &image.CreatedAt, &image.UpdatedAt); err != nil { + image.DataNonce, + ).Scan(&image.CreatedAt, &image.UpdatedAt); err != nil { return err } @@ -179,7 +192,7 @@ func (r *documentFileRepository) GetWithData(ctx context.Context, userID uuid.UU return nil, err } query := fmt.Sprintf(` - SELECT %s, data FROM document_files + SELECT %s, data, data_nonce FROM document_files WHERE id = $1 AND user_id = $2 AND %s = $3 `, documentFileColumns, col) diff --git a/internal/repository/postgres/document_file_integration_test.go b/internal/repository/postgres/document_file_integration_test.go index 1f692bd..b9cf4c4 100644 --- a/internal/repository/postgres/document_file_integration_test.go +++ b/internal/repository/postgres/document_file_integration_test.go @@ -51,6 +51,11 @@ func TestDocumentFileRepositoryIntegration(t *testing.T) { t.Fatalf("create credential: %v", err) } + // Every row carries a nonce — the column is NOT NULL — so these fixtures + // supply one. The repository stores whatever bytes it is handed and does + // not care whether they are really ciphertext; that is the service's job. + nonce := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + newImage := func(data []byte) *models.DocumentFile { licenseID := license.ID w, h := 4, 4 @@ -64,6 +69,7 @@ func TestDocumentFileRepositoryIntegration(t *testing.T) { Height: &h, Filename: &name, Data: data, + DataNonce: nonce, } } @@ -254,3 +260,89 @@ func TestDocumentFileRepositoryIntegration(t *testing.T) { } }) } + +// The storage half of at-rest encryption: the nonce column round-trips with the +// payload, and the schema refuses the shapes the read path would have to +// second-guess — a wrong-length nonce, or none at all. +func TestDocumentFileRepositoryEncryptionIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db := testutil.SetupTestDB(t) + defer testutil.TeardownTestDB(t, db) + + ctx := context.Background() + userRepo := postgres.NewUserRepository(db) + licenseRepo := postgres.NewLicenseRepository(db) + fileRepo := postgres.NewDocumentFileRepository(db) + + user := testutil.CreateTestUser("docfile-crypto@example.com", "Crypto User", "hashedpass") + if err := userRepo.Create(ctx, user); err != nil { + t.Fatalf("create user: %v", err) + } + license := &models.License{ + UserID: user.ID, RegulatoryAuthority: "EASA", LicenseType: "PPL", + LicenseNumber: "PPL-CRYPTO-1", IssueDate: time.Now(), IssuingAuthority: "LBA", + } + if err := licenseRepo.Create(ctx, license); err != nil { + t.Fatalf("create license: %v", err) + } + + newFile := func(data, nonce []byte) *models.DocumentFile { + licenseID := license.ID + return &models.DocumentFile{ + UserID: user.ID, + LicenseID: &licenseID, + ContentType: "image/png", + ByteSize: len(data), + Data: data, + DataNonce: nonce, + } + } + + ciphertext := []byte{0x9a, 0x11, 0x00, 0xff, 0x42} + nonce := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + + t.Run("the nonce round-trips with the payload", func(t *testing.T) { + // A caller-supplied id must survive: the service seals the bytes + // against it, so a database-generated substitute would make the file + // unreadable. + file := newFile(ciphertext, nonce) + file.ID = uuid.New() + wanted := file.ID + if err := fileRepo.Create(ctx, file, models.MaxDocumentFilesPerSubject); err != nil { + t.Fatalf("create: %v", err) + } + if file.ID != wanted { + t.Fatalf("id = %v, want the one supplied (%v)", file.ID, wanted) + } + + fetched, err := fileRepo.GetWithData(ctx, user.ID, models.DocumentSubjectLicense, license.ID, file.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !bytes.Equal(fetched.Data, ciphertext) { + t.Error("stored bytes differ from what was written") + } + if !bytes.Equal(fetched.DataNonce, nonce) { + t.Errorf("nonce = %v, want %v", fetched.DataNonce, nonce) + } + }) + + t.Run("a wrong-length nonce is refused by the database", func(t *testing.T) { + file := newFile(ciphertext, []byte{1, 2, 3}) + if err := fileRepo.Create(ctx, file, models.MaxDocumentFilesPerSubject); err == nil { + t.Fatal("a 3-byte nonce was accepted") + } + }) + + t.Run("a row with no nonce is refused by the database", func(t *testing.T) { + // The column is NOT NULL precisely so that "stored in the clear" is not + // a state the schema can represent. + file := newFile(ciphertext, nil) + if err := fileRepo.Create(ctx, file, models.MaxDocumentFilesPerSubject); err == nil { + t.Fatal("a row without a nonce was accepted") + } + }) +} diff --git a/internal/service/auth_enumeration_test.go b/internal/service/auth_enumeration_test.go index 5c728b3..0861f15 100644 --- a/internal/service/auth_enumeration_test.go +++ b/internal/service/auth_enumeration_test.go @@ -11,14 +11,15 @@ import ( // setupAuthServiceWithRepo builds an AuthService and returns the underlying // user repo so tests can flip account-state flags after registration. -func setupAuthServiceWithRepo() (*service.AuthService, *mockUserRepo) { +func setupAuthServiceWithRepo(t *testing.T) (*service.AuthService, *mockUserRepo) { + t.Helper() userRepo := newMockUserRepo() refreshTokenRepo := newMockRefreshTokenRepo() passwordResetRepo := newMockPasswordResetRepo() emailVerifyRepo := newMockEmailVerificationRepo() jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) return service.NewAuthService(userRepo, refreshTokenRepo, passwordResetRepo, emailVerifyRepo, jwtManager, - service.NewTwoFactorService(userRepo, jwtManager, nil)), userRepo + service.NewTwoFactorService(userRepo, jwtManager, testTOTPAEAD(t))), userRepo } const enumPassword = "correct-horse-battery" @@ -38,7 +39,7 @@ func registerEnumUser(t *testing.T, svc *service.AuthService, email string) { // the password is wrong: the pre-auth response must be the generic // ErrInvalidCredentials, not ErrAccountDisabled. func TestLogin_DisabledAccount_WrongPassword_IsGeneric(t *testing.T) { - svc, repo := setupAuthServiceWithRepo() + svc, repo := setupAuthServiceWithRepo(t) ctx := context.Background() registerEnumUser(t, svc, "disabled@example.com") @@ -54,7 +55,7 @@ func TestLogin_DisabledAccount_WrongPassword_IsGeneric(t *testing.T) { // The legitimate owner (correct password) still learns the account is disabled. func TestLogin_DisabledAccount_CorrectPassword_RevealsDisabled(t *testing.T) { - svc, repo := setupAuthServiceWithRepo() + svc, repo := setupAuthServiceWithRepo(t) ctx := context.Background() registerEnumUser(t, svc, "disabled2@example.com") @@ -70,7 +71,7 @@ func TestLogin_DisabledAccount_CorrectPassword_RevealsDisabled(t *testing.T) { // An unverified account must not be distinguishable when the password is wrong. func TestLogin_UnverifiedAccount_WrongPassword_IsGeneric(t *testing.T) { - svc, repo := setupAuthServiceWithRepo() + svc, repo := setupAuthServiceWithRepo(t) ctx := context.Background() registerEnumUser(t, svc, "unverified@example.com") @@ -86,7 +87,7 @@ func TestLogin_UnverifiedAccount_WrongPassword_IsGeneric(t *testing.T) { // The legitimate owner (correct password) still learns the email is unverified. func TestLogin_UnverifiedAccount_CorrectPassword_RevealsUnverified(t *testing.T) { - svc, repo := setupAuthServiceWithRepo() + svc, repo := setupAuthServiceWithRepo(t) ctx := context.Background() registerEnumUser(t, svc, "unverified2@example.com") @@ -103,7 +104,7 @@ func TestLogin_UnverifiedAccount_CorrectPassword_RevealsUnverified(t *testing.T) // The core enumeration guarantee: a wrong password against an existing account // and a login for a non-existent account return the exact same error. func TestLogin_UnknownVsWrongPassword_SameError(t *testing.T) { - svc, _ := setupAuthServiceWithRepo() + svc, _ := setupAuthServiceWithRepo(t) ctx := context.Background() registerEnumUser(t, svc, "known@example.com") diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 7ca73c0..d13e4a7 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -341,18 +341,19 @@ func (m *mockPasswordResetRepo) DeleteForUser(ctx context.Context, userID uuid.U } // Test functions -func setupAuthService() *service.AuthService { +func setupAuthService(t *testing.T) *service.AuthService { + t.Helper() userRepo := newMockUserRepo() refreshTokenRepo := newMockRefreshTokenRepo() passwordResetRepo := newMockPasswordResetRepo() emailVerifyRepo := newMockEmailVerificationRepo() jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) return service.NewAuthService(userRepo, refreshTokenRepo, passwordResetRepo, emailVerifyRepo, jwtManager, - service.NewTwoFactorService(userRepo, jwtManager, nil)) + service.NewTwoFactorService(userRepo, jwtManager, testTOTPAEAD(t))) } func TestRegister(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -392,7 +393,7 @@ func TestRegisterPreferredLocale(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) user, _, err := authService.Register(ctx, service.RegisterInput{ Email: "test@example.com", Password: "password1234", @@ -410,7 +411,7 @@ func TestRegisterPreferredLocale(t *testing.T) { } func TestRegisterDuplicateEmail(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -433,7 +434,7 @@ func TestRegisterDuplicateEmail(t *testing.T) { } func TestMarkEmailVerified(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() user, _, err := authService.Register(ctx, service.RegisterInput{ @@ -463,7 +464,7 @@ func TestMarkEmailVerified(t *testing.T) { } func TestLogin(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -499,7 +500,7 @@ func TestLogin(t *testing.T) { } func TestLoginInvalidPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -549,7 +550,7 @@ func TestPasswordHashing(t *testing.T) { } func TestRefreshToken(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -579,7 +580,7 @@ func TestRefreshToken(t *testing.T) { } func TestRefreshTokenInvalid(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() _, err := authService.RefreshToken(ctx, "invalid-token") @@ -589,7 +590,7 @@ func TestRefreshTokenInvalid(t *testing.T) { } func TestRefreshTokenRevoked(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -619,7 +620,7 @@ func TestRefreshTokenRevoked(t *testing.T) { } func TestLogout(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -645,7 +646,7 @@ func TestLogout(t *testing.T) { } func TestRequestPasswordReset(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -679,7 +680,7 @@ func TestRequestPasswordReset(t *testing.T) { } func TestRequestPasswordResetNonExistentUser(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() reset, err := authService.RequestPasswordReset(ctx, "nonexistent@example.com") @@ -696,7 +697,7 @@ func TestRequestPasswordResetNonExistentUser(t *testing.T) { } func TestResetPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -731,7 +732,7 @@ func TestResetPassword(t *testing.T) { } func TestResetPasswordInvalidToken(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() _, err := authService.ResetPassword(ctx, "invalid-token", "newpassword", "") @@ -741,7 +742,7 @@ func TestResetPasswordInvalidToken(t *testing.T) { } func TestResetPasswordUsedToken(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() registerInput := service.RegisterInput{ @@ -777,7 +778,7 @@ func TestChangePassword(t *testing.T) { jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) authService := service.NewAuthService(userRepo, refreshTokenRepo, passwordResetRepo, newMockEmailVerificationRepo(), jwtManager, - service.NewTwoFactorService(userRepo, jwtManager, nil)) + service.NewTwoFactorService(userRepo, jwtManager, testTOTPAEAD(t))) ctx := context.Background() // Register a user @@ -826,7 +827,7 @@ func TestDeleteUser(t *testing.T) { jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) authService := service.NewAuthService(userRepo, refreshTokenRepo, passwordResetRepo, newMockEmailVerificationRepo(), jwtManager, - service.NewTwoFactorService(userRepo, jwtManager, nil)) + service.NewTwoFactorService(userRepo, jwtManager, testTOTPAEAD(t))) ctx := context.Background() // Register a user @@ -863,7 +864,7 @@ func TestDeleteUser(t *testing.T) { } func TestRegister_EmptyEmail(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -878,7 +879,7 @@ func TestRegister_EmptyEmail(t *testing.T) { } func TestRegister_EmptyPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -893,7 +894,7 @@ func TestRegister_EmptyPassword(t *testing.T) { } func TestRegister_EmptyName(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -908,7 +909,7 @@ func TestRegister_EmptyName(t *testing.T) { } func TestRegister_ShortPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -923,7 +924,7 @@ func TestRegister_ShortPassword(t *testing.T) { } func TestRegister_InvalidEmail(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -938,7 +939,7 @@ func TestRegister_InvalidEmail(t *testing.T) { } func TestLogin_NonexistentUser(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() loginInput := service.LoginInput{ @@ -952,7 +953,7 @@ func TestLogin_NonexistentUser(t *testing.T) { } func TestGetUserByID(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -972,7 +973,7 @@ func TestGetUserByID(t *testing.T) { } func TestGetUserByID_NotFound(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() _, err := authService.GetUserByID(ctx, uuid.New()) @@ -987,7 +988,7 @@ func TestUpdateUser(t *testing.T) { passwordResetRepo := newMockPasswordResetRepo() jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) authService := service.NewAuthService(userRepo, refreshTokenRepo, passwordResetRepo, newMockEmailVerificationRepo(), jwtManager, - service.NewTwoFactorService(userRepo, jwtManager, nil)) + service.NewTwoFactorService(userRepo, jwtManager, testTOTPAEAD(t))) ctx := context.Background() input := service.RegisterInput{ @@ -1010,7 +1011,7 @@ func TestUpdateUser(t *testing.T) { } func TestGenerateTokensForUser(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -1033,7 +1034,7 @@ func TestGenerateTokensForUser(t *testing.T) { } func TestRegister_LongPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() longPassword := make([]byte, 73) @@ -1052,7 +1053,7 @@ func TestRegister_LongPassword(t *testing.T) { } func TestRegister_EmailNormalization(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -1070,7 +1071,7 @@ func TestRegister_EmailNormalization(t *testing.T) { } func TestLogin_EmailNormalization(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -1095,7 +1096,7 @@ func TestLogin_EmailNormalization(t *testing.T) { } func TestResetPassword_ShortPassword(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() input := service.RegisterInput{ @@ -1117,7 +1118,7 @@ func TestResetPassword_ShortPassword(t *testing.T) { // login — otherwise a user who registered and never signed in again shows no // last-login date at all in the admin user list. func TestVerifyEmailRecordsLastLogin(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() user, verificationToken, err := authService.Register(ctx, service.RegisterInput{ @@ -1152,7 +1153,7 @@ func TestVerifyEmailRecordsLastLogin(t *testing.T) { } func TestLoginRecordsLastLogin(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() if _, _, err := authService.Register(ctx, service.RegisterInput{ @@ -1186,7 +1187,7 @@ func TestLoginRecordsLastLogin(t *testing.T) { // The password is only the first factor for a 2FA account: the handler answers // with a challenge rather than a session, so the login is not complete yet. func TestLoginWithTwoFactorDefersLastLogin(t *testing.T) { - authService := setupAuthService() + authService := setupAuthService(t) ctx := context.Background() user, _, err := authService.Register(ctx, service.RegisterInput{ diff --git a/internal/service/document_file.go b/internal/service/document_file.go index f67e239..29d3561 100644 --- a/internal/service/document_file.go +++ b/internal/service/document_file.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "image" _ "image/jpeg" // registers the JPEG decoder used by image.DecodeConfig _ "image/png" // registers the PNG decoder used by image.DecodeConfig @@ -14,6 +15,7 @@ import ( "github.com/fjaeckel/ninerlog-api/internal/models" "github.com/fjaeckel/ninerlog-api/internal/repository" + "github.com/fjaeckel/ninerlog-api/pkg/cryptoutil" "github.com/google/uuid" ) @@ -41,40 +43,63 @@ var ( ErrDocumentFileCorrupt = errors.New("image could not be decoded") ErrDocumentFileTooManyPixel = errors.New("image resolution is too large") ErrDocumentFileLimitReached = errors.New("maximum number of images for this document reached") + + // ErrDocumentFileUnreadable means the stored bytes did not decrypt: the + // server is running with a different key than the one that sealed them, or + // the row was tampered with. Deliberately distinct from "not found" — the + // file is there and it is the caller's, and telling them it does not exist + // would send a pilot looking for a scan they never lost. + ErrDocumentFileUnreadable = errors.New("stored file could not be decrypted") ) // DocumentFileService owns reference photos attached to licences and -// credentials: the feature switch, upload validation, ownership checks and the -// per-document cap. +// credentials: the feature switch, upload validation, ownership checks, the +// per-document cap, and encryption of the stored bytes. // // Every method resolves the subject through the licence/credential repository // first. That both proves ownership and means an image can never be addressed // except through the document it belongs to. +// +// Encryption sits here rather than in the repository so that everything above +// this layer — handlers, tests, background jobs — deals in the actual file. +// The repository stores whatever bytes it is handed and knows nothing about +// what they mean, which is the same division the rest of the codebase keeps. type DocumentFileService struct { imageRepo repository.DocumentFileRepository licenseRepo repository.LicenseRepository credentialRepo repository.CredentialRepository enabled bool + aead *cryptoutil.AEAD } +// NewDocumentFileService wires the subsystem. aead is the purpose-derived key +// for document files; passing nil leaves the feature off no matter what +// enabled says, because storing a pilot's identity documents in the clear is +// not a mode this service offers. func NewDocumentFileService( imageRepo repository.DocumentFileRepository, licenseRepo repository.LicenseRepository, credentialRepo repository.CredentialRepository, enabled bool, + aead *cryptoutil.AEAD, ) *DocumentFileService { return &DocumentFileService{ imageRepo: imageRepo, licenseRepo: licenseRepo, credentialRepo: credentialRepo, enabled: enabled, + aead: aead, } } // Enabled reports whether the feature is switched on. Handlers use it to // answer GET /features so a client can hide the UI instead of discovering the // 403 by uploading. -func (s *DocumentFileService) Enabled() bool { return s.enabled } +// +// A missing key counts as switched off. main refuses to start when the feature +// is explicitly enabled without one, so this is the belt to that braces: no +// arrangement of configuration reaches the storage path without a key. +func (s *DocumentFileService) Enabled() bool { return s.enabled && s.aead != nil } // UploadInput is one candidate image as it arrives from the handler. Data is // the raw file; ContentType is *derived* from it, never taken from the @@ -121,7 +146,7 @@ func (s *DocumentFileService) verifySubject(ctx context.Context, userID uuid.UUI // Upload validates and stores one image against a licence or credential. func (s *DocumentFileService) Upload(ctx context.Context, userID uuid.UUID, subject models.DocumentSubjectType, subjectID uuid.UUID, in UploadInput) (*models.DocumentFile, error) { - if !s.enabled { + if !s.Enabled() { return nil, ErrDocumentFilesDisabled } if err := s.verifySubject(ctx, userID, subject, subjectID); err != nil { @@ -135,13 +160,21 @@ func (s *DocumentFileService) Upload(ctx context.Context, userID uuid.UUID, subj // Width and height are nil for formats without intrinsic pixel dimensions // (PDF), and are passed through as such rather than stored as zeroes. + // + // ByteSize is the size of the file the pilot uploaded, not of what lands in + // the column: the client shows it, the cap is expressed in it, and the + // ciphertext's extra authentication tag is an implementation detail of + // storage that nothing above should have to subtract. + // + // The id is minted here rather than by the database, because the bytes are + // sealed against it and so it has to exist before they are. img := &models.DocumentFile{ + ID: uuid.New(), UserID: userID, ContentType: contentType, ByteSize: len(in.Data), Width: width, Height: height, - Data: in.Data, } if name := sanitizeFilename(in.Filename); name != "" { img.Filename = &name @@ -158,6 +191,13 @@ func (s *DocumentFileService) Upload(ctx context.Context, userID uuid.UUID, subj img.CredentialID = &id } + ciphertext, nonce, err := s.aead.EncryptWithAAD(in.Data, documentFileAAD(img)) + if err != nil { + return nil, fmt.Errorf("encrypt document file: %w", err) + } + img.Data = ciphertext + img.DataNonce = nonce + if err := s.imageRepo.Create(ctx, img, models.MaxDocumentFilesPerSubject); err != nil { if errors.Is(err, repository.ErrDocumentFileLimit) { return nil, ErrDocumentFileLimitReached @@ -168,12 +208,13 @@ func (s *DocumentFileService) Upload(ctx context.Context, userID uuid.UUID, subj // The stored payload is not part of the create response; drop it so a // caller cannot accidentally serialize megabytes back out. img.Data = nil + img.DataNonce = nil return img, nil } // List returns the metadata for a document's images, oldest first. func (s *DocumentFileService) List(ctx context.Context, userID uuid.UUID, subject models.DocumentSubjectType, subjectID uuid.UUID) ([]*models.DocumentFile, error) { - if !s.enabled { + if !s.Enabled() { return nil, ErrDocumentFilesDisabled } if err := s.verifySubject(ctx, userID, subject, subjectID); err != nil { @@ -185,7 +226,7 @@ func (s *DocumentFileService) List(ctx context.Context, userID uuid.UUID, subjec // Get returns a single image including its bytes, for the authenticated // download endpoint. func (s *DocumentFileService) Get(ctx context.Context, userID uuid.UUID, subject models.DocumentSubjectType, subjectID, imageID uuid.UUID) (*models.DocumentFile, error) { - if !s.enabled { + if !s.Enabled() { return nil, ErrDocumentFilesDisabled } if err := s.verifySubject(ctx, userID, subject, subjectID); err != nil { @@ -198,12 +239,54 @@ func (s *DocumentFileService) Get(ctx context.Context, userID uuid.UUID, subject } return nil, err } + if err := s.open(img); err != nil { + return nil, err + } return img, nil } +// open replaces a row's stored bytes with the file itself, in place. +// +// Every stored file is encrypted — the column holding the nonce is NOT NULL, so +// a row without one cannot exist — which means there is no "maybe it is +// plaintext" branch to get wrong. A missing nonce is a corrupt row, not an old +// one, and is treated as unreadable rather than served as if it were the file. +func (s *DocumentFileService) open(img *models.DocumentFile) error { + if s.aead == nil || len(img.DataNonce) == 0 { + return ErrDocumentFileUnreadable + } + plaintext, err := s.aead.DecryptWithAAD(img.Data, img.DataNonce, documentFileAAD(img)) + if err != nil { + return ErrDocumentFileUnreadable + } + img.Data = plaintext + img.DataNonce = nil + return nil +} + +// documentFileAAD is the context a file's ciphertext is bound to: its own id, +// its owner, and the content type the server will hand back to a browser. +// +// None of this is stored in the ciphertext — the decrypting side rebuilds it +// from the row — so it costs nothing and makes the stored blob non-portable. +// Moving a blob to another row, another user, or relabelling a PDF as a JPEG +// all break authentication and surface as ErrDocumentFileUnreadable instead of +// as a file served under someone else's name. +// +// The two UUIDs are fixed-width in their string form, so concatenating with a +// separator cannot be made ambiguous by a crafted content type. +func documentFileAAD(img *models.DocumentFile) []byte { + return []byte(strings.Join([]string{ + "ninerlog/document-file/v1", + img.ID.String(), + img.UserID.String(), + img.ContentType, + }, "|")) +} + // Delete removes one image from a document. func (s *DocumentFileService) Delete(ctx context.Context, userID uuid.UUID, subject models.DocumentSubjectType, subjectID, imageID uuid.UUID) error { - if !s.enabled { + if !s.Enabled() { return ErrDocumentFilesDisabled } if err := s.verifySubject(ctx, userID, subject, subjectID); err != nil { diff --git a/internal/service/document_file_test.go b/internal/service/document_file_test.go index b359b50..172567a 100644 --- a/internal/service/document_file_test.go +++ b/internal/service/document_file_test.go @@ -15,6 +15,7 @@ import ( "github.com/fjaeckel/ninerlog-api/internal/models" "github.com/fjaeckel/ninerlog-api/internal/repository" "github.com/fjaeckel/ninerlog-api/internal/service" + "github.com/fjaeckel/ninerlog-api/pkg/cryptoutil" "github.com/google/uuid" ) @@ -41,7 +42,12 @@ func (m *mockDocumentFileRepo) Create(ctx context.Context, img *models.DocumentF if count >= maxPerSubject { return repository.ErrDocumentFileLimit } - img.ID = uuid.New() + // The real repository only mints an id when the caller left it unset; the + // service supplies one so the payload can be sealed against it, and + // overwriting it here would break that binding. + if img.ID == uuid.Nil { + img.ID = uuid.New() + } img.CreatedAt = time.Now() img.UpdatedAt = time.Now() stored := *img @@ -66,7 +72,11 @@ func (m *mockDocumentFileRepo) GetWithData(ctx context.Context, userID uuid.UUID if !ok || !m.matches(img, userID, subject, subjectID) { return nil, repository.ErrNotFound } - return img, nil + // A copy, like a real query: the service decrypts into the struct it is + // handed, and handing out the stored one would let a single read replace + // the "database" contents with plaintext. + copied := *img + return &copied, nil } func (m *mockDocumentFileRepo) Delete(ctx context.Context, userID uuid.UUID, subject models.DocumentSubjectType, subjectID, imageID uuid.UUID) error { @@ -171,12 +181,30 @@ func pdfBytes() []byte { } type docImageFixture struct { - svc *service.DocumentFileService - imageRepo *mockDocumentFileRepo - userID uuid.UUID - otherUser uuid.UUID - licenseID uuid.UUID - credential uuid.UUID + svc *service.DocumentFileService + imageRepo *mockDocumentFileRepo + licenseRepo *docMockLicenseRepo + credentialRepo *mockCredentialRepo + userID uuid.UUID + otherUser uuid.UUID + licenseID uuid.UUID + credential uuid.UUID +} + +// newTestAEAD builds the cipher the service seals stored files with, derived +// the same way main does so the tests exercise the real key path rather than a +// raw key the production code never sees. +func newTestAEAD(t *testing.T) *cryptoutil.AEAD { + t.Helper() + master, err := cryptoutil.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + aead, err := cryptoutil.DeriveAEAD(master, cryptoutil.PurposeDocumentFile) + if err != nil { + t.Fatalf("derive key: %v", err) + } + return aead } func newDocImageFixture(t *testing.T, enabled bool) *docImageFixture { @@ -202,13 +230,34 @@ func newDocImageFixture(t *testing.T, enabled bool) *docImageFixture { } return &docImageFixture{ - svc: service.NewDocumentFileService(imageRepo, licenseRepo, credentialRepo, enabled), - imageRepo: imageRepo, - userID: userID, - otherUser: uuid.New(), - licenseID: license.ID, - credential: credential.ID, + svc: service.NewDocumentFileService(imageRepo, licenseRepo, credentialRepo, enabled, newTestAEAD(t)), + imageRepo: imageRepo, + licenseRepo: licenseRepo, + credentialRepo: credentialRepo, + userID: userID, + otherUser: uuid.New(), + licenseID: license.ID, + credential: credential.ID, + } +} + +// seedWithoutNonce writes a row whose bytes carry no nonce. The database will +// not accept one (data_nonce is NOT NULL), so this exists only to prove the +// read path refuses such a row rather than serving the bytes as a file. +func (f *docImageFixture) seedWithoutNonce(t *testing.T, data []byte) *models.DocumentFile { + t.Helper() + licenseID := f.licenseID + img := &models.DocumentFile{ + UserID: f.userID, + LicenseID: &licenseID, + ContentType: models.ContentTypePNG, + ByteSize: len(data), + Data: data, } + if err := f.imageRepo.Create(context.Background(), img, models.MaxDocumentFilesPerSubject); err != nil { + t.Fatalf("seed row: %v", err) + } + return img } func (f *docImageFixture) upload(t *testing.T, data []byte) (*models.DocumentFile, error) { @@ -609,3 +658,143 @@ func TestDocumentFile_SubjectMustExist(t *testing.T) { t.Errorf("err = %v, want ErrDocumentSubjectNotFound", err) } } + +// ── Encryption at rest ──────────────────────────────────────────────────── + +// The claim the whole feature rests on: what lands in the database is not the +// pilot's licence. Asserted against the stored row rather than through the +// service, because a round trip that decrypts would pass just as well if +// nothing were ever encrypted. +func TestDocumentFile_StoresCiphertextNotTheFile(t *testing.T) { + f := newDocImageFixture(t, true) + + plaintext := pngBytes(t, 40, 30) + img, err := f.upload(t, plaintext) + if err != nil { + t.Fatalf("upload: %v", err) + } + + stored := f.imageRepo.images[img.ID] + if stored.DataNonce == nil { + t.Fatal("stored row has no nonce — the bytes were not encrypted") + } + if bytes.Equal(stored.Data, plaintext) { + t.Fatal("stored bytes are the file itself") + } + if bytes.Contains(stored.Data, plaintext[:8]) { + t.Fatal("stored bytes still contain the file's header") + } + + // The reported size is the file's, not the ciphertext's — the client shows + // it and the 5 MB cap is expressed in it. + if img.ByteSize != len(plaintext) { + t.Errorf("ByteSize = %d, want %d (the plaintext length)", img.ByteSize, len(plaintext)) + } + + // And the create response carries neither the payload nor the nonce. + if img.Data != nil || img.DataNonce != nil { + t.Error("create response leaked stored bytes") + } +} + +func TestDocumentFile_GetReturnsTheOriginalBytes(t *testing.T) { + f := newDocImageFixture(t, true) + + plaintext := pngBytes(t, 40, 30) + img, err := f.upload(t, plaintext) + if err != nil { + t.Fatalf("upload: %v", err) + } + + got, err := f.svc.Get(context.Background(), f.userID, models.DocumentSubjectLicense, f.licenseID, img.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !bytes.Equal(got.Data, plaintext) { + t.Fatal("round trip did not return the uploaded bytes") + } + if got.DataNonce != nil { + t.Error("decrypted file still carries a nonce") + } +} + +// A server holding a different key must say so rather than serving whatever +// the decryption produced. +func TestDocumentFile_WrongKeyIsReportedNotServed(t *testing.T) { + f := newDocImageFixture(t, true) + + img, err := f.upload(t, pngBytes(t, 40, 30)) + if err != nil { + t.Fatalf("upload: %v", err) + } + + rekeyed := service.NewDocumentFileService(f.imageRepo, f.licenseRepo, f.credentialRepo, true, newTestAEAD(t)) + _, err = rekeyed.Get(context.Background(), f.userID, models.DocumentSubjectLicense, f.licenseID, img.ID) + if !errors.Is(err, service.ErrDocumentFileUnreadable) { + t.Fatalf("err = %v, want ErrDocumentFileUnreadable", err) + } +} + +// The binding: a stored blob belongs to its row. Moving one onto another row — +// the shape a database-level attacker would use to plant a file under someone +// else's licence — must not produce a readable file. +func TestDocumentFile_CiphertextIsBoundToItsRow(t *testing.T) { + f := newDocImageFixture(t, true) + ctx := context.Background() + + first, err := f.upload(t, pngBytes(t, 40, 30)) + if err != nil { + t.Fatalf("upload first: %v", err) + } + second, err := f.upload(t, pngBytes(t, 20, 10)) + if err != nil { + t.Fatalf("upload second: %v", err) + } + + // Overwrite the second row's payload with the first's, exactly as a + // straight column copy would. + f.imageRepo.images[second.ID].Data = f.imageRepo.images[first.ID].Data + f.imageRepo.images[second.ID].DataNonce = f.imageRepo.images[first.ID].DataNonce + + if _, err := f.svc.Get(ctx, f.userID, models.DocumentSubjectLicense, f.licenseID, second.ID); !errors.Is(err, service.ErrDocumentFileUnreadable) { + t.Fatalf("moved ciphertext: err = %v, want ErrDocumentFileUnreadable", err) + } + + // Relabelling the format is bound too: the content type is what the server + // hands a browser, so it must not be changeable underneath the bytes. + f.imageRepo.images[first.ID].ContentType = models.ContentTypePDF + if _, err := f.svc.Get(ctx, f.userID, models.DocumentSubjectLicense, f.licenseID, first.ID); !errors.Is(err, service.ErrDocumentFileUnreadable) { + t.Fatalf("relabelled content type: err = %v, want ErrDocumentFileUnreadable", err) + } +} + +// There is no "maybe this row is plaintext" branch. A row without a nonce is +// corrupt, not old, and must not be handed to a browser as though the stored +// bytes were the file. +func TestDocumentFile_ARowWithoutANonceIsRefused(t *testing.T) { + f := newDocImageFixture(t, true) + + plaintext := pngBytes(t, 24, 24) + orphan := f.seedWithoutNonce(t, plaintext) + + _, err := f.svc.Get(context.Background(), f.userID, models.DocumentSubjectLicense, f.licenseID, orphan.ID) + if !errors.Is(err, service.ErrDocumentFileUnreadable) { + t.Fatalf("err = %v, want ErrDocumentFileUnreadable", err) + } +} + +// Without a key there is no plaintext fallback: the feature is simply off, so +// no arrangement of configuration can store a licence scan in the clear. +func TestDocumentFile_NoKeyMeansDisabled(t *testing.T) { + f := newDocImageFixture(t, true) + keyless := service.NewDocumentFileService(f.imageRepo, f.licenseRepo, f.credentialRepo, true, nil) + + if keyless.Enabled() { + t.Error("Enabled() = true without an encryption key") + } + _, err := keyless.Upload(context.Background(), f.userID, models.DocumentSubjectLicense, f.licenseID, + service.UploadInput{Data: pngBytes(t, 8, 8)}) + if !errors.Is(err, service.ErrDocumentFilesDisabled) { + t.Errorf("upload: err = %v, want ErrDocumentFilesDisabled", err) + } +} diff --git a/internal/service/oidc_test.go b/internal/service/oidc_test.go index b557b2a..ebbe2c5 100644 --- a/internal/service/oidc_test.go +++ b/internal/service/oidc_test.go @@ -160,7 +160,7 @@ func newOIDCHarness(t *testing.T, mutate func(*service.OIDCConfig)) *oidcHarness jwtManager := jwt.NewManager("test-access-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) authService := service.NewAuthService( users, refreshes, newMockPasswordResetRepo(), newMockEmailVerificationRepo(), - jwtManager, service.NewTwoFactorService(users, jwtManager, nil), + jwtManager, service.NewTwoFactorService(users, jwtManager, testTOTPAEAD(t)), ) cfg := service.OIDCConfig{ diff --git a/internal/service/password_reset_2fa_test.go b/internal/service/password_reset_2fa_test.go index 832c30e..14e4490 100644 --- a/internal/service/password_reset_2fa_test.go +++ b/internal/service/password_reset_2fa_test.go @@ -63,7 +63,7 @@ func newResetFixture(t *testing.T) *resetFixture { userRepo := newMockUserRepo() jwtManager := jwt.NewManager("test-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) repo := ©ingUserRepo{mockUserRepo: userRepo} - twoFactor := service.NewTwoFactorService(repo, jwtManager, nil) + twoFactor := service.NewTwoFactorService(repo, jwtManager, testTOTPAEAD(t)) auth := service.NewAuthService(repo, newMockRefreshTokenRepo(), newMockPasswordResetRepo(), newMockEmailVerificationRepo(), jwtManager, twoFactor) diff --git a/internal/service/twofactor.go b/internal/service/twofactor.go index 3280941..009bd25 100644 --- a/internal/service/twofactor.go +++ b/internal/service/twofactor.go @@ -25,24 +25,34 @@ var ( ErrTwoFactorNotEnabled = errors.New("two-factor authentication is not enabled") ErrInvalidTOTPCode = errors.New("invalid TOTP code") ErrInvalid2FAToken = errors.New("invalid two-factor token") + + // ErrTwoFactorKeyMissing means the service was built without an encryption + // key. A running server cannot reach this — ENCRYPTION_KEY is required at + // startup — so it exists to make the failure explicit in tests and in any + // future wiring that forgets to pass one, instead of silently degrading to + // plaintext seeds. + ErrTwoFactorKeyMissing = errors.New("two-factor authentication is unavailable: no encryption key is configured") ) // encSecretPrefix marks a TOTP secret that is stored encrypted (AES-256-GCM). -// Secrets without this prefix are legacy plaintext and are read as-is, so an -// existing database keeps working after the encryption key is introduced. +// Every stored secret carries it: migration 61 cleared the enrolments that +// predate mandatory encryption, so an unprefixed value is a corrupt row rather +// than an old one. const encSecretPrefix = "enc:v1:" type TwoFactorService struct { userRepo repository.UserRepository jwtManager *jwt.Manager - // aead encrypts TOTP secrets at rest. When nil, secrets are stored as - // plaintext (legacy behavior) — the deployment should set an encryption key. + // aead encrypts TOTP secrets at rest, derived from ENCRYPTION_KEY. It is + // never nil in a running server — the key is required at startup — and a + // nil one fails enrolment and verification closed rather than falling back + // to storing seeds in the clear. aead *cryptoutil.AEAD } -// NewTwoFactorService constructs the service. aead may be nil, in which case -// TOTP secrets are stored unencrypted (backward-compatible). Provide an AEAD -// (see cryptoutil) to encrypt secrets at rest. +// NewTwoFactorService constructs the service. aead comes from +// cryptoutil.DeriveAEAD(masterKey, PurposeTOTPSecrets); without it, 2FA cannot +// be set up or verified. func NewTwoFactorService(userRepo repository.UserRepository, jwtManager *jwt.Manager, aead *cryptoutil.AEAD) *TwoFactorService { return &TwoFactorService{ userRepo: userRepo, @@ -52,10 +62,12 @@ func NewTwoFactorService(userRepo repository.UserRepository, jwtManager *jwt.Man } // encodeSecret returns the value to persist for a TOTP secret: an encrypted, -// prefixed blob when an AEAD is configured, otherwise the plaintext (legacy). +// prefixed blob. There is no unencrypted form — a seed is a bearer credential +// for someone's second factor, and storing one in the clear because a key was +// missing was never a mode worth having. func (s *TwoFactorService) encodeSecret(plaintext string) (string, error) { if s.aead == nil { - return plaintext, nil + return "", ErrTwoFactorKeyMissing } ciphertext, nonce, err := s.aead.Encrypt([]byte(plaintext)) if err != nil { @@ -65,14 +77,19 @@ func (s *TwoFactorService) encodeSecret(plaintext string) (string, error) { return encSecretPrefix + base64.StdEncoding.EncodeToString(blob), nil } -// decodeSecret returns the plaintext TOTP secret from a stored value. Values -// without encSecretPrefix are treated as legacy plaintext. +// decodeSecret returns the plaintext TOTP secret from a stored value. +// +// An unprefixed value is refused rather than read as a legacy plaintext seed. +// Nothing writes one any more and migration 61 cleared the ones that existed, +// so a value arriving here without the marker is a corrupt or hand-edited row — +// and accepting it would mean an attacker who can write to the column could +// choose a victim's TOTP seed by storing it unencrypted. func (s *TwoFactorService) decodeSecret(stored string) (string, error) { - if !strings.HasPrefix(stored, encSecretPrefix) { - return stored, nil // legacy plaintext secret - } if s.aead == nil { - return "", errors.New("2FA secret is encrypted but no encryption key is configured") + return "", ErrTwoFactorKeyMissing + } + if !strings.HasPrefix(stored, encSecretPrefix) { + return "", errors.New("2FA secret is not in the encrypted format") } raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encSecretPrefix)) if err != nil { diff --git a/internal/service/twofactor_encryption_test.go b/internal/service/twofactor_encryption_test.go index 5a4f688..cf9f1d1 100644 --- a/internal/service/twofactor_encryption_test.go +++ b/internal/service/twofactor_encryption_test.go @@ -2,6 +2,7 @@ package service_test import ( "context" + "errors" "strings" "testing" "time" @@ -17,15 +18,23 @@ func setup2FAServiceEncrypted(t *testing.T) (*service.TwoFactorService, *mock2FA t.Helper() repo := newMock2FAUserRepo() jwtMgr := jwt.NewManager("test-access-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) - key, err := cryptoutil.GenerateKey() + return service.NewTwoFactorService(repo, jwtMgr, testTOTPAEAD(t)), repo +} + +// testTOTPAEAD builds the 2FA cipher the way main does — a subkey derived from +// a master key — so the tests exercise the real key path rather than a raw key +// the production code never constructs. +func testTOTPAEAD(t *testing.T) *cryptoutil.AEAD { + t.Helper() + master, err := cryptoutil.GenerateKey() if err != nil { t.Fatalf("generate key: %v", err) } - aead, err := cryptoutil.New(key) + aead, err := cryptoutil.DeriveAEAD(master, cryptoutil.PurposeTOTPSecrets) if err != nil { - t.Fatalf("new aead: %v", err) + t.Fatalf("derive key: %v", err) } - return service.NewTwoFactorService(repo, jwtMgr, aead), repo + return aead } // The stored TOTP secret must be ciphertext, not the plaintext base32 secret. @@ -82,9 +91,14 @@ func TestEncrypted2FA_VerifyAndValidateRoundTrip(t *testing.T) { } } -// Backward compatibility: an encryption-enabled service must still validate a -// legacy plaintext secret written before encryption was introduced. -func TestEncrypted2FA_ReadsLegacyPlaintextSecret(t *testing.T) { +// An unprefixed secret is refused, not read as a legacy plaintext seed. +// +// Migration 61 cleared every enrolment that predates mandatory encryption, so +// nothing legitimate writes one. Accepting it would mean anyone who can write to +// the column — a restored dump, a stray admin query, SQL injection — could +// choose a victim's TOTP seed simply by storing it unencrypted, which is a far +// worse outcome than a failed login. +func TestEncrypted2FA_RefusesAnUnprefixedSecret(t *testing.T) { svc, repo := setup2FAServiceEncrypted(t) ctx := context.Background() user := createTestUserFor2FA(repo) @@ -95,19 +109,66 @@ func TestEncrypted2FA_ReadsLegacyPlaintextSecret(t *testing.T) { } plain := key.Secret() user.TwoFactorEnabled = true - user.TwoFactorSecret = &plain // stored WITHOUT the enc: prefix (legacy) + user.TwoFactorSecret = &plain // stored WITHOUT the enc: prefix repo.users[user.ID] = user code, _ := totp.GenerateCode(plain, time.Now()) valid, err := svc.ValidateTOTP(ctx, user.ID, code) - if err != nil { - t.Fatalf("ValidateTOTP legacy plaintext: %v", err) + if err == nil { + t.Fatal("an unencrypted secret was accepted") } - if !valid { - t.Error("legacy plaintext secret should still validate") + if valid { + t.Error("ValidateTOTP reported success for an unencrypted secret") + } +} + +// Without a key, 2FA is unavailable rather than unencrypted. A running server +// cannot reach this — ENCRYPTION_KEY is required at startup — but the service +// must not have a mode that writes seeds in the clear. +func TestTwoFactor_WithoutAKeyRefusesRatherThanStoringPlaintext(t *testing.T) { + repo := newMock2FAUserRepo() + jwtMgr := jwt.NewManager("test-access-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) + svc := service.NewTwoFactorService(repo, jwtMgr, nil) + user := createTestUserFor2FA(repo) + + if _, _, err := svc.SetupTOTP(context.Background(), user.ID); !errors.Is(err, service.ErrTwoFactorKeyMissing) { + t.Fatalf("SetupTOTP: err = %v, want ErrTwoFactorKeyMissing", err) + } + if repo.users[user.ID].TwoFactorSecret != nil { + t.Error("a secret was stored despite the failure") } } func (m *mock2FAUserRepo) ConsumeRecoveryCode(_ context.Context, _ uuid.UUID, _ string) (bool, error) { return true, nil } + +// The stored form is much longer than the seed it protects, and the column has +// to be able to hold it. VARCHAR(64) — sized for a 32-character plaintext seed +// — rejected the 87-character encrypted value outright, and nothing above the +// database noticed until an enrolment hit real Postgres, because the repository +// used here is a map with no column widths. +// +// This pins the size so a change to the stored format shows up as a failing +// test with a pointer to the schema, rather than as a 500 on enrolment. +func TestEncodedSecretFitsTheColumn(t *testing.T) { + svc, repo := setup2FAServiceEncrypted(t) + user := createTestUserFor2FA(repo) + + if _, _, err := svc.SetupTOTP(context.Background(), user.ID); err != nil { + t.Fatalf("SetupTOTP: %v", err) + } + stored := repo.users[user.ID].TwoFactorSecret + if stored == nil { + t.Fatal("no secret stored") + } + + // users.two_factor_secret is TEXT (migration 62). If this grows past what a + // sized column could hold, the migration is the thing to revisit. + const observed = 87 + if len(*stored) != observed { + t.Errorf("stored secret is %d chars, expected %d — the stored format changed, "+ + "check that users.two_factor_secret can still hold it (db/migrations/000062)", + len(*stored), observed) + } +} diff --git a/internal/service/twofactor_lockout_test.go b/internal/service/twofactor_lockout_test.go index 1cfa5ad..50b41fa 100644 --- a/internal/service/twofactor_lockout_test.go +++ b/internal/service/twofactor_lockout_test.go @@ -13,7 +13,7 @@ import ( // After enough wrong codes the account locks, and subsequent attempts are // rejected with ErrAccountLocked even when the code is correct. func TestValidateTOTP_LocksAfterRepeatedFailures(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) ctx := context.Background() user := createTestUserFor2FA(repo) @@ -57,7 +57,7 @@ func TestValidateTOTP_LocksAfterRepeatedFailures(t *testing.T) { // A successful validation before the threshold clears the failure counter, so // intermittent typos don't accumulate into a lockout. func TestValidateTOTP_SuccessResetsFailureCounter(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) ctx := context.Background() user := createTestUserFor2FA(repo) diff --git a/internal/service/twofactor_test.go b/internal/service/twofactor_test.go index c9092ca..d82cb01 100644 --- a/internal/service/twofactor_test.go +++ b/internal/service/twofactor_test.go @@ -2,6 +2,7 @@ package service_test import ( "context" + "strings" "testing" "time" @@ -96,10 +97,11 @@ func (m *mock2FAUserRepo) UpdateLastLogin(ctx context.Context, id uuid.UUID, at return nil } -func setup2FAService() (*service.TwoFactorService, *mock2FAUserRepo) { +func setup2FAService(t *testing.T) (*service.TwoFactorService, *mock2FAUserRepo) { + t.Helper() repo := newMock2FAUserRepo() jwtMgr := jwt.NewManager("test-access-secret", "test-refresh-secret", 15*time.Minute, 7*24*time.Hour) - svc := service.NewTwoFactorService(repo, jwtMgr, nil) + svc := service.NewTwoFactorService(repo, jwtMgr, testTOTPAEAD(t)) return svc, repo } @@ -118,7 +120,7 @@ func createTestUserFor2FA(repo *mock2FAUserRepo) *models.User { } func TestSetupTOTP(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -138,13 +140,18 @@ func TestSetupTOTP(t *testing.T) { if updated.TwoFactorSecret == nil { t.Error("TwoFactorSecret should be set after setup") } - if *updated.TwoFactorSecret != secret { - t.Errorf("Stored secret = %s, want %s", *updated.TwoFactorSecret, secret) + // The secret handed to the user is the base32 seed; what is persisted is + // its encrypted form. See twofactor_encryption_test.go for the round trip. + if *updated.TwoFactorSecret == secret { + t.Error("the base32 seed was stored verbatim instead of encrypted") + } + if !strings.HasPrefix(*updated.TwoFactorSecret, "enc:v1:") { + t.Errorf("stored secret is not in the encrypted format: %q", *updated.TwoFactorSecret) } } func TestSetupTOTP_AlreadyEnabled(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) user.TwoFactorEnabled = true ctx := context.Background() @@ -156,7 +163,7 @@ func TestSetupTOTP_AlreadyEnabled(t *testing.T) { } func TestSetupTOTP_UserNotFound(t *testing.T) { - svc, _ := setup2FAService() + svc, _ := setup2FAService(t) ctx := context.Background() _, _, err := svc.SetupTOTP(ctx, uuid.New()) @@ -166,7 +173,7 @@ func TestSetupTOTP_UserNotFound(t *testing.T) { } func TestVerifyAndEnable(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -200,7 +207,7 @@ func TestVerifyAndEnable(t *testing.T) { } func TestVerifyAndEnable_InvalidCode(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -213,7 +220,7 @@ func TestVerifyAndEnable_InvalidCode(t *testing.T) { } func TestVerifyAndEnable_AlreadyEnabled(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) user.TwoFactorEnabled = true ctx := context.Background() @@ -225,7 +232,7 @@ func TestVerifyAndEnable_AlreadyEnabled(t *testing.T) { } func TestDisable2FA(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -250,7 +257,7 @@ func TestDisable2FA(t *testing.T) { } func TestDisable2FA_WrongPassword(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -265,7 +272,7 @@ func TestDisable2FA_WrongPassword(t *testing.T) { } func TestDisable2FA_NotEnabled(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -277,7 +284,7 @@ func TestDisable2FA_NotEnabled(t *testing.T) { } func TestValidateTOTP(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -297,7 +304,7 @@ func TestValidateTOTP(t *testing.T) { } func TestValidateTOTP_InvalidCode(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -315,7 +322,7 @@ func TestValidateTOTP_InvalidCode(t *testing.T) { } func TestValidateTOTP_NotEnabled(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -327,7 +334,7 @@ func TestValidateTOTP_NotEnabled(t *testing.T) { } func TestIsEnabled(t *testing.T) { - svc, repo := setup2FAService() + svc, repo := setup2FAService(t) user := createTestUserFor2FA(repo) ctx := context.Background() @@ -355,7 +362,7 @@ func TestIsEnabled(t *testing.T) { } func TestIsEnabled_UserNotFound(t *testing.T) { - svc, _ := setup2FAService() + svc, _ := setup2FAService(t) ctx := context.Background() _, err := svc.IsEnabled(ctx, uuid.New()) diff --git a/pkg/cryptoutil/aead.go b/pkg/cryptoutil/aead.go index 4e7d66f..ed8dfe9 100644 --- a/pkg/cryptoutil/aead.go +++ b/pkg/cryptoutil/aead.go @@ -1,5 +1,6 @@ // Package cryptoutil provides symmetric encryption primitives used to protect -// per-user secrets (currently: cloud backup credentials) at rest. +// stored data at rest: cloud backup credentials, TOTP secrets, and the +// licence/credential files pilots upload. // // Design goals: // - One key, one algorithm — AES-256-GCM with a 96-bit random nonce. @@ -10,6 +11,8 @@ // variable at startup; KeyFromBase64 enforces a 32-byte key length and // refuses obviously-empty values to fail closed on a misconfigured // deployment. +// - Domain separation: one operator secret can stand behind several +// independent uses without any two sharing key bytes — see DeriveKey. package cryptoutil import ( @@ -115,20 +118,44 @@ func GenerateKeyBase64() (string, error) { // nonce, error). The 16-byte authentication tag is appended to the ciphertext // by the underlying GCM implementation. func (a *AEAD) Encrypt(plaintext []byte) (ciphertext, nonce []byte, err error) { + return a.EncryptWithAAD(plaintext, nil) +} + +// Decrypt authenticates and decrypts ciphertext produced by Encrypt. +func (a *AEAD) Decrypt(ciphertext, nonce []byte) ([]byte, error) { + return a.DecryptWithAAD(ciphertext, nonce, nil) +} + +// EncryptWithAAD seals plaintext and additionally authenticates aad, which is +// covered by the authentication tag but is NOT stored in the ciphertext — the +// decrypting side has to reproduce it byte for byte or the open fails. +// +// That is the useful property when ciphertext lives in a database column: bind +// each value to the row it belongs to (its id, its owner) and the ciphertext +// stops being portable. Someone who can write to the table can still destroy a +// value, but copying one row's blob over another's no longer yields something +// that decrypts, so a stored file cannot be silently moved between records or +// between users. +// +// aad may be nil, which is ordinary AEAD with no bound context. +func (a *AEAD) EncryptWithAAD(plaintext, aad []byte) (ciphertext, nonce []byte, err error) { nonce = make([]byte, NonceSize) if _, err = io.ReadFull(rand.Reader, nonce); err != nil { return nil, nil, err } - ciphertext = a.gcm.Seal(nil, nonce, plaintext, nil) + ciphertext = a.gcm.Seal(nil, nonce, plaintext, aad) return ciphertext, nonce, nil } -// Decrypt authenticates and decrypts ciphertext produced by Encrypt. -func (a *AEAD) Decrypt(ciphertext, nonce []byte) ([]byte, error) { +// DecryptWithAAD authenticates and decrypts a value produced by +// EncryptWithAAD. aad must be exactly what was supplied at seal time; +// otherwise this fails with ErrInvalidCiphertext, indistinguishably from a +// corrupted or tampered ciphertext. +func (a *AEAD) DecryptWithAAD(ciphertext, nonce, aad []byte) ([]byte, error) { if len(nonce) != NonceSize { return nil, ErrInvalidCiphertext } - plaintext, err := a.gcm.Open(nil, nonce, ciphertext, nil) + plaintext, err := a.gcm.Open(nil, nonce, ciphertext, aad) if err != nil { return nil, ErrInvalidCiphertext } diff --git a/pkg/cryptoutil/aead_test.go b/pkg/cryptoutil/aead_test.go index 9450e4a..3ab8ad1 100644 --- a/pkg/cryptoutil/aead_test.go +++ b/pkg/cryptoutil/aead_test.go @@ -164,3 +164,62 @@ func TestGenerateKeyBase64IsDecodable(t *testing.T) { t.Fatalf("could not consume our own generated key: %v", err) } } + +func TestEncryptWithAADRoundTrip(t *testing.T) { + a := mustNewAEAD(t) + aad := []byte("row-id|user-id|image/jpeg") + + ciphertext, nonce, err := a.EncryptWithAAD([]byte("scan bytes"), aad) + if err != nil { + t.Fatalf("EncryptWithAAD: %v", err) + } + + plaintext, err := a.DecryptWithAAD(ciphertext, nonce, aad) + if err != nil { + t.Fatalf("DecryptWithAAD: %v", err) + } + if string(plaintext) != "scan bytes" { + t.Fatalf("round trip = %q", plaintext) + } +} + +// The binding property: a ciphertext sealed against one row's context must not +// open against another's, so a stored blob cannot be moved between rows or +// between users by anyone with write access to the table. +func TestDecryptWithAADRejectsDifferentContext(t *testing.T) { + a := mustNewAEAD(t) + + ciphertext, nonce, err := a.EncryptWithAAD([]byte("scan bytes"), []byte("row-a|user-1")) + if err != nil { + t.Fatalf("EncryptWithAAD: %v", err) + } + + for name, aad := range map[string][]byte{ + "different row": []byte("row-b|user-1"), + "different user": []byte("row-a|user-2"), + "absent": nil, + } { + if _, err := a.DecryptWithAAD(ciphertext, nonce, aad); err != ErrInvalidCiphertext { + t.Errorf("%s: err = %v, want ErrInvalidCiphertext", name, err) + } + } +} + +// Encrypt and Decrypt are the nil-AAD case of the same primitive, so values +// written before AAD existed still round-trip through the new methods. +func TestEncryptAndDecryptWithAADAgreeOnNilAAD(t *testing.T) { + a := mustNewAEAD(t) + + ciphertext, nonce, err := a.Encrypt([]byte("legacy value")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + plaintext, err := a.DecryptWithAAD(ciphertext, nonce, nil) + if err != nil { + t.Fatalf("DecryptWithAAD: %v", err) + } + if string(plaintext) != "legacy value" { + t.Fatalf("round trip = %q", plaintext) + } +} diff --git a/pkg/cryptoutil/derive.go b/pkg/cryptoutil/derive.go new file mode 100644 index 0000000..6d62acc --- /dev/null +++ b/pkg/cryptoutil/derive.go @@ -0,0 +1,62 @@ +package cryptoutil + +import ( + "crypto/hkdf" + "crypto/sha256" + "fmt" +) + +// Purposes for DeriveKey. Each names one place encrypted data is stored, and +// each string is baked into stored ciphertext forever: changing one makes every +// value sealed under the old label unreadable. Add new purposes; never edit an +// existing one. The trailing version lets a purpose be re-keyed deliberately — +// bump it and the old data becomes undecryptable, which is a migration, not a +// rename. +// These are labels, not key material. They are public by design — the same +// three strings ship in every build — and knowing one buys nothing without the +// master key it is combined with. gosec's G101 flags them anyway, on the name +// alone. +const ( + PurposeTOTPSecrets = "ninerlog/totp-secrets/v1" // #nosec G101 -- an HKDF label naming what a subkey is for, not a secret + PurposeBackupCredentials = "ninerlog/backup-credentials/v1" // #nosec G101 -- as above + PurposeDocumentFile = "ninerlog/document-files/v1" +) + +// DeriveKey returns a 32-byte subkey for one purpose, derived from a master key +// with HKDF-SHA256. +// +// The point is domain separation. One operator-facing secret (ENCRYPTION_KEY) +// stands behind several independent uses, but no two uses ever hold the same +// key bytes: recovering the subkey that protects licence scans tells an +// attacker nothing about the one protecting 2FA secrets, and neither reveals +// the master. That matters more here than it would with a single-purpose key, +// because these subkeys have very different lifetimes and blast radii. +// +// No salt is used. HKDF's salt strengthens extraction from a *low-entropy* +// secret; the master is 32 bytes of enforced-length key material, and a random +// salt would have to be stored alongside every ciphertext to be reproducible. +// The purpose string is passed as HKDF's info parameter, which is exactly what +// it is for. +func DeriveKey(master []byte, purpose string) ([]byte, error) { + if len(master) != KeySize { + return nil, ErrInvalidKey + } + if purpose == "" { + return nil, fmt.Errorf("cryptoutil: derive purpose must not be empty") + } + key, err := hkdf.Key(sha256.New, master, nil, purpose, KeySize) + if err != nil { + return nil, fmt.Errorf("cryptoutil: hkdf: %w", err) + } + return key, nil +} + +// DeriveAEAD is DeriveKey followed by New — the usual way a caller obtains the +// cipher for its own purpose without ever handling the master key's bytes. +func DeriveAEAD(master []byte, purpose string) (*AEAD, error) { + key, err := DeriveKey(master, purpose) + if err != nil { + return nil, err + } + return New(key) +} diff --git a/pkg/cryptoutil/derive_test.go b/pkg/cryptoutil/derive_test.go new file mode 100644 index 0000000..25e05de --- /dev/null +++ b/pkg/cryptoutil/derive_test.go @@ -0,0 +1,120 @@ +package cryptoutil + +import ( + "bytes" + "testing" +) + +func testMaster(t *testing.T) []byte { + t.Helper() + key, err := GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + return key +} + +func TestDeriveKeyIsDeterministic(t *testing.T) { + master := testMaster(t) + + first, err := DeriveKey(master, PurposeDocumentFile) + if err != nil { + t.Fatalf("DeriveKey: %v", err) + } + second, err := DeriveKey(master, PurposeDocumentFile) + if err != nil { + t.Fatalf("DeriveKey: %v", err) + } + + // A restart must produce the same subkey, or every stored value becomes + // unreadable the moment the process is restarted. + if !bytes.Equal(first, second) { + t.Fatal("same master and purpose produced different subkeys") + } + if len(first) != KeySize { + t.Fatalf("subkey length = %d, want %d", len(first), KeySize) + } +} + +func TestDeriveKeySeparatesPurposes(t *testing.T) { + master := testMaster(t) + + // Every purpose the server uses, checked pairwise: one key in the + // environment stands behind all of them, and the whole point is that none + // of them is the same key as another, or as the master. + purposes := []string{PurposeTOTPSecrets, PurposeBackupCredentials, PurposeDocumentFile} + seen := make(map[string]string, len(purposes)) + for _, purpose := range purposes { + key, err := DeriveKey(master, purpose) + if err != nil { + t.Fatalf("DeriveKey(%s): %v", purpose, err) + } + if bytes.Equal(key, master) { + t.Fatalf("%s: subkey equals the master key", purpose) + } + if other, clash := seen[string(key)]; clash { + t.Fatalf("%s and %s derived the same key — domain separation is not happening", purpose, other) + } + seen[string(key)] = purpose + } +} + +func TestDeriveKeyDiffersPerMaster(t *testing.T) { + a, err := DeriveKey(testMaster(t), PurposeDocumentFile) + if err != nil { + t.Fatalf("DeriveKey: %v", err) + } + b, err := DeriveKey(testMaster(t), PurposeDocumentFile) + if err != nil { + t.Fatalf("DeriveKey: %v", err) + } + if bytes.Equal(a, b) { + t.Fatal("different masters derived the same subkey") + } +} + +func TestDeriveKeyRejectsBadInput(t *testing.T) { + master := testMaster(t) + + if _, err := DeriveKey(master[:16], PurposeDocumentFile); err != ErrInvalidKey { + t.Fatalf("short master: err = %v, want ErrInvalidKey", err) + } + if _, err := DeriveKey(nil, PurposeDocumentFile); err != ErrInvalidKey { + t.Fatalf("nil master: err = %v, want ErrInvalidKey", err) + } + if _, err := DeriveKey(master, ""); err == nil { + t.Fatal("empty purpose was accepted") + } +} + +// A value sealed under one purpose must not open under another. This is the +// property the whole derivation scheme exists to provide: leaking the subkey +// that protects licence scans must not also unlock 2FA secrets. +func TestDerivedAEADsCannotOpenEachOthersData(t *testing.T) { + master := testMaster(t) + + files, err := DeriveAEAD(master, PurposeDocumentFile) + if err != nil { + t.Fatalf("DeriveAEAD: %v", err) + } + totp, err := DeriveAEAD(master, PurposeTOTPSecrets) + if err != nil { + t.Fatalf("DeriveAEAD: %v", err) + } + + ciphertext, nonce, err := files.Encrypt([]byte("a licence scan")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, err := totp.Decrypt(ciphertext, nonce); err != ErrInvalidCiphertext { + t.Fatalf("cross-purpose decrypt: err = %v, want ErrInvalidCiphertext", err) + } + + plaintext, err := files.Decrypt(ciphertext, nonce) + if err != nil { + t.Fatalf("same-purpose decrypt: %v", err) + } + if string(plaintext) != "a licence scan" { + t.Fatalf("round trip = %q", plaintext) + } +} diff --git a/scripts/verify-multi-replica-webauthn.sh b/scripts/verify-multi-replica-webauthn.sh index 832db42..dc5e605 100755 --- a/scripts/verify-multi-replica-webauthn.sh +++ b/scripts/verify-multi-replica-webauthn.sh @@ -71,6 +71,9 @@ psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -q \ export DATABASE_URL="postgresql://$PGUSER@$PGHOST:$PGPORT/$PGDATABASE?sslmode=disable" export JWT_SECRET="multi-replica-verification-jwt-secret-not-for-production-01" export REFRESH_SECRET="multi-replica-verification-refresh-secret-not-for-prod-02" +# Required at startup. Both replicas must share it, or they would derive +# different subkeys and disagree about every encrypted value they read. +export ENCRYPTION_KEY="bXVsdGktcmVwbGljYS12ZXJpZnkta2V5LTMyYnl0ZXM=" export WEBAUTHN_RP_ID="localhost" export WEBAUTHN_RP_ORIGINS="http://localhost:5173" export MIGRATIONS_PATH="${MIGRATIONS_PATH:-db/migrations}"