Skip to content

Encryption

Antonio Membrides Espinosa edited this page Jul 24, 2026 · 6 revisions

🔐 Encryption — Queryable Encryption, DEKs & Key Management

Status: Implemented (v2) PCI DSS coverage: Req 3 (encryption in use / at rest), Req 3.5–3.6 (key management), Req 4 (in transit), Req 7 (least-privilege access)

This page explains how cardholder and personal data are encrypted in the demo: how the Queryable Encryption (QE) fields are configured, how Data Encryption Keys (DEKs) and the master key (CMK) are managed, and how the cardholder-data (CHD) envelope works on the event bus.


1. Two complementary mechanisms

The platform uses two distinct crypto systems that share the same master key surface:

Mechanism Protects Where Managed by
Queryable Encryption (QE) PII & sensitive fields at rest in MongoDB (email, phone, account reference, address, date of birth, government ID, gateway payload, card expiry, and bank data: payout IBAN/routing and destination IBAN) MongoDB collections The MongoDB driver (automatic encryption)
CHD envelope Cardholder data (PAN / CVV / expiry) in transit on the internal event bus The opaque chd token on bus events The application (AES-256-GCM, node:crypto)

The CVV is never stored in MongoDB and is never placed in QE. It crosses the bus encrypted just-in-time on the way to the card issuer, and the built-in issuer also derives it per card on demand (HMAC-SHA256 under the issuer key, see §13) rather than holding a single global value (PCI DSS Req 3.2: no sensitive authentication data stored). The full PAN is not stored by the PSP core either; when the built-in card-issuer module is active it stores the full PAN encrypted with QE:equality in its own module-owned vault (cardIssuerVault, see §13), the issuer CDE. The core keeps only the token + BIN + last4.


2. Key hierarchy

Both mechanisms follow the standard envelope-encryption model — a three-level key hierarchy:

CMK / Master Key            ← lives in the KMS; never encrypts data directly
   │  (wraps / unwraps)
   └─ DEK  (Data Encryption Key)   ← stored encrypted in the key vault
        │  (encrypts / decrypts)
        └─ field value / message   ← the actual ciphertext in the document or on the bus
  • The CMK never leaves the KMS (for AWS) — the driver asks KMS to wrap/unwrap DEKs.
  • Each DEK is stored already-encrypted (wrapped by the CMK) in the key vault collection.
  • The DEK is what actually encrypts a field value; compromising a DEK ciphertext is useless without the CMK.

3. The master key (CMK) — KMS_PROVIDER

Source: backend/src/vendors/encryption/kms.ts

The KMS provider is chosen by the PSP_KMS_PROVIDER environment variable (a legacy KMS_PROVIDER is still read as a fallback):

Local (offline demo — default)

PSP_KMS_PROVIDER=local
PSP_KMS_LOCAL_MASTER_KEY=<96-byte base64 key>

A 96-byte local master key (the size MongoDB QE requires for the local provider). Generate it with:

npm run setup:key:master   # → backend/bin/seed-generate-key.ts → randomBytes(96).toString('base64')

AWS KMS (production)

PSP_KMS_PROVIDER=aws
AWS_CMK_ARN=arn:aws:kms:...:key/...
AWS_REGION=...
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
# AWS_SESSION_TOKEN=...   # optional (temporary credentials)

With AWS the customer controls the CMK; MongoDB has zero access to it.

buildKmsProviders() returns the kmsProviders object passed to the driver; buildCmkOptions() returns the masterKey reference (AWS only — local needs none).


4. Data Encryption Keys (DEKs)

Source: backend/src/vendors/encryption/keyVault.ts

  • One DEK per encrypted field. MongoDB QE forbids sharing a DEK between fields of the same collection (error 6338401).
  • DEKs are stored in the key vault: encryption.__keyVault, each one wrapped by the CMK.
  • DEKs are created with ClientEncryption.createDataKey(provider, { masterKey, keyAltNames: ['DEK-...'] }) and looked up by their keyAltNames alias — so provisioning is idempotent (re-running setup reuses existing keys, never duplicates them).

The DEKs are organised into two access tiers (16 DEKs total: 5 lookup + 11 sensitive):

