Skip to content

feat(sdk): DSPX-3383 add pure ML-KEM-768 and ML-KEM-1024 key wrapping#373

Open
sujankota wants to merge 1 commit into
mainfrom
DSPX-3383-pure-mlkem
Open

feat(sdk): DSPX-3383 add pure ML-KEM-768 and ML-KEM-1024 key wrapping#373
sujankota wants to merge 1 commit into
mainfrom
DSPX-3383-pure-mlkem

Conversation

@sujankota
Copy link
Copy Markdown
Contributor

@sujankota sujankota commented May 29, 2026

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

    • Added support for ML-KEM (FIPS 203) post-quantum cryptography with ML-KEM-768 and ML-KEM-1024 key types.
    • SDK now supports DEK wrapping using ML-KEM algorithms.
  • Documentation

    • Added guide for end-to-end testing of ML-KEM workflows with the Java SDK.
  • Tests

    • Added comprehensive unit and integration tests for ML-KEM keypair generation, serialization, and DEK wrapping/unwrapping operations.
    • Added automated test script for validating ML-KEM encryption and decryption round-trips.

Review Change Stack

@sujankota sujankota requested review from a team as code owners May 29, 2026 16:57
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

📝 Walkthrough

Walkthrough

This 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.

Changes

ML-KEM Post-Quantum Cryptography Support

Layer / File(s) Summary
ML-KEM Type Contract
sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
KeyType enum extended with MLKEM768Key and MLKEM1024Key variants; new isMLKEM() method identifies post-quantum key types.
Maven Dependency Setup
sdk/pom.xml
Bouncy Castle provider dependency (bcprov-jdk18on) added to non-fips profile to enable ML-KEM cryptographic operations via JCA KEM API.
ML-KEM Cryptographic Implementation
sdk/src/main/java/io/opentdf/platform/sdk/MLKEMKeyPair.java
MLKEMKeyPair class handles ML-KEM-768/1024 key generation via BouncyCastle, PEM serialization/parsing, and DEK wrapping via ML-KEM encapsulation with HKDF-derived AES-GCM encryption.
TDF ML-KEM Integration
sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
TDF.createKeyAccess extended with ML-KEM branch that wraps the symmetric DEK using MLKEMKeyPair and stores the result as base64 in manifest wrappedKey.
ML-KEM Cryptography Unit Tests
sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java
Parameterized unit tests verify keypair generation, PEM round-tripping, DEK wrapping/unwrapping correctness, input validation, AES-GCM tamper detection, and key-type dispatch for both ML-KEM variants.
TDF ML-KEM Integration Tests
sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java
Integration tests validate producer-side manifest generation for ML-KEM key access, confirming wrapped key format, ephemeralPublicKey absence, and DEK unwrap round-trip.
E2E Testing Scripts and Documentation
scripts/test-mlkem.sh, scripts/README.md
Bash script automates round-trip ML-KEM encryption/decryption testing against local OpenTDF instance with configurable algorithms, optional KAS pre-flight, and manifest/payload validation. README documents prerequisites, configuration flags, troubleshooting, and known SDK mapping limitations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • opentdf/java-sdk#367: Earlier ML-KEM keypair/wrapping PR that introduced the Bouncy Castle provider dependency refactoring in the non-fips profile.

Suggested reviewers

  • marythought
  • jentfoo

🐰 Post-quantum dreams in Java bloom,
ML-KEM wrapped keys banish future gloom,
From BouncyCastle springs the KEMing art,
AES-GCM guards the DEK's secret heart,
Tests and scripts show the way so clear.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.30% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: adding ML-KEM-768 and ML-KEM-1024 key wrapping support to the SDK, which is evident from the changeset contents.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch DSPX-3383-pure-mlkem

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +58
/** 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
/** 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();

Comment on lines +105 to +108
MLKEMKeyPair generate() {
SecureRandom random = new SecureRandom();
MLKEMKeyPairGenerator gen = new MLKEMKeyPairGenerator();
gen.init(new MLKEMKeyGenerationParameters(random, mlkemParams));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reuse the shared SECURE_RANDOM instance instead of instantiating a new one on every key generation.

Suggested change
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));

Comment on lines +144 to +147
byte[] wrapDEK(byte[] rawPub, byte[] dek) {
if (rawPub.length != publicKeySize) {
throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
        }

Comment on lines +148 to +149
MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub);
SecretWithEncapsulation enc = new MLKEMGenerator(new SecureRandom()).generateEncapsulated(pub);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reuse the shared SECURE_RANDOM instance instead of instantiating a new one on every DEK wrapping operation.

Suggested change
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);

Comment on lines +168 to +172
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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) {

Comment on lines +194 to +201
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
        }

Comment thread scripts/test-mlkem.sh
##### 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://}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}"
host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}"; host="${host%/}"

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java (1)

114-116: ⚡ Quick win

Assert the recovered DEK actually decrypts the generated TDF.

MLKEMKeyPairTest already proves wrapDEK/unwrapDEK can round-trip a DEK, so this integration test should verify the TDF wiring. On Line 116, checking only .hasSize(32) would still pass if createTDF(...) 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

📥 Commits

Reviewing files that changed from the base of the PR and between 63b49af and 232a805.

📒 Files selected for processing (8)
  • scripts/README.md
  • scripts/test-mlkem.sh
  • sdk/pom.xml
  • sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
  • sdk/src/main/java/io/opentdf/platform/sdk/MLKEMKeyPair.java
  • sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
  • sdk/src/test/java/io/opentdf/platform/sdk/MLKEMKeyPairTest.java
  • sdk/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java

Comment thread scripts/test-mlkem.sh
Comment on lines +121 to +127
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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).

Comment on lines +194 to +207
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment on lines +27 to +28
private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes();
private static final int AES_GCM_OVERHEAD = 12 + 16; // 12-byte nonce + 16-byte tag
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
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.

Comment on lines +90 to +95
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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 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/java

Repository: 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 || true

Repository: 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 -ba

Repository: 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 -n

Repository: 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.

@github-actions
Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant