PayeeConfig Encryption
Overviewβ
Payee configuration data (address, tax ID, patient account number, payer 835 ID, etc.) contains sensitive PII/PHI. To protect this data at rest, Convenient Checkout encrypts the serialized payee details using AES-256-GCM before persisting them in the database. The encryption key is stored in Azure Key Vault, never in source code or application configuration.
Data Modelβ
PayeeConfig (wallet-event-commons)β
Stored per-payee on VendorMerchant.payeeConfigs.
| Field | Type | Description |
|---|---|---|
payeeId | UUID | Unique identifier for the payee entry. |
encryptedPayeeDetails | String | AES-256-GCM ciphertext prefixed with enc:v1:, or legacy plain text for pre-encryption rows. |
keyVersion | String | The Azure Key Vault secret version used to encrypt this row; required for decryption. |
PayeeConfigs (wallet-event-commons)β
Top-level container stored on VendorMerchant.
| Field | Type | Description |
|---|---|---|
defaultPayeeId | UUID | The payee used by default when none is specified on a payment. |
payees | List<PayeeConfig> | All payee entries for this vendor-merchant. |
PayeeConfigDTO (wallet-merchant-service)β
The inbound API representation validated before encryption.
| Field | Constraints | Description |
|---|---|---|
payeeName | Required, max 35 chars, [A-Za-z0-9 !@#$%&()_+=\-;\",'./?] | Payee name. |
payeeAddressLine1 | Required, max 30 chars, [A-Za-z0-9 !@#$%&()_+=\-;\",'./?] | Street address line 1. |
payeeAddressLine2 | Optional | Street address line 2. |
payeeCity | Required, max 24 chars, letters & spaces only | City. |
payeeState | Required, max 2 chars, letters only | US state code. |
payeeZip | Required, max 10 chars, alphanumeric | ZIP / postal code. |
payeeCountry | Required | Country code. |
payeePatientAccountNumber | Required, max 38 chars | Patient account number used in payment routing. |
payeeTaxId | Required, max 15 chars, alphanumeric | Payee tax identifier. |
payer835Id | Required, max 5 chars, alphanumeric | 835 payer ID used for claim routing. |
π InternalInternal information β not visible in the public (merchant) site.
Architectureβ
ββββββββββββββββββββββββββββββββββββββββ
β wallet-merchant-service β
β β
Merchant API ββββΊ β MerchantService β
(PayeeConfigDTO) β ββ PayeeConfigEncryptionHelper β
β ββ PayeeEncryptionService ββββββ Azure Key Vault
β (Spring @Service) β (secret: payee-encryption-key)
ββββββββββββββββββββ¬ββββββββββββββββββββ
β MerchantEvent (encrypted PayeeConfigs)
βΌ (Kafka / Event Bus)
ββββββββββββββββββββββββββββββββββββββββ
β wallet-payment-service β
β β
β MerchantConsumer β
β ββ stores encrypted PayeeConfigs β
β in Merchant entity (DB) β
β β
β CreateUserPaymentCommandHandler β
β ββ PayeeEncryptionService.decrypt ββββββ Azure Key Vault
ββββββββββββββββββββββββββββββββββββββββ
βββ wallet-event-commons (shared library) βββ
PayeeEncryptionService (core logic)
AesGcmUtils (AES-256-GCM)
KeyVersionedSupplier (KV abstraction)
PayeeConfig / PayeeConfigs (POJOs)
Key Componentsβ
PayeeEncryptionService (wallet-event-commons β framework-agnostic core)β
Located at com.optum.wallet.common.util.payment.vendor.crypto.PayeeEncryptionService.
Pure-Java library class with no Spring dependency. Both services wrap it with a thin @Service adapter
that supplies the KeyVersionedSupplier from their own Azure Key Vault client.
| Method | Description |
|---|---|
encrypt(String plainPayeeDetails) | Encrypts the plain text using the current key version; returns a PayeeConfig with ciphertext prefixed enc:v1: and the keyVersion used. |
decrypt(String storedValue, String keyVersion) | Decrypts an enc:v1: prefixed value with the specified key version. Returns the value unchanged for legacy plain-text rows (backward compatibility). |
isEncrypted(String value) | Returns true if the value starts with enc:v1:. |
Versioned-key designβ
Every encrypted row stores the keyVersion (Azure Key Vault secret version ID) alongside the ciphertext.
On decryption the service fetches that specific version from Key Vault. This enables zero-downtime key rotation:
new rows are encrypted with the new version; old rows remain decryptable as long as the old version exists in the vault.
Backward compatibilityβ
Rows that pre-date encryption (plain-text encryptedPayeeDetails) are detected by the absence of the enc:v1: prefix
and are returned as-is by decrypt(). No migration script is required; rows are re-encrypted lazily on the next write.
AesGcmUtils (wallet-event-commons)β
Low-level utility that performs AES-256-GCM operations.
- Algorithm:
AES/GCM/NoPadding - IV: 12 random bytes generated per encryption via
SecureRandom - GCM tag: 128 bits
- Key size: exactly 256 bits (32 bytes); enforced at runtime
- Wire format:
Base64( IV[12] || ciphertext+tag )
KeyVersionedSupplier (wallet-event-commons)β
Interface that decouples the core encryption logic from any specific key-store SDK.
public interface KeyVersionedSupplier {
/** Returns the Base64-encoded AES-256 key for the given version. */
String getKey(String version);
/** Returns the current (latest) version identifier from the key store. */
String currentVersion();
}
Both services implement this anonymously using their KeyVaultClient.
Service Responsibilitiesβ
wallet-merchant-serviceβ
Responsibility: encrypt payee details on write.
- Merchant API receives a
VendorMerchantpayload containing aPayeeConfigsobject with plain-textencryptedPayeeDetails. MerchantServicedelegates toPayeeConfigEncryptionHelper.encryptAll(PayeeConfigs).PayeeConfigEncryptionHelperiterates eachPayeeConfig:- Skips entries where details are blank.
- Skips entries already prefixed with
enc:v1:(idempotent re-saves). - Calls
PayeeEncryptionService.encrypt(details)for all other entries.
- The resulting encrypted
PayeeConfigsis stored and published as aMerchantEvent.
Spring PayeeEncryptionService adapter (merchant-service) wires to Key Vault via:
- Secret name property:
payment.payee.encryption.secret-name(default:payee-encryption-key) - Strips trailing whitespace/newlines from Key Vault responses before Base64 decoding.
wallet-payment-serviceβ
Responsibility: store encrypted payee configs and decrypt on payment processing.
MerchantConsumerreceivesMerchantEventand persists thePayeeConfigs(already encrypted) onto theMerchantentity β no decryption at this stage.CreateUserPaymentCommandHandlerdecrypts payee details at payment-processing time by callingPayeeEncryptionService.decrypt(storedValue, keyVersion).
Key Vault Configurationβ
| Property | Service | Default | Description |
|---|---|---|---|
payment.payee.encryption.secret-name | merchant-service | payee-encryption-key | Name of the Key Vault secret holding the AES-256 key. |
payment.payee.encryption.secret-name | payment-service | payee-encryption-key | Same secret name; each service has its own Key Vault client. |
Secret formatβ
The Key Vault secret value must be a Base64-encoded 256-bit (32-byte) AES key, e.g.:
# Generate a suitable key:
openssl rand -base64 32
Store the output directly as the secret value. The service automatically strips leading/trailing whitespace.
Local developmentβ
For local development profiles (LocalKeyVaultClient), a static Base64 key is supplied by implementing KeyVersionedSupplier with fixed values:
KeyVersionedSupplier localSupplier = new KeyVersionedSupplier() {
@Override
public String getKey(String version) { return localBase64Key; }
@Override
public String currentVersion() { return "local"; }
};
new com.optum.wallet.common.util.payment.vendor.crypto.PayeeEncryptionService(localSupplier);
This means encryption/decryption still works end-to-end locally without a real Key Vault connection.
Sequence: Merchant Save (Encrypt)β
Client β MerchantController
β MerchantService.saveMerchant(vendorMerchant)
β PayeeConfigEncryptionHelper.encryptAll(payeeConfigs)
β [for each PayeeConfig]
β PayeeEncryptionService.isEncrypted(details) β false
β PayeeEncryptionService.encrypt(details)
β KeyVaultClient.getCurrentSecretVersion() β version
β KeyVaultClient.getSecretValue(name, version) β base64Key
β AesGcmUtils.encrypt(details, aesKey) β ciphertext
β return PayeeConfig(enc:v1:<ciphertext>, version)
β save encrypted VendorMerchant
β publish MerchantEvent (encrypted)
Sequence: Payment Processing (Decrypt)β
PaymentCommand β CreateUserPaymentCommandHandler
β merchantRepository.findMerchant(...)
β PayeeEncryptionService.decrypt(encryptedPayeeDetails, keyVersion)
β isEncrypted? β yes (enc:v1: prefix)
β KeyVaultClient.getSecretValue(name, keyVersion) β base64Key
β AesGcmUtils.decrypt(cipherPayload, aesKey) β plainText
β use plain-text payee details for payment routing
Error Handlingβ
All encryption/decryption failures are wrapped in PayeeCryptoException (unchecked). Common causes:
| Cause | Exception message |
|---|---|
| Blank payee details on encrypt | "Payee details are empty" |
Missing keyVersion for encrypted row | "Missing key version for encrypted payee details" |
| Key Vault returns empty/null key | "Encryption key is empty" |
| Key Vault key is not valid Base64 | "Encryption key is not valid Base64" |
| Key is not 256-bit | "Invalid AES key length: N bytes" |
| JCE encryption failure | "Failed to encrypt payee details" |
| JCE decryption failure | "Failed to decrypt payee details" |
Testingβ
PayeeEncryptionServiceTest(commons, merchant-service, payment-service): unit tests covering encrypt/decrypt round-trips, legacy plain-text pass-through, blank-input guards, and invalid key detection.PayeeConfigEncryptionHelperTest(merchant-service): tests the idempotency guard (skip already-encrypted), null/blank skip, and fullencryptAlllist behaviour.