Demo code for the talk "Crypto you can change: getting ready for post-quantum without a rewrite" (James Carman, Momentum Dev Conference, Oct 15 2026).
It shows crypto agility through envelope encryption against a key management service. Data whose keys are wrapped with RSA, elliptic-curve, AES-GCM, ML-KEM, HQC and a hybrid sits side by side, all of it decrypts, and the calling code never changes.
Post-quantum migration is not a one-time swap. Algorithms get broken and the standard set keeps changing, so the skill worth building is the ability to change algorithms cheaply. Two things make that possible:
- Isolate crypto behind one seam. Call sites ask to seal an envelope, not for RSA.
- Store the algorithm identity with the data. Old data stays readable under the old algorithm while new data uses the new one.
Envelope encryption gives you both. Each record is encrypted with its own data key (the DEK), and that data key is wrapped by a key-encrypting key (the KEK) that a key management service (KMS) holds. Quantum computers do not threaten the data encryption; they threaten the public-key algorithms used to wrap the DEK. That wrap is the part this repo makes swappable, and every envelope records which algorithm and which KMS key it used.
You need JDK 25 or later. Maven is not required; the wrapper script fetches it the first time.
./mvnw -q compile exec:javaOn macOS and Linux, ./demo is shorthand for the same thing. It starts an
interactive prompt. The name in brackets is the algorithm that wraps new data
keys:
[RSA-OAEP-3072] > write customer record #1
saved record-0001: {alg=RSA-OAEP-3072, kid=key-0208c8, enc=AES-256-GCM, ciphertext=..., wrappedKey=384 bytes}
[RSA-OAEP-3072] > use ML-KEM-768
new data keys will be wrapped with ML-KEM-768 using KMS key key-e4e6a9
[ML-KEM-768] > write customer record #2
saved record-0002: {alg=ML-KEM-768, kid=key-e4e6a9, enc=AES-256-GCM, ciphertext=..., wrappedKey=48 bytes, kemCt=1088 bytes, wrapNonce=12 bytes}
[ML-KEM-768] > read
record-0001 (RSA-OAEP-3072) -> customer record #1
record-0002 (ML-KEM-768) -> customer record #2
algorithms in use: {ML-KEM-768=1, RSA-OAEP-3072=1}
use is the one-line change. Old data still reads, new data is post-quantum, and
no code changed. Tab completes commands, algorithm names and record ids, and the
up arrow recalls earlier lines.
| Command | What it does |
|---|---|
use [name] |
Chooses what wraps new data keys (an algorithm such as ML-KEM-768) or what encrypts new data (a cipher such as ChaCha20-Poly1305). On its own, lists both |
algorithms |
Everything you can use: what kind it is, whether it is quantum-safe, which provider implements it, and how many keys and records use it (algs works too) |
write <text> |
Encrypts the text under a fresh data key from the KMS and stores the envelope |
read |
Decrypts everything stored, whichever algorithms it names |
list |
One line per stored envelope: algorithm, KMS key, data cipher and size |
show [record] |
Draws a stored envelope field by field, or all of them. Nothing is decrypted |
rewrap |
Moves stored envelopes to the current KMS key. The KMS re-wraps each data key; the data is not touched |
retire <algorithm> |
Disables every KMS key for an algorithm, and names any records that strands |
enable <algorithm> |
Re-enables an algorithm's keys |
keys |
Lists the KMS's keys: algorithm, state, when each was created, and how many records use it |
cbom |
Inventories the algorithms and keys protecting stored data, and writes a CycloneDX CBOM to data/cbom.json |
help, exit |
The command list, and the way out |
Envelopes and the KMS's keys are kept under data/, so what one session writes
the next can read. Every session starts on RSA with AES-256-GCM; the choices made
with use are not saved. Delete data/ for a clean start.
The demo from the talk is also a script you can play back:
rm -rf data && ./demo --script docs/momentum.demoIn a real terminal, press a key and the next command types itself and runs
(in an IDE's run window, press Enter instead), with the same prompt
and the same commands as when you type them. The script's comments explain each
step. On Windows, clear old data with rmdir /s /q data (in PowerShell,
Remove-Item -Recurse data), then run
.\mvnw -q compile exec:java "-Dexec.args=--script docs/momentum.demo".
Set DEMO_TYPING=0 to skip the typing effect, or DEMO_CPS to change its speed.
Write your own scripts the same way: one command per line, # for comments.
A single command can also be run without the prompt:
./demo cbom
./mvnw -q compile exec:java -Dexec.args="cbom"docs/exploring.md walks through seven experiments,
including a full migration (inventory, rewrap, retire), adding an algorithm of
your own, and taking a cryptographic inventory.
./mvnw test checks the same claims automatically; the test names read as sentences.
CI also checks the code's formatting (google-java-format) and license headers.
Your own builds never do, so changes you try out are never blocked on layout;
./mvnw spotless:apply license:format tidies both if you want to send a change
back.
Everything is under src/main/java/com/callibrity/cryptoagility/.
| Package | What is there |
|---|---|
envelope |
The Envelope, which can seal data under a KMS key, open itself, and rewrap itself onto another key |
kms |
KeyManagementService, shaped like AWS KMS or Google Cloud KMS, and LocalKms, a stand-in that keeps its keys in files |
wrapper |
The key-wrapping seam (KeyWrapper) and one class per algorithm: RsaWrapper and EcdhWrapper (classical), AesGcmWrapper (symmetric, as a cloud KMS does it), MlKemWrapper and HqcWrapper (post-quantum), and HybridWrapper |
cipher |
The data-encryption seam (DataCipher) and its two implementations, AesGcm and ChaCha20Poly1305 |
store |
Envelopes kept as small text files under data/envelopes |
cbom |
CycloneDx: the slice of the CycloneDX 1.6 format a cryptographic bill of materials needs, as records |
command |
One class per command, the Workspace they share, and the show drawing. Commands write to a PrintWriter they are given |
demo |
The entry point (Demo), the command dispatcher (Shell), the interactive prompt (Repl), and the scripted player (ScriptedRepl) |
Swap the implementation, never the call sites.
public interface KeyWrapper {
String algId();
WrappedDek wrap(SecretKey dek, byte[] aad) throws GeneralSecurityException;
SecretKey unwrap(WrappedDek w, byte[] aad) throws GeneralSecurityException;
}Each wrapper lives inside the KMS: LocalKms uses them as its engine, the way a
real KMS has an implementation per key type. Supporting a new algorithm is one
more KeyWrapper class and one register line in LocalKms.
There are two seams, one per layer. The wrap is chosen by naming a KMS key; its
algorithm belongs to the key, so the application never sees it. The data cipher
is chosen by passing a DataCipher, because the data is encrypted here, in the
application. Neither is named where data is sealed: both come from
configuration, and the envelope records both.
This is Envelope.seal, the one place data gets encrypted:
String alg = kms.describeKey(keyId).algorithm();
// The only line that involves the KEK. The data key is bound to the header it will be stored
// under.
DataKey dataKey =
kms.generateDataKey(keyId, cipher.keySpec(), encryptionContext(alg, keyId, cipher.algId()));
// The data never goes to the KMS.
Encrypted encrypted = cipher.encrypt(dataKey.key(), plaintext);
// And the envelope says what it used.
return new Envelope(
alg, keyId, cipher.algId(), encrypted.nonce(), encrypted.ciphertext(), dataKey.wrapped());The application never holds a KEK. It names a KMS key and gets back a fresh data
key twice over: in plaintext to encrypt with once, and wrapped to store. That is
how GenerateDataKey works in AWS KMS.
The header the envelope will carry (alg, kid and enc) goes in as the
encryption context, so the wrapped data key only unwraps under that same header.
Change any of it in a stored envelope and the read fails at the KMS, before the
data is touched. The aad in the seam is how each wrapper binds it: AES-GCM
associated data, or the OAEP label for RSA.
The envelope's three header fields borrow their names from JWE (JSON Web
Encryption): alg for how the data key is wrapped, kid for which key unwraps
it, and enc for how the data is encrypted.
WrappedDek is a set of named byte fields that the wrapper chooses. RSA stores
one, the KEM-based wrappers three, the hybrid four. The envelope, the store and
the commands carry them without knowing what they mean, so a new algorithm can
store whatever it needs.
The data itself is encrypted with AES-256-GCM unless you use ChaCha20-Poly1305.
That is simply what this repo uses for data keys. Neither cipher is threatened by
quantum computers, but each envelope still names its data cipher, so changing it
would be as easy as changing the wrap.
The envelope format is homegrown, and LocalKms keeps its private keys
unencrypted in data/kms/ so you can look at them. A real KMS keeps keys in
hardware and never hands them out. HybridWrapper shows the shape of a hybrid
but is not a reviewed construction. Do not hand-roll any of this in production.
Use a real KMS with Tink or the AWS Encryption SDK, JWE when the envelope is a
token crossing a boundary, or CMS when a standard requires it. All of them follow
the same skeleton as this demo, and all of them write the algorithm into the
envelope.
There are three library dependencies. BouncyCastle supplies HQC, which the JDK does not have yet; every other algorithm is standard JDK. Jackson writes the CBOM as JSON, and JLine provides the prompt's line editing.
docs/exploring.md has guided experiments with this code.
Fixes, clearer explanations and new algorithms are welcome. See CONTRIBUTING.md.
Apache License 2.0. See LICENSE.