Skip to main content
Version: 0.4.0

Encryption Audit

The Reference Implementation encrypts two kinds of data at rest under DATA_ENCRYPTION_KEY: service instance configurations and credential decryption keys. Startup validation decrypts a single sampled envelope, so a mixed-key database, or rows corrupted after that check, can pass boot and only surface when a request happens to touch an affected row. The audit:encryption command answers the question exhaustively: it attempts to decrypt every stored envelope under the active key and reports the result per store, without writing anything.

Run it:

  • before rotating DATA_ENCRYPTION_KEY, to confirm the current key decrypts everything the rotation will re-encrypt (see Encryption Key Rotation);
  • after restoring a database backup, to confirm the restored data and the configured key still match (use the stopped-writers form below, as the recovery procedure directs);
  • during incident triage, when decryption errors suggest a key or data problem and you need the full extent rather than the one row a request tripped over.

Running the audit​

From a source checkout, in packages/reference-implementation:

pnpm audit:encryption

The published Docker image carries no package manifest for the Reference Implementation, so inside the image run the script directly against the running application (live triage):

docker compose exec -w /app ri node_modules/.bin/tsx scripts/audit-encryption.ts

Running with the application stopped​

Before a rotation, after a restore, and in any state where writers must stay stopped, the audit runs as a one-off container instead. The key under test is whatever DATA_ENCRYPTION_KEY holds in .env, exactly as for the serving application, so the command needs no key arguments. The SKIP_ variables stop the image's entrypoint running migrations, backfills, and the seed first, with SKIP_MIGRATIONS=true covering the backfills as well, because they run inside the same guard. The entrypoint otherwise writes to the database before the audit has gated anything, and against a database the configured key cannot yet read the seed's own key validation fails outright. The empty SERVICE_ENCRYPTION_KEY override neutralises a stale deprecated alias the compose file would otherwise forward (the audit refuses to run while the two names disagree):

docker compose run --rm \
-e SKIP_MIGRATIONS=true -e SKIP_SEED=true \
-e SERVICE_ENCRYPTION_KEY= \
ri node_modules/.bin/tsx scripts/audit-encryption.ts

To audit under a key other than the one in .env (verifying a historical backup's key, for example), forward it for this one run by name-only -e DATA_ENCRYPTION_KEY with the value loaded from your secret store by command substitution (DATA_ENCRYPTION_KEY="$(...)" docker compose run ... -e DATA_ENCRYPTION_KEY ...); never type the literal key into the command, which records it in shell history.

On a source checkout, pnpm audit:encryption needs no entrypoint handling, but a stale SERVICE_ENCRYPTION_KEY must likewise be unset or overridden.

The command needs DATA_ENCRYPTION_KEY and a database target: a pre-set RI_DATABASE_URL is honoured as given, and the RI_POSTGRES_* variables are used to construct one only when it is absent (the same rule the application and the backfill follow).

Reading the report​

For each store the audit prints how many values decrypted cleanly, then the ids of any rows that failed to decrypt (a valid envelope that will not open, meaning a key mismatch or corruption of the envelope's ciphertext, IV, or authentication tag) and of any rows holding corrupted values (data that is not a valid envelope at all). Service instance configurations are always written encrypted, so any non-envelope value there is corruption. Credential keys can legitimately predate encryption at rest, so only values that look like a damaged envelope are flagged; other plain values are counted as legacy plaintext.

The audit doubles as the dry run for the decryption-key backfill: it reports how many legacy plaintext keys a backfill run would wrap, notes any corrupted-looking credential rows a backfill would skip untouched, and states when a backfill would refuse to run without --force because no stored envelope proves the key. When the audit finds decrypt failures or corrupted service instance configurations, a backfill run aborts before writing anything, and --force does not change that; resolve those rows first.

When the database holds nothing encrypted (a fresh deployment, or only legacy plaintext keys), the audit says so explicitly: a clean result in that state means nothing failed, not that the key was proven able to decrypt anything.

Exit codes​

  • 0: the audit completed and every stored envelope decrypted cleanly (including the nothing-to-verify case, which is stated in the output).
  • 1: the audit found decrypt failures or corrupted rows, or it could not complete (for example, the database was unreachable). The output distinguishes the two.

Concurrent writes​

The audit reads in id order with cursor pagination and no transaction, so it is a point-in-time report: a row changed after it was scanned, or inserted behind the scan position, is not re-examined. When the result gates a rotation or a restore, stop application writers first and keep them stopped until the follow-up action is done. A run against a live system is fine for triage; re-run it quiesced before acting on the result.