feat(sdk): DSPX-3383 add pure ML-KEM-768 and ML-KEM-1024 key wrapping#373
feat(sdk): DSPX-3383 add pure ML-KEM-768 and ML-KEM-1024 key wrapping#373sujankota wants to merge 1 commit into
Conversation
📝 WalkthroughWalkthroughThis PR adds ML-KEM (FIPS 203) post-quantum cryptography support to the OpenTDF Java SDK. The implementation introduces ML-KEM key types, a cryptographic keypair handler that wraps DEKs via ML-KEM encapsulation and AES-GCM encryption, integration into the TDF key-access creation pipeline, comprehensive unit and integration test coverage, and developer-facing end-to-end testing scripts with documentation. ChangesML-KEM Post-Quantum Cryptography Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request adds pure ML-KEM (FIPS 203) post-quantum key wrapping support for mlkem:768 and mlkem:1024 to the Java SDK, integrating it into the TDF creation flow and adding end-to-end testing scripts. The review feedback recommends optimizing performance by reusing a single static SecureRandom instance, adding defensive null checks in MLKEMKeyPair to prevent potential NullPointerExceptions, and robustly handling trailing slashes in the test script's platform endpoint parsing.
| /** FIPS 203 seed (d || z) — same 64 bytes for both 768 and 1024. */ | ||
| static final int SEED_SIZE = 64; | ||
| static final int SHARED_SECRET_SIZE = 32; |
There was a problem hiding this comment.
Instantiating SecureRandom repeatedly can be computationally expensive and may lead to resource exhaustion or performance bottlenecks. It is highly recommended to define a single, shared static SecureRandom instance and reuse it across key generation and encapsulation operations.
| /** FIPS 203 seed (d || z) — same 64 bytes for both 768 and 1024. */ | |
| static final int SEED_SIZE = 64; | |
| static final int SHARED_SECRET_SIZE = 32; | |
| /** FIPS 203 seed (d || z) — same 64 bytes for both 768 and 1024. */ | |
| static final int SEED_SIZE = 64; | |
| static final int SHARED_SECRET_SIZE = 32; | |
| private static final SecureRandom SECURE_RANDOM = new SecureRandom(); |
| MLKEMKeyPair generate() { | ||
| SecureRandom random = new SecureRandom(); | ||
| MLKEMKeyPairGenerator gen = new MLKEMKeyPairGenerator(); | ||
| gen.init(new MLKEMKeyGenerationParameters(random, mlkemParams)); |
There was a problem hiding this comment.
Reuse the shared SECURE_RANDOM instance instead of instantiating a new one on every key generation.
| MLKEMKeyPair generate() { | |
| SecureRandom random = new SecureRandom(); | |
| MLKEMKeyPairGenerator gen = new MLKEMKeyPairGenerator(); | |
| gen.init(new MLKEMKeyGenerationParameters(random, mlkemParams)); | |
| MLKEMKeyPair generate() { | |
| MLKEMKeyPairGenerator gen = new MLKEMKeyPairGenerator(); | |
| gen.init(new MLKEMKeyGenerationParameters(SECURE_RANDOM, mlkemParams)); |
| byte[] wrapDEK(byte[] rawPub, byte[] dek) { | ||
| if (rawPub.length != publicKeySize) { | ||
| throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize); | ||
| } |
There was a problem hiding this comment.
To prevent potential NullPointerExceptions, add defensive null checks for rawPub and dek before accessing their properties or passing them to other methods.
byte[] wrapDEK(byte[] rawPub, byte[] dek) {
if (rawPub == null) {
throw new SDKException("rawPub cannot be null");
}
if (dek == null) {
throw new SDKException("dek cannot be null");
}
if (rawPub.length != publicKeySize) {
throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize);
}| MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub); | ||
| SecretWithEncapsulation enc = new MLKEMGenerator(new SecureRandom()).generateEncapsulated(pub); |
There was a problem hiding this comment.
Reuse the shared SECURE_RANDOM instance instead of instantiating a new one on every DEK wrapping operation.
| MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub); | |
| SecretWithEncapsulation enc = new MLKEMGenerator(new SecureRandom()).generateEncapsulated(pub); | |
| MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub); | |
| SecretWithEncapsulation enc = new MLKEMGenerator(SECURE_RANDOM).generateEncapsulated(pub); |
| byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedBlob) { | ||
| if (rawPriv.length != SEED_SIZE) { | ||
| throw new SDKException("invalid " + keyType + " private key seed size: got " + rawPriv.length + " want " + SEED_SIZE); | ||
| } | ||
| if (wrappedBlob.length <= ciphertextSize) { |
There was a problem hiding this comment.
To prevent potential NullPointerExceptions, add defensive null checks for rawPriv and wrappedBlob before accessing their properties.
byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedBlob) {
if (rawPriv == null) {
throw new SDKException("rawPriv cannot be null");
}
if (wrappedBlob == null) {
throw new SDKException("wrappedBlob cannot be null");
}
if (rawPriv.length != SEED_SIZE) {
throw new SDKException("invalid " + keyType + " private key seed size: got " + rawPriv.length + " want " + SEED_SIZE);
}
if (wrappedBlob.length <= ciphertextSize) {| private static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) { | ||
| String header = "-----BEGIN " + expectedType + "-----"; | ||
| String footer = "-----END " + expectedType + "-----"; | ||
| int headerIdx = pem.indexOf(header); | ||
| int footerIdx = pem.indexOf(footer); | ||
| if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) { | ||
| throw new SDKException("failed to parse PEM formatted " + expectedType); | ||
| } |
There was a problem hiding this comment.
To prevent potential NullPointerExceptions, add a defensive null check for pem before calling pem.indexOf().
private static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) {
if (pem == null) {
throw new SDKException("PEM content is null for " + expectedType);
}
String header = "-----BEGIN " + expectedType + "-----";
String footer = "-----END " + expectedType + "-----";
int headerIdx = pem.indexOf(header);
int footerIdx = pem.indexOf(footer);
if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) {
throw new SDKException("failed to parse PEM formatted " + expectedType);
}| ##### 2. Pre-flight: confirm KAS publishes ML-KEM keys | ||
| if [[ $SKIP_KAS_CHECK -eq 0 ]] && command -v grpcurl >/dev/null 2>&1; then | ||
| info "Pre-flight: querying KAS for ML-KEM public keys" | ||
| host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" |
There was a problem hiding this comment.
If PLATFORM_ENDPOINT contains a trailing slash (e.g., http://localhost:8080/), the parsed host will retain the trailing slash, which can cause grpcurl to fail. It is safer to strip any trailing slash.
| host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" | |
| host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}"; host="${host%/}" |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java (1)
114-116: ⚡ Quick winAssert the recovered DEK actually decrypts the generated TDF.
MLKEMKeyPairTestalready proveswrapDEK/unwrapDEKcan round-trip a DEK, so this integration test should verify the TDF wiring. On Line 116, checking only.hasSize(32)would still pass ifcreateTDF(...)wrapped the wrong 32-byte buffer. Load the generated TDF with a local unwrap stub and assert the plaintext matches the original payload instead.As per coding guidelines, "sdk/src/test/java/**/*.java: Prefer focused unit tests over integration tests; keep network calls mocked (e.g., using MockWebServer)".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java` around lines 114 - 116, The test currently only asserts the unwrapped DEK byte length; instead, after obtaining symKey from alg.unwrapDEK(privSeed, wrappedBytes) in TDFMLKEMTest, use the same local TDF load/decryption path (i.e., call createTDF(...) output loader or a local unwrap/decrypt helper) to decrypt the generated TDF payload with symKey and assert the resulting plaintext equals the original payload bytes; reference the createTDF(...) call that produced the TDF, the alg.unwrapDEK(...) call that yields symKey, and the wrappedBytes/privSeed values to wire the decryption and final equality assertion so the test verifies end-to-end TDF decryption rather than just key size.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/test-mlkem.sh`:
- Around line 121-127: The grpcurl pre-flight is using PLATFORM_ENDPOINT to
build host but the script exposes --kas-url; change the host extraction to use
KAS_URL instead of PLATFORM_ENDPOINT (i.e., replace
host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://"} with host derived
from KAS_URL), so the grpcurl probe that runs in the loop (the resp=$(grpcurl
... kas.AccessService/PublicKey ...) invocation) targets the actual KAS service;
no other logic changes needed (keep the http/https stripping and the
alg_to_string usage).
In `@sdk/src/main/java/io/opentdf/platform/sdk/MLKEMKeyPair.java`:
- Around line 194-207: The method decodeSizedPemBlock currently dereferences pem
without checking for null; add an explicit null-check at the start of
decodeSizedPemBlock(String pem, String expectedType, int expectedSize) and throw
an SDKException (consistent with other parse failures) when pem is null (include
the expectedType in the message for context). This prevents a
NullPointerException and keeps error handling uniform before continuing with
header/footer parsing and base64 decoding.
In `@sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java`:
- Around line 27-28: The DEK test vector uses String.getBytes() which depends on
the JVM default charset; update the DEK constant in MLKEMKeyPairTest to call
getBytes with an explicit Charset (e.g.,
"0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII) or
StandardCharsets.UTF_8) and add the necessary import for StandardCharsets so the
test becomes portable across JVMs.
- Around line 90-95: The test tamperedCiphertextFailsAesGcmTag currently asserts
any Exception which is too broad; change it to assert the specific AES-GCM auth
failure by asserting a RuntimeException is thrown from alg.unwrapDEK(...) and
then assert that that RuntimeException's cause is a
javax.crypto.BadPaddingException (or the specific cause type produced by
AesGcm.decrypt). Locate the test method tamperedCiphertextFailsAesGcmTag in
MLKEMKeyPairTest, replace assertThrows(Exception.class, ...) with capturing the
RuntimeException via assertThrows(RuntimeException.class, ...) and add an
assertion that the thrown.getCause() is an instance of
javax.crypto.BadPaddingException. Ensure the assertions reference
alg.unwrapDEK(kp.getPrivateKey(), wrapped) and the AesGcm decrypt failure cause.
---
Nitpick comments:
In `@sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java`:
- Around line 114-116: The test currently only asserts the unwrapped DEK byte
length; instead, after obtaining symKey from alg.unwrapDEK(privSeed,
wrappedBytes) in TDFMLKEMTest, use the same local TDF load/decryption path
(i.e., call createTDF(...) output loader or a local unwrap/decrypt helper) to
decrypt the generated TDF payload with symKey and assert the resulting plaintext
equals the original payload bytes; reference the createTDF(...) call that
produced the TDF, the alg.unwrapDEK(...) call that yields symKey, and the
wrappedBytes/privSeed values to wire the decryption and final equality assertion
so the test verifies end-to-end TDF decryption rather than just key size.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bdd21dc4-96e3-458d-adce-22fda003f3eb
📒 Files selected for processing (8)
scripts/README.mdscripts/test-mlkem.shsdk/pom.xmlsdk/src/main/java/io/opentdf/platform/sdk/KeyType.javasdk/src/main/java/io/opentdf/platform/sdk/MLKEMKeyPair.javasdk/src/main/java/io/opentdf/platform/sdk/TDF.javasdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.javasdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java
| host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" | ||
| for alg_name in "${ALGORITHMS[@]}"; do | ||
| if ! alg=$(alg_to_string "$alg_name"); then | ||
| fail "unknown algorithm: $alg_name"; exit 2 | ||
| fi | ||
| resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ | ||
| "$host" kas.AccessService/PublicKey 2>&1 || true) |
There was a problem hiding this comment.
Point the pre-flight check at KAS_URL, not PLATFORM_ENDPOINT.
The script exposes --kas-url, but the grpcurl probe still strips the host from PLATFORM_ENDPOINT. If the platform and KAS are on different endpoints, this will fail the pre-flight against the wrong service even though the subsequent encrypt call is configured correctly.
Suggested fix
- host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}"
+ host="${KAS_URL#http://}"; host="${host#https://}"; host="${host%/}"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" | |
| for alg_name in "${ALGORITHMS[@]}"; do | |
| if ! alg=$(alg_to_string "$alg_name"); then | |
| fail "unknown algorithm: $alg_name"; exit 2 | |
| fi | |
| resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ | |
| "$host" kas.AccessService/PublicKey 2>&1 || true) | |
| host="${KAS_URL#http://}"; host="${host#https://}"; host="${host%/}" | |
| for alg_name in "${ALGORITHMS[@]}"; do | |
| if ! alg=$(alg_to_string "$alg_name"); then | |
| fail "unknown algorithm: $alg_name"; exit 2 | |
| fi | |
| resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ | |
| "$host" kas.AccessService/PublicKey 2>&1 || true) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/test-mlkem.sh` around lines 121 - 127, The grpcurl pre-flight is
using PLATFORM_ENDPOINT to build host but the script exposes --kas-url; change
the host extraction to use KAS_URL instead of PLATFORM_ENDPOINT (i.e., replace
host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://"} with host derived
from KAS_URL), so the grpcurl probe that runs in the loop (the resp=$(grpcurl
... kas.AccessService/PublicKey ...) invocation) targets the actual KAS service;
no other logic changes needed (keep the http/https stripping and the
alg_to_string usage).
| private static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) { | ||
| String header = "-----BEGIN " + expectedType + "-----"; | ||
| String footer = "-----END " + expectedType + "-----"; | ||
| int headerIdx = pem.indexOf(header); | ||
| int footerIdx = pem.indexOf(footer); | ||
| if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) { | ||
| throw new SDKException("failed to parse PEM formatted " + expectedType); | ||
| } | ||
| String body = pem.substring(headerIdx + header.length(), footerIdx).replaceAll("\\s", ""); | ||
| byte[] raw; | ||
| try { | ||
| raw = Base64.getDecoder().decode(body); | ||
| } catch (IllegalArgumentException e) { | ||
| throw new SDKException("failed to base64-decode " + expectedType + " PEM body", e); |
There was a problem hiding this comment.
Reject null PEM input before parsing.
Line 197 dereferences pem unconditionally, so a null input throws NullPointerException instead of the SDKException this parser uses for other malformed PEM cases.
Proposed fix
private static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) {
+ if (pem == null) {
+ throw new SDKException("failed to parse PEM formatted " + expectedType);
+ }
String header = "-----BEGIN " + expectedType + "-----";
String footer = "-----END " + expectedType + "-----";
int headerIdx = pem.indexOf(header);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/main/java/io/opentdf/platform/sdk/MLKEMKeyPair.java` around lines 194
- 207, The method decodeSizedPemBlock currently dereferences pem without
checking for null; add an explicit null-check at the start of
decodeSizedPemBlock(String pem, String expectedType, int expectedSize) and throw
an SDKException (consistent with other parse failures) when pem is null (include
the expectedType in the message for context). This prevents a
NullPointerException and keeps error handling uniform before continuing with
header/footer parsing and base64 decoding.
| private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes(); | ||
| private static final int AES_GCM_OVERHEAD = 12 + 16; // 12-byte nonce + 16-byte tag |
There was a problem hiding this comment.
Use a fixed charset for the DEK test vector.
String.getBytes() depends on the JVM default charset, so this test vector is not guaranteed to be portable.
Proposed fix
+import java.nio.charset.StandardCharsets;
+
- private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes();
+ private static final byte[] DEK =
+ "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes(); | |
| private static final int AES_GCM_OVERHEAD = 12 + 16; // 12-byte nonce + 16-byte tag | |
| import java.nio.charset.StandardCharsets; | |
| private static final byte[] DEK = | |
| "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII); | |
| private static final int AES_GCM_OVERHEAD = 12 + 16; // 12-byte nonce + 16-byte tag |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java` around lines
27 - 28, The DEK test vector uses String.getBytes() which depends on the JVM
default charset; update the DEK constant in MLKEMKeyPairTest to call getBytes
with an explicit Charset (e.g.,
"0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII) or
StandardCharsets.UTF_8) and add the necessary import for StandardCharsets so the
test becomes portable across JVMs.
| void tamperedCiphertextFailsAesGcmTag(MLKEMKeyPair alg) { | ||
| MLKEMKeyPair kp = alg.generate(); | ||
| byte[] wrapped = alg.wrapDEK(kp.getPublicKey(), DEK); | ||
| // Flip a bit inside the AES-GCM-wrapped DEK section — must fail the tag check | ||
| wrapped[wrapped.length - 1] ^= 0x01; | ||
| assertThrows(Exception.class, () -> alg.unwrapDEK(kp.getPrivateKey(), wrapped)); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Inspecting unwrap/decrypt exception types..."
fd -i 'AesGcm.java' -x sed -n '1,240p' {}
fd -i 'SDKException.java' -x sed -n '1,200p' {}
echo
echo "Current broad exception assertions in ML-KEM tests:"
rg -n -C2 'assertThrows\(Exception\.class' sdk/src/test/javaRepository: opentdf/java-sdk
Length of output: 10028
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Finding MLKEMKeyPair.unwrapDEK implementation..."
fd -i 'MLKEMKeyPair.java' -x sed -n '1,240p' {}
rg -n --hidden --glob '!*target/*' 'unwrapDEK\s*\(' -S .
echo
echo "Finding AES-GCM decrypt usage inside MLKEMKeyPair (or related classes)..."
rg -n 'new AesGcm|AesGcm\.|decrypt\(' sdk/src/main/java/io/opentdf/platform/sdk -S || trueRepository: opentdf/java-sdk
Length of output: 12754
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,140p' sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java | nl -ba
echo
echo "Inspecting AesGcm.decrypt(Encrypted) exception mapping..."
sed -n '150,235p' sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java | nl -baRepository: opentdf/java-sdk
Length of output: 103
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "MLKEMKeyPairTest.java (lines ~1-140):"
sed -n '1,140p' sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java | cat -n
echo
echo "AesGcm.java (lines ~140-260):"
sed -n '140,260p' sdk/src/main/java/io/opentdf/platform/sdk/AesGcm.java | cat -nRepository: opentdf/java-sdk
Length of output: 9607
Tighten the tamper-test exception to the AES-GCM auth failure (not any Throwable).
assertThrows(Exception.class, ...) can pass for unrelated runtime failures; for this path AesGcm.decrypt(Encrypted) throws a RuntimeException("error gcm decrypt", e) where e should be a javax.crypto.BadPaddingException. Assert that specific type/cause instead of Exception.class.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java` around lines
90 - 95, The test tamperedCiphertextFailsAesGcmTag currently asserts any
Exception which is too broad; change it to assert the specific AES-GCM auth
failure by asserting a RuntimeException is thrown from alg.unwrapDEK(...) and
then assert that that RuntimeException's cause is a
javax.crypto.BadPaddingException (or the specific cause type produced by
AesGcm.decrypt). Locate the test method tamperedCiphertextFailsAesGcmTag in
MLKEMKeyPairTest, replace assertThrows(Exception.class, ...) with capturing the
RuntimeException via assertThrows(RuntimeException.class, ...) and add an
assertion that the thrown.getCause() is an instance of
javax.crypto.BadPaddingException. Ensure the assertions reference
alg.unwrapDEK(kp.getPrivateKey(), wrapped) and the AesGcm decrypt failure cause.
Adds two new KeyType values, MLKEM768Key (mlkem:768) and MLKEM1024Key
(mlkem:1024), backed by BouncyCastle's FIPS 203 ML-KEM primitives.
Wire format (matches opentdf/platform PR 3491 server-side):
wrappedKey = base64( mlkem_ciphertext || AES-GCM(nonce(12) || DEK || tag(16)) )
AES wrap key = HKDF-SHA256(ikm=mlkem_shared_secret,
salt=SHA-256("TDF"), info=∅, L=32)
keyAccess.type = "wrapped" (reuses RSA slot; KAS disambiguates by
registered key algorithm)
ephemeralPublicKey absent
New supporting class MLKEMKeyPair (parameterised across 768/1024),
parameterised unit tests covering both variants + 4 negative cases,
TDF-level manifest test, and scripts/test-mlkem.sh for local-KAS e2e
round-trip (mlkem:768 only by default; Go KAS doesn't yet support 1024).
Summary by CodeRabbit
Release Notes
New Features
Documentation
Tests