Tier DEK aliases Backs Audience
Lookup (QE:equality) DEK-tx-account-ref, DEK-party-email, DEK-party-phone, DEK-customer-account-ref, DEK-auth-email searchable encrypted fields All authenticated analyst roles (L1+)
Sensitive (QE:none) DEK-tx-raw-payload, DEK-tx-processor-meta, DEK-customer-address, DEK-customer-gov-id, DEK-customer-risk-notes, DEK-card-expiry, DEK-party-address, DEK-party-dob, DEK-payout-iban, DEK-payout-routing, DEK-exec-dest-iban retrieval-only sensitive fields (high-sensitivity PII, CHD, and GDPR/PSD2 bank data) L2 Investigator (with escalation token) + Security Auditor

Provisioning (backend/src/vendors/setup/provisionDEKs.ts) first creates a unique index on keyAltNames in the vault, then calls provisionDataEncryptionKeys() which getOrCreates each named DEK.


5. QE field configuration

Source: backend/src/vendors/encryption/encryptedFieldsMaps.ts

Each encrypted collection declares an encryptedFields.fields[] map. Every field specifies the DEK that protects it, its path, its BSON type, and (optionally) how it can be queried:

{
  keyId: deks.partyEmail,                 // which DEK encrypts this field
  path: 'partyEmailAddress',
  bsonType: 'string',
  queries: { queryType: 'equality' },     // ← searchable while encrypted; omit ⇒ QE:none
}

Two field categories:

  • QE:equality (has queries) — searchable while encrypted. The driver encrypts the query value with the field's DEK and matches it against the encrypted index, so the server never sees plaintext. Examples: partyEmailAddress, partyMobilePhoneNumber, cardTransactionAccountReference, customerAgreementReference, customerAuthenticationEmailAddress.
  • QE:none (no queries) — retrieval-only, not searchable. Examples: rawGatewayPayload, processorTransactionMetadata, customerAgreementResidentialAddress, governmentIdentificationReference, customerAgreementRiskNotes, paymentCardExpirationDate, partyPostalAddress, partyDateOfBirth, payoutAccountIban, payoutAccountRoutingNumber, destinationIban.

Encrypted collections & their fields

There are 7 QE-encrypted collections (5 QE:equality search keys, 11 QE:none fields):

Collection (BIAN SD) QE:equality (lookup) QE:none (sensitive)
party (SD-13) partyEmailAddress, partyMobilePhoneNumber partyPostalAddress, partyDateOfBirth
cardTransactionLog (SD-254) cardTransactionAccountReference rawGatewayPayload, processorTransactionMetadata
customerAgreementProcedure (SD-53) customerAgreementReference customerAgreementResidentialAddress, governmentIdentificationReference, customerAgreementRiskNotes
paymentCardManagement (SD-88) paymentCardExpirationDate
customerAuthenticationAssessment (SD-91) customerAuthenticationEmailAddress
payoutAccountArrangement (SD-66) payoutAccountIban, payoutAccountRoutingNumber
paymentExecutionProcedure (SD-65) destinationIban
cardIssuerVault (Card Administration, module-owned) paymentCardNumber (full PAN, CHD), cardServiceCode

The card token (paymentCardReference) is intentionally not in QE, a network token is not cardholder data under PCI DSS v4.0. The party date of birth / postal address and the payoutAccountArrangement / paymentExecutionProcedure bank fields (IBAN, routing, destination IBAN) are bank data under GDPR Art. 32 / PSD2, not PCI-scoped card data; they are QE:none, Level 2 only.

Two tiers from the same DEKs

buildEncryptedFieldsMaps(deks, tier) produces two maps:

  • level1 — includes only the QE:equality fields. The QE:none fields are not in the map, so the driver returns them as raw Binary ciphertext, and the service layer strips them out.
  • level2 — includes all fields, so the driver auto-decrypts everything before the service even sees the document.

This is the crux of the design: field-level access control is enforced by the QE client itself (which DEKs are in its map) — not by application projection code. A Level 1 client is physically unable to decrypt QE:none fields because it lacks them in its encrypted-fields map.


6. Creating the encrypted collections

Source: backend/src/vendors/setup/createCollections.ts

For each QE collection, setup calls:

clientEncryption.createEncryptedCollection(db, name, {
  provider,                                       // 'local' | 'aws'
  createCollectionOptions: { encryptedFields: map },
  ...(masterKey && { masterKey }),                // AWS only
});

