Credentials service (envelope encryption)
Source: src/security/credentials.service.ts
Per-tenant envelope encryption for token-shaped material. Used by the Google adapter to seal OAuth access + refresh tokens before they touch platform_credentials, and by the (forthcoming PR-7) DEK rotation script.
Threat model
- A single process-level KEK (
CREDENTIAL_KEK) wraps one DEK per tenant. Both are AES-256-GCM keys. - The plaintext DEK lives ONLY in process memory. The on-disk row in
tenant_deksholds the wrapped DEK (dek_enc), its IV (dek_iv), and the GCM auth tag (dek_tag). - Destroying a tenant’s
tenant_deksrow makes their prior ciphertexts permanently unreadable without re-encrypting anyone else’s data. This is the GDPR right-to-erasure mechanism. tenant_deksis RLS-isolated as defence-in-depth on the table that holds the keys to every tenant’s secrets.
Public API
encryptToken(tenantId: string, plaintext: string, tx?: CredentialsTx): Promise<string>
decryptToken(tenantId: string, blob: string, tx?: CredentialsTx): Promise<string>
destroyDek(tenantId: string, tx?: CredentialsTx): Promise<void>encryptToken returns the base64 of iv(12) || tag(16) || ciphertext — a single TEXT column at rest. decryptToken reverses it. Both look up the tenant’s DEK transparently: first call for a tenant opens one short transaction to read or insert the wrapped DEK row; subsequent calls hit a process-local cache (Map<tenantId, Buffer>).
The optional tx parameter (PR-7) is for callers that need atomicity across multiple crypto + DB hops — specifically the DEK rotation script. When provided, all DB hops happen on the same connection as the caller’s transaction so the inner ops see the outer tx’s pending state (e.g. a freshly-DELETEd tenant_deks row); when omitted, behaviour is unchanged from PR-2 (the helper opens its own short tx).
KEK format on disk
CREDENTIAL_KEK is a base64-encoded 32-byte value (openssl rand -base64 32 — see setup-ubuntu.md Step 7). The module base64-decodes at load and validates the result is exactly 32 bytes; the process exits non-zero before binding a port if the file is corrupt.
The doc spec originally checked KEK.length !== 32 on the raw loaded buffer, which would always fail because the file contents are ~45 bytes (44 base64 chars + newline). The base64-decode contract is uniform across dev and prod.
RLS integration
tenant_deks is RLS-isolated. Reads and writes happen inside short transactions that first run:
SELECT set_config('app.current_tenant_id', $1, true)The true argument makes the setting transaction-local — pooled connections never leak tenant context across requests.
Concurrent fresh-DEK creation
Two processes that miss the cache for the same brand-new tenant simultaneously both generate a random DEK and try to insert. The implementation uses INSERT ... ON CONFLICT DO NOTHING followed by a re-SELECT so the loser discards its fresh Buffer and adopts the winner’s row. One DEK per tenant, always. (See phase-2-google-ads.md “Deviations from spec” for the rationale.)
Test-only exports
_clearDekCacheForTests(tenantId?)— clears the in-process plaintext cache so tests can simulate a cold process._peekDekForTests(tenantId)— returns the cached plaintext DEK so the “plaintext DEK never appears in DB” test can compare cache vs. row bytes.
Both are prefixed with _ and called out as test-only in the source. Don’t import them from production code.
DEK rotation
scripts/rotate-dek.ts re-encrypts a tenant’s platform_credentials under a freshly-generated DEK in one outer transaction. Run as npx tsx scripts/rotate-dek.ts <tenantId>. The script:
SELECT FOR UPDATEthe credentials row (serializes with concurrent writers).- Decrypts access + refresh tokens via
decryptToken(..., tx). - Clears the in-process cached DEK and DELETEs the old
tenant_deksrow. encryptToken(..., tx)re-encrypts the plaintexts —ensureDek(tx)sees the post-delete state of the outer tx, generates a fresh DEK, and INSERTs the new row.- UPDATEs
platform_credentialswith the new ciphertexts and stampstenant_deks.rotated_at.
The whole sequence is atomic: if anything fails, the transaction rolls back and the old DEK + ciphertexts are preserved. Concurrent reads outside the transaction see either the old or new state cleanly — never half.
Tests
tests/integration/credentials.test.ts covers the envelope encryption contract:
- Non-leak: ciphertext does not contain the plaintext bytes, and two encryptions of the same input produce different ciphertexts (random IV).
- Round-trip:
decryptToken(t1, encryptToken(t1, x)) === x. - Cross-tenant decrypt fails (different DEK).
destroyDek(t1)thendecryptToken(t1, prior_blob)throws.- The plaintext DEK in memory is byte-different from the stored
dek_enc. - mcp_app without
app.current_tenant_idset sees zero rows intenant_deks(RLS engaged).
tests/integration/dek-rotation.test.ts covers the rotation script:
- Round-trip: rotate → decrypt yields the original plaintext.
tenant_deks.dek_encbytes change androtated_atbecomes non-null after rotation.- Rotating tenant A does not change tenant B’s DEK or ciphertexts.
- No-op when the tenant has no credentials (no orphaned DEK row generated).
Cross-references
secrets-loader.md—CREDENTIAL_KEKlives in the secrets store.database.md—tenant_deksschema and RLS.google-adapter.md— the first consumer ofencryptToken/decryptToken.