Skip to content

Single HMAC Strategy

Single HMAC Strategy

Overview

With an HMAC field in place, this stage puts it to work: searching for a record by plaintext value, and enforcing a uniqueness constraint, both via an equality lookup against the stored HMAC. Facilitators should walk through Single HMAC Strategy first, especially the unique-constraint failure mode it flags: because the strategy stores exactly one HMAC per record, a value's HMAC changes the moment its key rotates, and enforcement anomalies follow directly from that. This stage doesn't just describe that failure - it reproduces it, in code, on the last line of Main.java.

This stage comes as two projects:

  • starter/ - what you work in. It compiles and runs, but PaymentCardStore.findByHmac() always reports no match, so search fails and nothing is ever treated as a duplicate. Look for the // TODO comment.
  • complete/ - the finished reference, including the rotation failure mode reproduced live.

Follow along

cd stages/07-Single-HMAC-Strategy/starter
Using an IDE instead? Open stages/07-Single-HMAC-Strategy/starter as its own project.

The entity hasn't changed

PaymentCardEntity is exactly what Introducing HMACs left it: cardNumber is @Encrypt and @Hmac, cardNumberHmac and hmacKeyId carry the computed value and the key that produced it. The entity never knows what its HMAC gets used for - that's entirely up to the code around it, which is this stage's actual subject.

A store that never sees plaintext

public Optional<PaymentCardEntity> findByHmac(String hmac) {
    // The previous stages had no HMAC to search by at all.
    Optional<PaymentCardEntity> match = Optional.empty();

    match = records.stream()
            .filter(record -> record.getCardNumberHmac().equals(hmac))
            .findFirst();

    return match;
}

PaymentCardStore stands in for a real table/column. It never touches cardNumber or encryptedData - only cardNumberHmac. Because that value is deterministic, an equality filter against it is enough to find a match. The same filter against encryptedData could never work: two encryptions of the identical card number produce different ciphertext every time.

Your turn: in starter/.../PaymentCardStore.java, make findByHmac() actually search records instead of always returning empty.

/**
 * Enforces uniqueness the same way search works: by HMAC equality.
 * Returns false (and doesn't save) if a record with the same HMAC
 * already exists.
 */
public boolean save(PaymentCardEntity entity) {
    if (findByHmac(entity.getCardNumberHmac()).isPresent()) {
        return false;
    }
    records.add(entity);
    return true;
}

Uniqueness reuses the exact same lookup: save() rejects anything whose HMAC is already present. Search and uniqueness aren't two mechanisms here, they're one mechanism used twice.

Search and duplicate rejection, working correctly

PaymentCardEntity card = new PaymentCardEntity();
card.setCardNumber("5111111111111111");
originalShield.encrypt(card);
store.save(card);

// Searching means: HMAC the search term under the same key, then
// look it up by equality - encryptedData never enters into it.
PaymentCardEntity searchProbe = new PaymentCardEntity();
searchProbe.setCardNumber("5111111111111111");
originalShield.encrypt(searchProbe);
Optional<PaymentCardEntity> found = store.findByHmac(searchProbe.getCardNumberHmac());
System.out.println("search found the record?               " + found.isPresent());
// A second entity with the same card number hashes to the same
// HMAC under the same key, so it's correctly rejected as a duplicate.
PaymentCardEntity duplicate = new PaymentCardEntity();
duplicate.setCardNumber("5111111111111111");
originalShield.encrypt(duplicate);
boolean saved = store.save(duplicate);
System.out.println("duplicate accepted (same key)?         " + saved);

Under a single, unchanging HMAC key, both work exactly as expected: the search probe finds the saved record, and a genuine duplicate is rejected before it's ever added to the store.

Where Single HMAC Strategy breaks

// Now simulate an HMAC key rotation: a shield built after the HMAC
// key changed, querying the same store, which still only holds
// "card"'s HMAC computed under the old key - nothing has rekeyed it.
CryptoShield rotatedShield = new CryptoShield.Builder()
        .withCryptoKeyProvider(new InMemoryCryptoKeyProvider("workshop-hmac-key-v2"))
        .withAnnotatedEntities(List.of(PaymentCardEntity.class))
        .withEncryptionServiceDelegates(List.of(delegate))
        .build();

PaymentCardEntity duplicateAfterRotation = new PaymentCardEntity();
duplicateAfterRotation.setCardNumber("5111111111111111");
rotatedShield.encrypt(duplicateAfterRotation);
boolean acceptedAfterRotation = store.save(duplicateAfterRotation);
System.out.println("duplicate accepted (after key rotation)? " + acceptedAfterRotation);

rotatedShield is built with a different "current" HMAC key - simulating a key rotation that's happened since card was first saved. The duplicate encrypted under the new key hashes to a completely different cardNumberHmac, so findByHmac() genuinely finds nothing: as far as the store can tell, this is a brand new value, not a repeat of one it already has.

This isn't a bug in PaymentCardStore - it's the structural limit Single HMAC Strategy describes. The strategy stores exactly one HMAC per record, computed under whatever key was current at the time. Checking uniqueness against records written under an older key would mean recomputing the candidate's HMAC under every key that's ever been active and comparing each - something this strategy has no mechanism for. List HMAC Strategy, next, stores one HMAC per active key instead of one HMAC per record, closing exactly this gap.

Running it

starter/ before your change:

search found the record?               false
duplicate accepted (same key)?         true
duplicate accepted (after key rotation)? true

Nothing is ever found, and nothing is ever rejected - findByHmac() always reporting "no match" makes every save look like the first one.

After your change:

search found the record?               true
duplicate accepted (same key)?         false
duplicate accepted (after key rotation)? true

The middle line is the fix working: a genuine duplicate, same key, correctly rejected. The last line is the point of this stage: the exact same duplicate, encrypted under a rotated key, is silently accepted.