This registers the encryptedFields configuration on the collection and provisions the internal QE metadata/index collections (esc/ecoc) used for encrypted equality search. In v2 the sensitive fields live inline in the main collection (no separate *Sensitive collections); separation is achieved purely through the DEK/tier split.


7. Runtime — role-aware client pools

Source: backend/src/vendors/encryption/roleClients.ts

Two long-lived MongoClient instances are maintained, one per tier, each configured with autoEncryption:

autoEncryption: {
  keyVaultNamespace: 'encryption.__keyVault',
  kmsProviders: buildKmsProviders(),
  encryptedFieldsMap: { 'pcidb.party': maps.party, 'pcidb.cardTransactionLog': maps.cardTransactionLog, ... },
  extraOptions: { cryptSharedLibPath, cryptSharedLibRequired },
}
  • L1 poollevel1 map, connection string MONGODB_URI_LEVEL1.
  • L2 poollevel2 map, connection string MONGODB_URI_LEVEL2.
  • DEKs are resolved first via a plain (non-QE) client (provisionDataEncryptionKeys, idempotent), then the encrypted client is built with the maps.

getDbForRole(role, hasValidToken) returns the right Db:

Role Pool
level2_investigator with a valid escalation token L2 (full decrypt)
security_auditor L2 (full decrypt)
everyone else (customer, L1 analyst, …) L1 (lookup only)

crypt_shared library

Source: backend/src/vendors/encryption/cryptLib.ts

Automatic encryption needs the MongoDB Automatic Encryption Shared Library (mongo_crypt_v1.dll / .so / .dylib). It is located via MONGODB_CRYPT_SHARED_LIB_PATH or platform defaults; if absent the driver attempts auto-discovery / mongocryptd. Download it from the MongoDB Enterprise downloads ("Cryptography Library / crypt_shared").

Defense in depth (Atlas RBAC)

Source: backend/src/vendors/setup/createAtlasRoles.ts

Setup also creates Atlas custom roles and DB users per tier, so the L1/L2 isolation exists at the database-credential level too — not only in the encrypted-fields map.


8. How an encrypted query works

L1 analyst searches partyEmailAddress = "luis@example.com":

  1. The driver encrypts the query value with DEK-party-email.
  2. It matches against the encrypted equality index — the server never decrypts.
  3. Matching documents return with partyEmailAddress auto-decrypted, but governmentIdentificationReference (a QE:none field absent from the L1 map) comes back as Binary and is stripped by the service.

L2 investigator (after an approved escalation, holding a short-lived token) uses the L2 pool → every encrypted field is auto-decrypted.


9. CHD envelope (cardholder data in transit)

Source: backend/src/vendors/encryption/chdCrypto.ts

Independent of QE, but shares the same master-key surface:

  • The local provider derives a 256-bit KEK from the same KMS_LOCAL_MASTER_KEY via HKDF-SHA256.
  • A fresh per-message DEK encrypts the content with AES-256-GCM; the KEK/CMK wraps that DEK (envelope encryption).
  • The result is the opaque chd token that rides card.issuer.validation.requested on the bus. The AAD binds the token to its journey (correlationId + event type), so it cannot be replayed onto another event.
  • The issuer adapter decrypts the chd just-in-time for the wire; plaintext is never re-published, persisted, or logged. The same KMS_PROVIDER switch allows moving to AWS without changing the contract.

10. Key lifecycle & operations

  • Provisioning: npm run setup:key:master (local) → npm run setup:db creates Atlas roles/users, provisions DEKs (one per field, reused by alias), creates encrypted collections, then indexes. Orchestrated by backend/src/vendors/setup/index.ts.
  • Rotation: rotate the CMK in the KMS (AWS) and re-wrap DEKs; DEKs themselves can be rotated by re-keying the vault. The application contract (encrypted-fields maps) is unchanged.
  • Backup: the key vault (encryption.__keyVault) holds the wrapped DEKs — back it up with the CMK reference; ciphertext is meaningless without the CMK.

11. Environment variables

