Skip to content

feat(sdk): support ML-KEM rewrap session keys - #388

Open
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4221-pq-sessions
Open

feat(sdk): support ML-KEM rewrap session keys#388
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4221-pq-sessions

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Part of DSPX-4221 (Session Keys should support ML-KEM). KASClient.unwrap() only branched EC vs RSA for the client's ephemeral rewrap "session key" (as opposed to a KAS-managed wrapping key), so requesting an ML-KEM session key would fail. KeyType already had MLKEM768Key/MLKEM1024Key entries and sdk-pqc-bc already had the BouncyCastle KEM primitives (used for the KAS-managed mechanism-key path), but nothing generated a fresh ML-KEM keypair for the session-key role.

Changes

  • spi/KemProvider: add generateKeyPair(KeyType), returning a new KeyPairPem (PEM-encoded SPKI/PKCS#8 pair). The core sdk module has no compile-time dependency on BouncyCastle (per ADR 0001), so the actual keypair generation lives in the optional sdk-pqc-bc module and is reached via the existing ServiceLoader-based KemProviders registry — same pattern already used for wrapDEK/unwrapDEK.
  • sdk-pqc-bc/HybridCrypto: add a generateKeyPair dispatch mirroring the existing wrapDEK/unwrapDEK switch, covering all five supported PQC key types (X-Wing, both NIST hybrids, ML-KEM-768/1024) so the SPI contract stays complete for every type in supportedKeyTypes().
  • sdk-pqc-bc/BouncyCastleKemProvider: implement the new SPI method.
  • KASClient.unwrap(): branch on sessionKeyType.isMLKEM(), generating a keypair via KemProviders.get(...).generateKeyPair(...) and decrypting the response via KemProviders.get(...).unwrapDEK(...).
  • New unit test HybridCryptoGenerateKeyPairTest round-trips generateKeyPair -> wrapDEK -> unwrapDEK for all five PQC key types.

cmdline's --rewrap-key-type already accepts mlkem:768/mlkem:1024 with no code changes needed: picocli's enum candidate list is derived from KeyType#toString(), which already returns "mlkem:768"/"mlkem:1024". Verified by building cmdline.jar and checking help decrypt output.

Test plan

mvn -pl sdk,sdk-pqc-bc -am test

All existing tests plus the new HybridCryptoGenerateKeyPairTest (5/5) pass. Also built cmdline.jar and confirmed --rewrap-key-type=mlkem:768 parses correctly (fails downstream only on the expected network/gRPC call, not on argument validation).

Related work

Companion PRs:

Ref: DSPX-4221

Summary by CodeRabbit

  • New Features

    • Added support for ML-KEM session key unwrapping.
    • Added key-pair generation for supported hybrid and ML-KEM algorithms.
    • Generated keys are available in PEM format for cryptographic workflows.
  • Bug Fixes

    • Improved end-to-end handling of ML-KEM keys, including validation and successful wrap/unwrap operations.

Part of DSPX-4221 (Session Keys should support ML-KEM). KASClient.unwrap()
only branched EC vs RSA for the client's ephemeral rewrap "session key",
so requesting an ML-KEM session key would fail. KeyType already had
MLKEM768Key/MLKEM1024Key entries and sdk-pqc-bc already had the
BouncyCastle KEM primitives (used for KAS-managed mechanism keys), but
nothing generated a fresh ML-KEM keypair for the session-key role.

- spi/KemProvider: add generateKeyPair(KeyType), returning a new
  KeyPairPem (PEM-encoded SPKI/PKCS#8 pair). The core sdk module has no
  compile-time dependency on BouncyCastle, so the actual keypair
  generation lives in the optional sdk-pqc-bc module and is reached via
  the existing ServiceLoader-based KemProviders registry.
- sdk-pqc-bc/HybridCrypto: add a generateKeyPair dispatch mirroring the
  existing wrapDEK/unwrapDEK switch, covering all five supported PQC key
  types (X-Wing, both NIST hybrids, ML-KEM-768/1024).
- sdk-pqc-bc/BouncyCastleKemProvider: implement the new SPI method.
- KASClient.unwrap(): branch on sessionKeyType.isMLKEM(), generating a
  keypair via KemProviders.get(...).generateKeyPair(...) and decrypting
  the response with KemProviders.get(...).unwrapDEK(...).
- New unit test (HybridCryptoGenerateKeyPairTest) round-trips
  generateKeyPair -> wrapDEK -> unwrapDEK for all five PQC key types.

cmdlines --rewrap-key-type already accepts mlkem:768/mlkem:1024 with no
changes needed: picoclis enum candidate list is derived from
KeyType#toString(), which already returns "mlkem:768"/"mlkem:1024".
Verified by building cmdline.jar and checking help decrypt output.

Ref: DSPX-4221

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru requested review from a team as code owners July 31, 2026 21:36
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds PEM key-pair generation to the KEM provider API and Bouncy Castle implementation. KASClient.unwrap uses generated ML-KEM keys during rewrap handling. Parameterized tests validate supported key types and DEK round trips.

Changes

KEM key generation and ML-KEM unwrap

Layer / File(s) Summary
KEM key-generation contract and implementation
sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProvider.java, sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/...
KemProvider now exposes PEM key-pair generation. HybridCrypto dispatches supported key types, and BouncyCastleKemProvider delegates to it.
ML-KEM unwrap integration
sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
KASClient.unwrap generates an ML-KEM key pair, sends the public key, checks the returned pair, and unwraps with the private key.
Key-generation round-trip validation
sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/HybridCryptoGenerateKeyPairTest.java
Parameterized tests cover five key types, PEM headers, and DEK wrap/unwrap equality.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • opentdf/java-sdk#368: Introduces the hybrid KEM provider extended by this key-generation and unwrap work.
  • opentdf/java-sdk#373: Adds related key-pair generation support for the KEM provider components.

Suggested reviewers: mkleene, sujankota

Poem

A rabbit makes a key pair bright,
PEM leaves public, private light.
ML-KEM hops through unwrap’s door,
DEKs return as they did before.
Five tests thump their paws: success!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: adding ML-KEM support for rewrap session keys in the SDK.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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-4221-pq-sessions

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 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 `@sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java`:
- Around line 126-133: Update the unwrap flow around the ML-KEM branch to keep
the generated public key in a local request-scoped variable instead of shared
clientPublicKey. Use that local variable when assigning body.clientPublicKey,
while preserving the existing EC behavior and each call’s private-key pairing.
🪄 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 Plus

Run ID: 9be24f35-9e29-4f8c-b225-82b889ac8cc4

📥 Commits

Reviewing files that changed from the base of the PR and between 57d070b and 08e8b8d.

📒 Files selected for processing (5)
  • sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java
  • sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java
  • sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/HybridCryptoGenerateKeyPairTest.java
  • sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
  • sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProvider.java

Comment on lines +126 to +133
KemProvider.KeyPairPem kemKeyPair = null;
if (sessionKeyType.isEc()) {
var curve = sessionKeyType.getECCurve();
ecKeyPair = new ECKeyPair(curve);
clientPublicKey = ecKeyPair.publicKeyInPEMFormat();
} else if (sessionKeyType.isMLKEM()) {
kemKeyPair = KemProviders.get(sessionKeyType).generateKeyPair(sessionKeyType);
clientPublicKey = kemKeyPair.publicKeyPEM;

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep the ML-KEM public key local to this unwrap call.

Line 133 writes the generated public PEM to shared clientPublicKey. Another concurrent unwrap call can overwrite this field before line 145 serializes the request. The first call then sends the second call's public key but unwraps with its own private key.

Store the request public key in a local variable and assign body.clientPublicKey from that variable.

Proposed fix
     public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) {
         ECKeyPair ecKeyPair = null;
         KemProvider.KeyPairPem kemKeyPair = null;
+        String requestClientPublicKey;
         if (sessionKeyType.isEc()) {
             var curve = sessionKeyType.getECCurve();
             ecKeyPair = new ECKeyPair(curve);
-            clientPublicKey = ecKeyPair.publicKeyInPEMFormat();
+            requestClientPublicKey = ecKeyPair.publicKeyInPEMFormat();
         } else if (sessionKeyType.isMLKEM()) {
             kemKeyPair = KemProviders.get(sessionKeyType).generateKeyPair(sessionKeyType);
-            clientPublicKey = kemKeyPair.publicKeyPEM;
+            requestClientPublicKey = kemKeyPair.publicKeyPEM;
         } else {
             // Existing RSA initialization
+            requestClientPublicKey = clientPublicKey;
         }
 
         RewrapRequestBody body = new RewrapRequestBody();
-        body.clientPublicKey = clientPublicKey;
+        body.clientPublicKey = requestClientPublicKey;
📝 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
KemProvider.KeyPairPem kemKeyPair = null;
if (sessionKeyType.isEc()) {
var curve = sessionKeyType.getECCurve();
ecKeyPair = new ECKeyPair(curve);
clientPublicKey = ecKeyPair.publicKeyInPEMFormat();
} else if (sessionKeyType.isMLKEM()) {
kemKeyPair = KemProviders.get(sessionKeyType).generateKeyPair(sessionKeyType);
clientPublicKey = kemKeyPair.publicKeyPEM;
public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) {
ECKeyPair ecKeyPair = null;
KemProvider.KeyPairPem kemKeyPair = null;
String requestClientPublicKey;
if (sessionKeyType.isEc()) {
var curve = sessionKeyType.getECCurve();
ecKeyPair = new ECKeyPair(curve);
requestClientPublicKey = ecKeyPair.publicKeyInPEMFormat();
} else if (sessionKeyType.isMLKEM()) {
kemKeyPair = KemProviders.get(sessionKeyType).generateKeyPair(sessionKeyType);
requestClientPublicKey = kemKeyPair.publicKeyPEM;
} else {
// Existing RSA initialization
requestClientPublicKey = clientPublicKey;
}
RewrapRequestBody body = new RewrapRequestBody();
body.clientPublicKey = requestClientPublicKey;
🤖 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/KASClient.java` around lines 126 -
133, Update the unwrap flow around the ML-KEM branch to keep the generated
public key in a local request-scoped variable instead of shared clientPublicKey.
Use that local variable when assigning body.clientPublicKey, while preserving
the existing EC behavior and each call’s private-key pairing.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
29.4% Coverage on New Code (required ≥ 33%)

See analysis details on SonarQube Cloud

@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