Skip to main content

Key Rotation

Cryptographic key rotation is essential for security compliance and incident response. Regulatory frameworks such as eIDAS and institutional security policies typically mandate periodic key rotation, and the ability to rotate keys immediately is critical when a key compromise is suspected. The challenge with HMAC keys is that changing the key changes every hash -- which would break all existing lookups if handled naively. A wallet user who was registered under Key A version 1 would become invisible to lookups using Key A version 2, because the HMAC of the same input with a different key produces a completely different hash.

The portal solves this with a dual-read mechanism that enables zero-downtime key rotation without requiring a bulk migration before the new key can be activated. The moment a new key is configured, the system begins using it for all new writes while continuing to recognize records created with the previous key.

The Dual-Read Mechanism

During a rotation window, the system maintains both the current key and the previous key in its configuration. The dual-read mechanism works as follows:

For writes, only the new key is used. Any new identity_match records or identity_link_binding records are created with hashes computed using the current key version. This ensures that the system is always moving forward -- no new records are created with the old key.

For reads, the system attempts the current key first, then falls back to the previous key:

1. Receive external identifier (e.g., wallet public key fingerprint)
2. Compute hash_current = HMAC-SHA256(identifier, current_key)
3. Query database: SELECT * FROM identity_match
WHERE identifier_hash = hash_current AND ...
4. If found -> return result (current key match)
5. If NOT found -> compute hash_previous = HMAC-SHA256(identifier, previous_key)
6. Query database: SELECT * FROM identity_match
WHERE identifier_hash = hash_previous AND ...
7. If found via old key -> return result
AND optionally re-hash with new key on next write operation
8. If neither found -> identifier is genuinely unknown

This approach has several important properties:

  • Zero downtime. The moment the new key is deployed, the system is fully functional. There is no migration window during which some users cannot authenticate.
  • No bulk re-hashing required upfront. Existing records are migrated lazily as they are accessed, or proactively through an optional background migration job. Either way, the system works correctly throughout.
  • Transparent to users. A returning wallet user experiences no difference whether their record has been migrated to the new key or is still being resolved through the dual-read fallback.
  • Bounded performance impact. The worst case is two database queries instead of one, but this only affects records that have not yet been migrated. As migration progresses, more lookups are resolved on the first query.

Key Version Tracking

Every record in the matching system stores the version of the key that was used to create its hashes and ciphertexts. This version tracking is what makes targeted migration possible -- the system knows exactly which records need processing without scanning every row.

The following version fields are maintained:

  • identity_match.hash_key_version: Records which HMAC key version produced the identifier_hash in the match record. During dual-read, if a match is found via the previous key (step 7 above), this field's value will be less than the current key version.

  • identity_link_binding.holder_hash_key_version: Records which version of Key A was used to compute the holder_identifier_hash. Independent of the match record's key version because the binding may have been created or migrated at a different time.

  • identity_link_binding.institution_hash_key_version: Records which version of Key B was used to compute the institution_identifier_hash. Key A and Key B can be rotated independently, so their version tracking is separate.

  • identity_link_binding.encrypted_institution_id_key_version: Records which version of Key C was used to encrypt the encrypted_institution_id and the persisted_attributes_envelope. Unlike HMAC key rotation (which changes lookup hashes), encryption key rotation requires decrypting with the old key and re-encrypting with the new key.

Configuration

Key rotation is configured through the application's YAML configuration. The configuration supports both the current key and the previous key for each of the three key types.

sphereon:
app:
identity:
reconciliation:
crypto:
# Key A - Holder HMAC key
holder-hmac-key-alias: "reconciliation:holder"
holder-hmac-key-version: 2
# Previous Key A for dual-read during rotation
previous-holder-hmac-key-alias: "reconciliation:holder-v1"
previous-holder-hmac-key-version: 1

# Key B - Institution HMAC key
institution-hmac-key-alias: "reconciliation:institution"
institution-hmac-key-version: 2
previous-institution-hmac-key-alias: "reconciliation:institution-v1"
previous-institution-hmac-key-version: 1