Variable Purpose
PSP_KMS_PROVIDER local or aws (legacy KMS_PROVIDER still read as fallback)
PSP_KMS_LOCAL_MASTER_KEY 96-byte base64 master key (local KMS)
AWS_CMK_ARN, AWS_REGION AWS KMS customer master key (unprefixed)
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN AWS credentials (unprefixed)
MONGODB_URI base/fallback connection string + DEK provisioning
MONGODB_URI_LEVEL1, MONGODB_URI_LEVEL2 per-tier connection strings (Atlas RBAC)
MONGODB_DB_NAME application database name (e.g. fsi-psp-leafy-pay-store)
MONGODB_CRYPT_SHARED_LIB_PATH path to mongo_crypt_v1 shared library
PSP_KMS_KEY_VAULT_URI, PSP_KMS_KEY_VAULT_DATABASE, PSP_KMS_KEY_VAULT_COLLECTION key-vault location overrides (default encryption.__keyVault)

12. Source map

Concern File
KMS providers / CMK options backend/src/vendors/encryption/kms.ts
DEK provisioning + key vault backend/src/vendors/encryption/keyVault.ts
QE field maps (equality / none, tiers) backend/src/vendors/encryption/encryptedFieldsMaps.ts
Role-aware QE client pools backend/src/vendors/encryption/roleClients.ts
crypt_shared library resolution backend/src/vendors/encryption/cryptLib.ts
CHD envelope (bus) backend/src/vendors/encryption/chdCrypto.ts
Encrypted collection creation backend/src/vendors/setup/createCollections.ts
DEK index + provisioning entry backend/src/vendors/setup/provisionDEKs.ts
Atlas roles / DB users backend/src/vendors/setup/createAtlasRoles.ts
Setup orchestration backend/src/vendors/setup/index.ts
Local master-key generator backend/bin/generate-key.ts

13. Derived CVV, issuer CVK, and the module-owned PAN vault

The built-in card-issuer module demonstrates three MongoDB encryption capabilities on genuine issuer data.

13.1 Derived CVV (never stored, SAD)

The per-card CVV is recomputed on demand, never persisted:

perCardCvv = digits( HMAC-SHA256( CVK, cardToken | expiryMMYY | serviceCode ) )[0 : cvvLength]

cvvLength is 3 for Visa / Mastercard and 4 for Amex. A global escape-hatch CVV (validCvv, default 123) stays available for fast demos; cvvMode (both default | global | per_card) selects which values validation accepts. The CVV appears only in ephemeral reveal responses, never in a collection, log, listing, or validation response (PCI DSS Req 3.2).

13.2 CVK envelope encryption (KMS → DEK → CVK)

The Card Verification Key is module-owned issuer key material, provisioned once and stored only wrapped:

CMK / master key  →  DEK (wrapped in encryption.__keyVault)  →  CVK (HKDF from the unwrapped DEK)

Cleartext CVK exists only in process memory. This is the same envelope model as the QE DEKs; source: backend/src/providers/card-issuer/services/cardVerificationKey.service.ts, wired from vendors/setup/provisionDEKs.ts.

13.3 PAN vault (QE:equality) + reveal on demand

cardIssuerVault stores the full PAN (paymentCardNumber) and cardServiceCode with QE:equality (DEKs DEK-vault-pan, DEK-vault-service-code). Equality supports exact PAN lookup (panExact) and dedup while the server sees only ciphertext; substring / suffix QE is intentionally off (equality only, compatible with server 8.0). Day-to-day search stays on the non-sensitive core: last4 (equality) + bin (prefix). The full PAN is revealed on demand, exactly like the IBAN: hidden by default behind an eye icon, revealed ephemerally and audited (card.pan.revealed; IBAN uses account.iban.revealed, CVV card.cvv.revealed). operations_officer reveals directly from the built-in admin (cards:manage + internal-provider gate); the card owner reveals through the provider flow. Step-up MFA/SCA applies in production.


See also: PII (field-protection strategy & options evaluated), Architecture, and the Technical Specification for the full encryptedFieldsMaps and index strategy.


Beneficial owners stay out of QE

merchantAgreementProcedure remains plaintext (no QE map). The new merchantBeneficialOwners embed carries only FK + role + numeric ownership metadata (business data, not PII/CHD). Owner PII is encrypted in the referenced party record (existing QE tiers); it is never copied into the embed, so no double encryption and no new encrypted fields (QE fields are immutable once the collection exists).

Added 2026-07-24.

Clone this wiki locally