Key Aliases & Key Configs
Key Aliases & Crypto Key Configs
Overview
Every stage so far has had exactly one key, so getById() could get away with ignoring the id it was asked for and always returning that same key. This stage makes that shortcut visible by introducing a second key: getCurrentEncryptionKey() now answers "which key is current" by alias (an id, resolved through getById()) rather than by being the only option in existence. Facilitators should pair this with Key Aliases & Key Configs, and be explicit up front that this stage is not key rotation - it's the indirection mechanism rotation depends on, covered on its own so Key Rotation can later be "just repoint the alias" instead of introducing two ideas at once.
This stage comes as two projects:
starter/- what you work in. It compiles and runs, but both "shields" it builds point at the same key, so nothing demonstrates the alias yet. Look for the// TODOcomments.complete/- the finished reference, with two independently-configured shields.
Follow along
cd stages/04-Key-Aliases-and-Configs/starter
stages/04-Key-Aliases-and-Configs/starter as its own project.
The shortcut the previous stages got away with
PaymentCardEntity is untouched again - the entity never knows how many keys exist. What changes is InMemoryCryptoKeyProvider: instead of one hardcoded key returned unconditionally, it now holds two.
Resolving a key by id, for real
@Override
public CryptoKey getById(String cryptoKeyId) {
// The previous stage's shortcut: ignore the id asked for, just
// return the one key that exists.
CryptoKey resolved = KEYS_BY_ID.get(currentEncryptionKeyId);
resolved = KEYS_BY_ID.get(cryptoKeyId);
return resolved;
}
getById() is what decrypt() calls with whatever key id is recorded inside the ciphertext it's reading - current or not. A provider with only one key can ignore its argument and still be correct by accident. With two keys, that stops being true, which is exactly why this stage adds a second one: it's the smallest change that turns "ignore the id" from a harmless simplification into a bug.
Your turn: in starter/.../InMemoryCryptoKeyProvider.java, make getById() look cryptoKeyId up in the key map instead of defaulting to the current key.
"Current" is just another lookup
@Override
public CryptoKey getCurrentEncryptionKey() {
// "Current" is answered by alias, not by a dedicated field: whichever
// id this provider was configured with is looked up exactly the same
// way any other key id is. Nothing about encrypt() has to know or
// care that this particular lookup is the "current" one.
return getById(currentEncryptionKeyId);
}
getCurrentEncryptionKey() doesn't hold its own copy of a key - it asks getById() for whichever id this provider instance was configured with. That id is the alias: application code (and CryptoShield) never sees it, they just ask for "the current encryption key" and "a specific key by id," and both questions are answered by the same lookup.
The two known keys
private static Map<String, CryptoKey> buildKeys() {
CryptoKey archiveKey = buildEncryptionKey(
"workshop-archive-key",
"workshop-archive-passphrase-do-not-use-in-production",
"workshop-archive-salt");
CryptoKey currentKey = buildEncryptionKey(
"workshop-encryption-key",
"workshop-demo-passphrase-do-not-use-in-production",
"workshop-demo-salt");
return Map.of(archiveKey.getId(), archiveKey, currentKey.getId(), currentKey);
}
workshop-archive-key and workshop-encryption-key are both real, resolvable CryptoKeys - the only difference between them, from the provider's point of view, is which one a given instance was told is "current."
Proving it: two shields, one archive
// Before this stage there was only ever one shield/provider - both
// start out as the same thing, both pointed at the current key.
CryptoShield lastYearsShield = new CryptoShield.Builder()
.withCryptoKeyProvider(new InMemoryCryptoKeyProvider("workshop-encryption-key"))
.withAnnotatedEntities(List.of(PaymentCardEntity.class))
.withEncryptionServiceDelegates(List.of(delegate))
.build();
CryptoShield todaysShield = lastYearsShield;
lastYearsShield = new CryptoShield.Builder()
.withCryptoKeyProvider(new InMemoryCryptoKeyProvider("workshop-archive-key"))
.withAnnotatedEntities(List.of(PaymentCardEntity.class))
.withEncryptionServiceDelegates(List.of(delegate))
.build();
todaysShield = new CryptoShield.Builder()
.withCryptoKeyProvider(new InMemoryCryptoKeyProvider("workshop-encryption-key"))
.withAnnotatedEntities(List.of(PaymentCardEntity.class))
.withEncryptionServiceDelegates(List.of(delegate))
.build();
Your turn: in starter/.../Main.java, build lastYearsShield with "workshop-archive-key" as current and todaysShield with "workshop-encryption-key" as current - two providers sharing the same known keys, disagreeing only about which one is "current."
// Simulate a record encrypted a while ago, back when the archive key
// was current.
PaymentCardEntity archived = new PaymentCardEntity();
archived.setCardNumber("5111111111111111");
lastYearsShield.encrypt(archived);
System.out.println("archived encryptedData: " + archived.getEncryptedData());
// Today's shield has a different "current" key, but can still
// decrypt it: decrypt() reads the key id recorded on the ciphertext
// and resolves it by id via getById(), current or not.
PaymentCardEntity loaded = new PaymentCardEntity();
loaded.setEncryptedData(archived.getEncryptedData());
todaysShield.decrypt(loaded);
System.out.println("decrypted by today's shield: " + loaded.getCardNumber());
lastYearsShield encrypts as if the archive key were still current - simulating a record written a while ago. todaysShield, with a completely different "current" key, decrypts it anyway: decrypt reads the key id off the ciphertext and resolves it by id, not by asking what's current right now.
// New writes go out under whichever key is current for the shield
// doing the writing.
PaymentCardEntity fresh = new PaymentCardEntity();
fresh.setCardNumber("5111111111111111");
todaysShield.encrypt(fresh);
System.out.println("fresh encryptedData: " + fresh.getEncryptedData());
New writes, in contrast, always go out under whichever key is current for the shield doing the writing.
Running it
starter/ before your changes builds two shields that are actually the same object, so the run succeeds but proves nothing about aliasing - it would succeed even if getById() were still broken:
archived encryptedData: {"cryptoKeyId":"workshop-encryption-key",...}
decrypted by today's shield: 5111111111111111
fresh encryptedData: {"cryptoKeyId":"workshop-encryption-key",...}
After both changes, the archived record is genuinely encrypted under a different key than the fresh one, and todaysShield still decrypts it correctly:
archived encryptedData: {"cryptoKeyId":"workshop-archive-key",...}
decrypted by today's shield: 5111111111111111
fresh encryptedData: {"cryptoKeyId":"workshop-encryption-key",...}
The cryptoKeyId on each line is the tell: two different values, one successful cross-key decrypt.