# Key C - Encryption key
encryption-key-alias: "reconciliation:encryption"
encryption-key-version: 2
previous-encryption-key-alias: "reconciliation:encryption-v1"
previous-encryption-key-version: 1

Each key type has four configuration properties:

  • *-key-alias: The alias of the current key in the KMS or HSM. This is the key used for all new writes.
  • *-key-version: The version number of the current key. This value is stored in every new record's key version field.
  • previous-*-key-alias: The alias of the previous key. This key is used only for dual-read fallback during the rotation window. When set to null or omitted, dual-read is disabled for this key type.
  • previous-*-key-version: The version number of the previous key. Used to identify which records in the database were created with this key and need migration.

The three key types (A, B, C) can be rotated independently. For example, you can rotate Key A (holder HMAC) without touching Key B (institution HMAC) or Key C (encryption). This is useful when only one key type is affected by a policy change or security incident.

Migration Job

While the dual-read mechanism ensures that the system works correctly without any migration, it is advisable to proactively migrate all records to the new key. This eliminates the dual-read fallback overhead and allows the previous key to be deactivated.

The migration job is a background process that iterates through all records with old key versions and re-hashes or re-encrypts them with the current key. Progress is tracked in the key_migration_history table.

Migration Configuration

sphereon:
app:
identity:
reconciliation:
migration:
enabled: true
target-version: 2
operations: FULL # Migrate all record types
batch-size: 100 # Records per batch
  • enabled: Whether the migration job is active. Set to true to start migration, false to pause or disable.
  • target-version: The key version to migrate to. Must match the current key version in the crypto configuration.
  • operations: Which record types to migrate. FULL migrates both identity_match and identity_link_binding records. Other options may include MATCH_ONLY or BINDING_ONLY for targeted migration.
  • batch-size: The number of records processed in each batch. Smaller batches reduce the impact on database performance but increase the total migration time. A value of 100 is a reasonable default for most deployments.

Migration Process

For each batch, the migration job performs the following steps:

  1. Select records with key versions older than the target version.
  2. For HMAC fields (in identity_match and identity_link_binding): Decrypt the original identifier using the old key is not possible (HMAC is one-way), so the migration job must have access to a mapping or must re-derive the hash. In practice, the system re-hashes from the original identifier only if it is available. For records where the original identifier is not available, the record is flagged and the dual-read mechanism continues to handle lookups.
  3. For AES fields (in identity_link_binding): Decrypt the ciphertext using the old Key C, then re-encrypt with the new Key C. Update the encrypted_institution_id_key_version field.
  4. Update key version fields on all migrated records.
  5. Record progress in key_migration_history, including records_processed, records_failed, and records_skipped.

Migration History

The key_migration_history table tracks the progress and outcome of each migration run:

FieldDescription
idPrimary key for the migration run
tenant_idWhich tenant this migration run applies to
source_versionThe key version being migrated from
target_versionThe key version being migrated to
records_processedNumber of records successfully migrated
records_failedNumber of records that failed migration (logged for investigation)
records_skippedNumber of records skipped (e.g., already at target version)
started_atWhen the migration run began
completed_atWhen the migration run finished
statusCurrent status: RUNNING, COMPLETED, FAILED, PAUSED

Rotation Procedure

The following step-by-step procedure describes how to perform a key rotation in a production environment.

Step 1: Generate New Key

Generate a new cryptographic key in the KMS or HSM under a new alias. For example, if the current Key A alias is reconciliation:holder (version 1), create a new key under reconciliation:holder (version 2) or a new alias like reconciliation:holder-v2, depending on the KMS's key versioning model.

Verify that the new key is functional by performing a test HMAC operation (for Keys A and B) or a test encrypt/decrypt round-trip (for Key C).

Step 2: Update Configuration

Update the application configuration to set the new key as primary and the old key as previous:

  • Set holder-hmac-key-version: 2 (or the appropriate new version number)
  • Set previous-holder-hmac-key-alias and previous-holder-hmac-key-version to the old key's values
  • Repeat for Key B and Key C if they are also being rotated

Step 3: Deploy

