SEC-22: PII Encryption at Rest — What Changed and Why It Matters
SEC-22: PII Encryption at Rest — What Changed and Why It Matters
Release: v0.1.133 · Category: Data Protection · Severity: High
Background
The platform's sanctions screening workflow necessarily handles sensitive personal data — full names, dates of birth, and nationalities — as part of its OFSI list matching process. UK GDPR and broader data-protection best practice require that this personally identifiable information (PII) is encrypted when stored.
An AES-256-GCM encryption module (src/lib/encryption.ts) was already present in the codebase, but it was not connected to the database write or read paths. This meant that despite the encryption capability existing, all three PII fields were being persisted to and retrieved from the database in plaintext.
SEC-22 closes this gap.
What Was the Problem?
In both the internal and public-facing People API routes, database operations looked like this:
// BEFORE — plaintext write (vulnerable)
await db.insert(people).values({
fullName, // stored in plaintext
dateOfBirth, // stored in plaintext
nationality, // stored in plaintext
});
The encryptPiiFields() helper existed in src/lib/encryption.ts but was never called. Any party with read access to the database (a leaked backup, a misconfigured query tool, an internal threat actor) could read PII in full.
What Changed in v0.1.133?
1. Encryption on write
encryptPiiFields() is now called before every insert or update that touches a people record:
// AFTER — encrypted write
const encrypted = encryptPiiFields(data, [
'fullName',
'dateOfBirth',
'nationality',
]);
await db.insert(people).values(encrypted);
The module uses AES-256-GCM, which provides both confidentiality and integrity guarantees. Each field is encrypted individually with a random initialisation vector, so identical plaintext values produce different ciphertexts.
2. Decryption on read
decryptPiiFields() is now called after every db.select() that returns people records. Callers continue to receive plaintext values and require no changes:
const rows = await db.select().from(people).where(...);
return decryptPiiFields(rows, ['fullName', 'dateOfBirth', 'nationality']);
3. Centralised data-access layer
A dedicated repository layer now owns all PII reads and writes. Individual API routes delegate to this layer rather than calling the ORM directly. This architectural change means:
- No future route can accidentally bypass encryption.
- Encryption policy changes (key rotation, additional fields) are made in one place.
- Unit tests can verify encryption behaviour independently of HTTP handling.
4. ENCRYPTION_SECRET enforcement in production
The application now validates that ENCRYPTION_SECRET is present and meets minimum length requirements at start-up when NODE_ENV=production. If the secret is missing or too short the process will exit with a descriptive error rather than silently writing unencrypted data.
Configuration
Set the following environment variable in every environment that writes people records:
| Variable | Description | Required |
|---|---|---|
ENCRYPTION_SECRET | 256-bit (32-byte) secret key used by AES-256-GCM. Must be kept outside source control. | Yes — production will not start without it |
Generate a strong secret with:
openssl rand -base64 32
Then add it to your environment:
ENCRYPTION_SECRET="<output from above>"
Migrating Existing Data
Rows written before v0.1.133 contain plaintext PII. A one-time backfill is required to encrypt this existing data.
High-level backfill procedure:
- Take a full database backup before proceeding.
- Confirm
ENCRYPTION_SECRETis set in the environment running the migration. - Fetch all rows from the
peopletable. - For each row, call
encryptPiiFields()and update the record in place. - Verify a sample of rows by reading them back through the API and confirming decryption succeeds.
- Confirm no plaintext values remain using a direct database query.
Contact your platform administrator or consult the internal runbook for the exact migration script.
Affected Files
| File | Change |
|---|---|
src/app/api/people/route.ts | Encrypt on write, decrypt on read; delegate to repository layer |
src/app/api/v1/people/route.ts | Same as above |
src/lib/encryption.ts | No logic changes; now actively invoked by the data-access layer |
Frequently Asked Questions
Does this affect API request or response payloads? No. Encryption and decryption happen entirely within the server-side data-access layer. All API inputs and outputs remain in plaintext JSON.
What happens if ENCRYPTION_SECRET rotates?
Rows encrypted with the old key cannot be decrypted with the new key. A key-rotation migration (re-encrypt all rows) is required before switching secrets. Key rotation tooling is planned for a future release.
Will search and exact-match queries still work?
Because each field is encrypted with a random IV, database-level LIKE or equality queries on ciphertext are not possible. Fuzzy matching is performed in application memory after decryption. This is the existing architectural approach and is unchanged.
Is this change backwards compatible? Rows written by v0.1.133 onwards will be unreadable by older versions of the application. Do not roll back to a version prior to v0.1.133 after deploying without also performing a decryption backfill.