Skip to Content
Deneva MCPComponentsCredentials Service

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_deks holds the wrapped DEK (dek_enc), its IV (dek_iv), and the GCM auth tag (dek_tag).
  • Destroying a tenant’s tenant_deks row makes their prior ciphertexts permanently unreadable without re-encrypting anyone else’s data. This is the GDPR right-to-erasure mechanism.
  • tenant_deks is 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:

  1. SELECT FOR UPDATE the credentials row (serializes with concurrent writers).
  2. Decrypts access + refresh tokens via decryptToken(..., tx).
  3. Clears the in-process cached DEK and DELETEs the old tenant_deks row.
  4. 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.
  5. UPDATEs platform_credentials with the new ciphertexts and stamps tenant_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) then decryptToken(t1, prior_blob) throws.
  • The plaintext DEK in memory is byte-different from the stored dek_enc.
  • mcp_app without app.current_tenant_id set sees zero rows in tenant_deks (RLS engaged).

tests/integration/dek-rotation.test.ts covers the rotation script:

  • Round-trip: rotate → decrypt yields the original plaintext.
  • tenant_deks.dek_enc bytes change and rotated_at becomes 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