Deploy the updated configuration. The moment the new configuration is active, dual-read begins. All new records are created with the new key. Lookups for existing records fall back to the previous key transparently.

There is no downtime during this step. The deployment can be a rolling update, a blue-green deployment, or any other deployment strategy -- the dual-read mechanism is stateless and works correctly even if different application instances are temporarily running different configurations.

Enable the migration job to proactively migrate all existing records to the new key version. Monitor the key_migration_history table to track progress.

migration:
enabled: true
target-version: 2
operations: FULL
batch-size: 100

The migration job can run concurrently with normal operations. It does not lock records or block lookups. If a record is accessed during migration, the dual-read mechanism handles it transparently.

Step 5: Monitor Completion

Monitor the key_migration_history table until the migration run reports status: COMPLETED with zero records_failed. If any records failed, investigate the failures and re-run the migration for those records.

You can also query the database directly to verify that no records remain with the old key version:

SELECT COUNT(*) FROM identity_match
WHERE hash_key_version < 2 AND deleted_at IS NULL;

Step 6: Remove Previous Key Configuration

Once all records have been migrated, remove the previous-* configuration entries. This disables dual-read, which means the system will no longer attempt fallback lookups with the old key. Deploy this configuration change.

Step 7: Deactivate Old Key

Deactivate (or schedule for deletion) the old key in the KMS or HSM. This is the final step -- once the old key is deactivated, it cannot be used for any operation. Ensure that the migration is fully complete before taking this step, as it is irreversible.

Inactive Key Cleanup

Records that have not been accessed within the inactivity threshold (default 2 years) and still use the old key version are candidates for purge rather than migration. There is no value in migrating a record to the new key if the record is going to be deleted for inactivity anyway.

The findInactiveOldKeyMatches query identifies these records:

SELECT * FROM identity_match
WHERE tenant_id = :tenant_id
AND hash_key_version < :target_version
AND last_used_at < :inactivity_threshold
AND deleted_at IS NULL;

These records are soft-deleted with deletion_reason = 'inactivity' rather than being migrated. This reduces the workload of the migration job and ensures that inactive records do not consume migration resources. The soft-deleted records follow the standard retention and hard-delete lifecycle.

ReconciliationCryptoService Interface

The cryptographic operations for key rotation are encapsulated in the ReconciliationCryptoService interface. This interface abstracts the underlying KMS or HSM, providing a clean API for the matching logic.

interface ReconciliationCryptoService {
/**
* Compute HMAC-SHA256 of the given value using the current key.
* Returns the hex-encoded hash string.
*/
suspend fun hmacHash(value: String): String

/**
* Encrypt the given plaintext using AES-256-GCM with the current Key C.
* Returns the Base64-encoded ciphertext (including nonce and auth tag).
*/
suspend fun encrypt(plaintext: String): String

/**
* Decrypt the given ciphertext using AES-256-GCM.
* Automatically detects the key version from the ciphertext envelope
* and uses the appropriate key (current or previous).
*/
suspend fun decrypt(ciphertext: String): String

/**
* Compute HMAC-SHA256 of the given value using the PREVIOUS key.
* Returns null if no previous key is configured (dual-read is not active).
* Used by the dual-read mechanism to attempt fallback lookups.
*/
suspend fun hmacHashWithPrevious(value: String): String?
}

The key method for dual-read support is hmacHashWithPrevious. When this method returns null, it signals that no previous key is configured and dual-read is not active -- the system should not attempt a fallback lookup. When it returns a hash string, the system uses that hash for the fallback query.

The decrypt method is designed to handle both current and previous key versions transparently. The ciphertext envelope includes metadata that identifies which key version was used for encryption, allowing the method to select the correct key without the caller needing to specify a version. This simplifies the calling code: it simply calls decrypt and the service handles key version detection internally.

This interface is implemented by a KMS-backed service in production and by an in-memory service in tests. The in-memory implementation uses the same algorithms (HMAC-SHA256 and AES-256-GCM) with locally generated keys, ensuring that the test behavior matches production behavior without requiring access to a real KMS.