Checking access…

Skip to main content
Version: v2

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.

FieldTypeDescription
payeeIdUUIDUnique identifier for the payee entry.
encryptedPayeeDetailsStringAES-256-GCM ciphertext prefixed with enc:v1:, or legacy plain text for pre-encryption rows.
keyVersionStringThe Azure Key Vault secret version used to encrypt this row; required for decryption.

PayeeConfigs (wallet-event-commons)​

Top-level container stored on VendorMerchant.

FieldTypeDescription
defaultPayeeIdUUIDThe payee used by default when none is specified on a payment.
payeesList<PayeeConfig>All payee entries for this vendor-merchant.

PayeeConfigDTO (wallet-merchant-service)​

The inbound API representation validated before encryption.

FieldConstraintsDescription
payeeNameRequired, max 35 chars, [A-Za-z0-9 !@#$%&()_+=\-;\",'./?]Payee name.
payeeAddressLine1Required, max 30 chars, [A-Za-z0-9 !@#$%&()_+=\-;\",'./?]Street address line 1.
payeeAddressLine2OptionalStreet address line 2.
payeeCityRequired, max 24 chars, letters & spaces onlyCity.
payeeStateRequired, max 2 chars, letters onlyUS state code.
payeeZipRequired, max 10 chars, alphanumericZIP / postal code.
payeeCountryRequiredCountry code.
payeePatientAccountNumberRequired, max 38 charsPatient account number used in payment routing.
payeeTaxIdRequired, max 15 chars, alphanumericPayee tax identifier.
payer835IdRequired, max 5 chars, alphanumeric835 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.

MethodDescription
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.

  1. Merchant API receives a VendorMerchant payload containing a PayeeConfigs object with plain-text encryptedPayeeDetails.
  2. MerchantService delegates to PayeeConfigEncryptionHelper.encryptAll(PayeeConfigs).
  3. PayeeConfigEncryptionHelper iterates each PayeeConfig:
    • Skips entries where details are blank.
    • Skips entries already prefixed with enc:v1: (idempotent re-saves).
    • Calls PayeeEncryptionService.encrypt(details) for all other entries.
  4. The resulting encrypted PayeeConfigs is stored and published as a MerchantEvent.

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.

  1. MerchantConsumer receives MerchantEvent and persists the PayeeConfigs (already encrypted) onto the Merchant entity β€” no decryption at this stage.
  2. CreateUserPaymentCommandHandler decrypts payee details at payment-processing time by calling PayeeEncryptionService.decrypt(storedValue, keyVersion).

Key Vault Configuration​

PropertyServiceDefaultDescription
payment.payee.encryption.secret-namemerchant-servicepayee-encryption-keyName of the Key Vault secret holding the AES-256 key.
payment.payee.encryption.secret-namepayment-servicepayee-encryption-keySame 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:

CauseException 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 full encryptAll list behaviour.