feat(sdk): DSPX-3309 add hybrid post-quantum key wrapping for KAS (X-Wing, ECDH+ML-KEM)#368
feat(sdk): DSPX-3309 add hybrid post-quantum key wrapping for KAS (X-Wing, ECDH+ML-KEM)#368sujankota wants to merge 1 commit into
Conversation
…Wing, ECDH+ML-KEM)
📝 WalkthroughWalkthroughThis PR adds hybrid post-quantum cryptography support to the Java TDF SDK by introducing X-Wing and NIST ECDH+ML-KEM key wrapping schemes. It includes implementations for both key schemes, shared envelope marshaling and key derivation utilities, integration into TDF manifest creation, and comprehensive unit and integration test coverage. ChangesHybrid Post-Quantum Cryptography
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 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 introduces support for hybrid post-quantum key wrapping, specifically X-Wing (X25519 + ML-KEM-768) and NIST hybrid schemes (P-256/P-384 + ML-KEM). It adds new classes for cryptographic operations and ASN.1 envelope management, updates the KeyType enum, and integrates these capabilities into the TDF creation process. Feedback recommends specifying the UTF-8 character set when converting strings to bytes and suggests explicitly referencing the BouncyCastle provider in cryptographic calls to ensure platform consistency and avoid provider ambiguity.
| static byte[] defaultTDFSalt() { | ||
| try { | ||
| MessageDigest d = MessageDigest.getInstance("SHA-256"); | ||
| d.update("TDF".getBytes()); |
There was a problem hiding this comment.
| PublicKey peerPub = kf.generatePublic(peerSpec); | ||
| PrivateKey myPriv = kf.generatePrivate(mySpec); | ||
|
|
||
| KeyAgreement ka = KeyAgreement.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME); |
There was a problem hiding this comment.
When using KeyAgreement.getInstance, it is generally safer to use the provider instance directly if it has already been loaded, or ensure that the provider name is correctly referenced. While "BC" is standard for BouncyCastle, consider if the provider should be explicitly passed to avoid ambiguity in environments with multiple providers.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java (1)
12-13: ⚡ Quick winClear the derived secrets after wrap/unwrap.
sharedSecretandwrapKeystay live until GC on both paths. In the wrapping primitive itself, this is worth clearing in afinallyblock once AES-GCM completes.💡 Suggested fix
import java.security.SecureRandom; +import java.util.Arrays; @@ - SecretWithEncapsulation enc = new XWingKEMGenerator(new SecureRandom()).generateEncapsulated(pub); - byte[] sharedSecret = enc.getSecret(); - byte[] ciphertext = enc.getEncapsulation(); - - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); - byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); - return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek); + SecretWithEncapsulation enc = new XWingKEMGenerator(new SecureRandom()).generateEncapsulated(pub); + byte[] sharedSecret = enc.getSecret(); + byte[] ciphertext = enc.getEncapsulation(); + byte[] wrapKey = null; + try { + wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); + return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek); + } finally { + Arrays.fill(sharedSecret, (byte) 0); + if (wrapKey != null) { + Arrays.fill(wrapKey, (byte) 0); + } + } @@ - byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext); - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); - return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext); + byte[] wrapKey = null; + try { + wrapKey = HybridCrypto.deriveWrapKey(sharedSecret, null, null); + return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + } finally { + Arrays.fill(sharedSecret, (byte) 0); + if (wrapKey != null) { + Arrays.fill(wrapKey, (byte) 0); + } + }Also applies to: 67-73, 87-90
🤖 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/XWingKeyPair.java` around lines 12 - 13, In XWingKeyPair.java ensure derived secrets are explicitly cleared after use: locate the methods that perform wrapping/unwrapping (e.g., the wrap/unwrap primitives around sharedSecret and wrapKey) and add try { ... } finally { Arrays.fill(sharedSecret, (byte)0); Arrays.fill(wrapKey, (byte)0); sharedSecret = null; wrapKey = null; } (or equivalent) so both success and exception paths wipe and null out the byte[] secrets; apply the same pattern to the other occurrences noted around the blocks at the other wrap/unwrap usages (lines referenced 67-73 and 87-90).sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java (1)
203-207: ⚡ Quick winZero the hybrid shared secrets on both paths.
ecdhSecret,mlSecret,combinedSecret, andwrapKeyall remain in heap memory after the DEK is processed. Given this sits at the core crypto boundary, clear them infinallyblocks.💡 Suggested fix
- byte[] combinedSecret = concat(ecdhSecret, mlSecret); - byte[] hybridCt = concat(ephemeralEcPub, mlCiphertext); - byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); - byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); - return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek); + byte[] combinedSecret = null; + byte[] wrapKey = null; + try { + combinedSecret = concat(ecdhSecret, mlSecret); + byte[] hybridCt = concat(ephemeralEcPub, mlCiphertext); + wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); + return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek); + } finally { + Arrays.fill(ecdhSecret, (byte) 0); + Arrays.fill(mlSecret, (byte) 0); + if (combinedSecret != null) { + Arrays.fill(combinedSecret, (byte) 0); + } + if (wrapKey != null) { + Arrays.fill(wrapKey, (byte) 0); + } + } @@ - byte[] combinedSecret = concat(ecdhSecret, mlSecret); - byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); - return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + byte[] combinedSecret = null; + byte[] wrapKey = null; + try { + combinedSecret = concat(ecdhSecret, mlSecret); + wrapKey = HybridCrypto.deriveWrapKey(combinedSecret, null, null); + return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); + } finally { + Arrays.fill(ecdhSecret, (byte) 0); + Arrays.fill(mlSecret, (byte) 0); + if (combinedSecret != null) { + Arrays.fill(combinedSecret, (byte) 0); + } + if (wrapKey != null) { + Arrays.fill(wrapKey, (byte) 0); + } + }Also applies to: 231-235
🤖 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/HybridNISTKeyPair.java` around lines 203 - 207, The shared-secret bytes (ecdhSecret, mlSecret, combinedSecret, wrapKey) in HybridNISTKeyPair must be zeroed after use; wrap the encryption and the corresponding decryption path (the block around deriveWrapKey/encrypt and the block referenced at 231-235) in try/finally so that in each finally you overwrite each secret byte[] (e.g., Arrays.fill(..., (byte)0) or equivalent) and null out references to avoid lingering heap data; ensure you zero ecdhSecret, mlSecret, combinedSecret, and wrapKey regardless of success or exception and do so in the same methods that call HybridCrypto.deriveWrapKey, AesGcm.encrypt, and HybridCrypto.unmarshal/unwrap to guarantee cleanup.
🤖 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/HybridCrypto.java`:
- Around line 74-100: The ASN.1 parsing can throw IllegalArgumentException and
IllegalStateException (e.g., ASN1ParsingException) which currently escape
normalization; update the code to catch and wrap those in SDKException.
Specifically, in unmarshalEnvelope around the
ASN1InputStream/ASN1Sequence.getInstance calls add handlers for
IllegalArgumentException and IllegalStateException (or a multi-catch alongside
IOException) and rethrow new SDKException(..., e). Also update
readImplicitOctetString to guard the ASN1TaggedObject.getInstance and
ASN1OctetString.getInstance calls with a try/catch for
IllegalArgumentException/IllegalStateException and throw an SDKException with a
clear message; refer to the methods unmarshalEnvelope and
readImplicitOctetString and the BouncyCastle calls ASN1Sequence.getInstance,
ASN1TaggedObject.getInstance, and ASN1OctetString.getInstance when locating
changes.
In `@sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java`:
- Around line 18-20: Add the three new hybrid enum constants to both factory
switch statements so fromAlgorithm(...) and fromPublicKeyAlgorithm(...) return
the correct KeyType for HybridXWingKey, HybridSecp256r1MLKEM768Key, and
HybridSecp384r1MLKEM1024Key; locate the switch in the KeyType enum's
fromAlgorithm(...) method and the switch in fromPublicKeyAlgorithm(...) and add
matching case entries that map the corresponding protobuf algorithm enum values
to these KeyType constants to avoid IllegalArgumentException when those protobuf
values are encountered.
---
Nitpick comments:
In `@sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java`:
- Around line 203-207: The shared-secret bytes (ecdhSecret, mlSecret,
combinedSecret, wrapKey) in HybridNISTKeyPair must be zeroed after use; wrap the
encryption and the corresponding decryption path (the block around
deriveWrapKey/encrypt and the block referenced at 231-235) in try/finally so
that in each finally you overwrite each secret byte[] (e.g., Arrays.fill(...,
(byte)0) or equivalent) and null out references to avoid lingering heap data;
ensure you zero ecdhSecret, mlSecret, combinedSecret, and wrapKey regardless of
success or exception and do so in the same methods that call
HybridCrypto.deriveWrapKey, AesGcm.encrypt, and HybridCrypto.unmarshal/unwrap to
guarantee cleanup.
In `@sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java`:
- Around line 12-13: In XWingKeyPair.java ensure derived secrets are explicitly
cleared after use: locate the methods that perform wrapping/unwrapping (e.g.,
the wrap/unwrap primitives around sharedSecret and wrapKey) and add try { ... }
finally { Arrays.fill(sharedSecret, (byte)0); Arrays.fill(wrapKey, (byte)0);
sharedSecret = null; wrapKey = null; } (or equivalent) so both success and
exception paths wipe and null out the byte[] secrets; apply the same pattern to
the other occurrences noted around the blocks at the other wrap/unwrap usages
(lines referenced 67-73 and 87-90).
🪄 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: d2649e03-5d69-4cad-bbe5-02e23e6fc142
📒 Files selected for processing (7)
sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.javasdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.javasdk/src/main/java/io/opentdf/platform/sdk/KeyType.javasdk/src/main/java/io/opentdf/platform/sdk/TDF.javasdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.javasdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.javasdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
| static byte[][] unmarshalEnvelope(byte[] der) { | ||
| try (ASN1InputStream in = new ASN1InputStream(new ByteArrayInputStream(der))) { | ||
| ASN1Primitive prim = in.readObject(); | ||
| if (prim == null) { | ||
| throw new SDKException("hybrid wrapped key envelope is empty"); | ||
| } | ||
| if (in.readObject() != null) { | ||
| throw new SDKException("hybrid wrapped key envelope has trailing bytes"); | ||
| } | ||
| ASN1Sequence seq = ASN1Sequence.getInstance(prim); | ||
| if (seq.size() != 2) { | ||
| throw new SDKException("hybrid wrapped key envelope must have 2 elements, got " + seq.size()); | ||
| } | ||
| byte[] hybridCt = readImplicitOctetString(seq.getObjectAt(0), 0); | ||
| byte[] encDek = readImplicitOctetString(seq.getObjectAt(1), 1); | ||
| return new byte[][] { hybridCt, encDek }; | ||
| } catch (IOException e) { | ||
| throw new SDKException("failed to decode hybrid wrapped key envelope", e); | ||
| } | ||
| } | ||
|
|
||
| private static byte[] readImplicitOctetString(org.bouncycastle.asn1.ASN1Encodable enc, int expectedTag) { | ||
| ASN1TaggedObject tagged = ASN1TaggedObject.getInstance(enc); | ||
| if (tagged.getTagNo() != expectedTag) { | ||
| throw new SDKException("expected context tag " + expectedTag + " but got " + tagged.getTagNo()); | ||
| } | ||
| return org.bouncycastle.asn1.ASN1OctetString.getInstance(tagged, false).getOctets(); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Bouncy Castle ASN.1, what exceptions can ASN1Sequence.getInstance(Object)andASN1OctetString.getInstance(ASN1TaggedObject, boolean) throw when the ASN.1 input is the wrong type?
💡 Result:
In the Bouncy Castle ASN.1 library, both ASN1Sequence.getInstance(Object) and ASN1OctetString.getInstance(ASN1TaggedObject, boolean) are designed to throw a java.lang.IllegalArgumentException when the provided input cannot be converted to the expected type [1][2]. When these methods are called with an object that does not match the required ASN.1 structure (for example, passing a non-sequence object to ASN1Sequence.getInstance), the library explicitly throws an IllegalArgumentException [1][2]. This exception often includes a descriptive message, such as "unknown object in getInstance" or similar, indicating that the input type is incompatible with the requested ASN.1 class [3][4]. While IllegalArgumentException is the standard exception for type mismatches in these factory methods, other ASN.1-related exceptions may occur during broader parsing operations: 1. ASN1ParsingException: This is a runtime exception (extending IllegalStateException) thrown when the ASN.1 data is correctly encoded but contains unexpected data while building an object [5][6]. 2. ASN1Exception: This is an IOException thrown when the library encounters corrupted or unexpected data within an ASN.1 stream [7][6]. In summary, for the specific getInstance methods mentioned, you should expect and handle java.lang.IllegalArgumentException when the input type is incorrect [1][2].
Citations:
- 1: http://docs.glngn.com/latest/api/org.bouncycastle.bcprov-jdk15on/org/bouncycastle/asn1/ASN1Sequence.html
- 2: https://javadoc.io/static/org.bouncycastle/bcprov-ext-jdk15on/1.49/org/bouncycastle/asn1/ASN1OctetString.html
- 3: IllegalArgumentException: unknown object in getInstance: org.bouncycastle.asn1.DERTaggedObject bcgit/bc-java#213
- 4: https://www.javatips.net/api/org.bouncycastle.asn1.asn1taggedobject
- 5: https://downloads.bouncycastle.org/java/docs/bcprov-jdk14-javadoc/org/bouncycastle/asn1/ASN1ParsingException.html
- 6: https://downloads.bouncycastle.org/java/docs/bcprov-jdk14-javadoc/org/bouncycastle/asn1/package-summary.html
- 7: https://downloads.bouncycastle.org/lts-java/docs/bccore-lts8on-2.73.5-javadoc/org/bouncycastle/asn1/ASN1Exception.html
Normalize all ASN.1 shape failures to SDKException.
The catch block only handles IOException, but Bouncy Castle's ASN.1 getInstance(...) methods throw IllegalArgumentException when the input type is incorrect, and ASN1ParsingException (extends IllegalStateException) when the ASN.1 is validly encoded but structurally unexpected. These exceptions will propagate uncaught from the parser, bypassing SDK exception normalization on untrusted envelope bytes.
Suggested fix
- } catch (IOException e) {
+ } catch (IOException | IllegalArgumentException | IllegalStateException e) {
throw new SDKException("failed to decode hybrid wrapped key envelope", e);
}🤖 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/HybridCrypto.java` around lines 74
- 100, The ASN.1 parsing can throw IllegalArgumentException and
IllegalStateException (e.g., ASN1ParsingException) which currently escape
normalization; update the code to catch and wrap those in SDKException.
Specifically, in unmarshalEnvelope around the
ASN1InputStream/ASN1Sequence.getInstance calls add handlers for
IllegalArgumentException and IllegalStateException (or a multi-catch alongside
IOException) and rethrow new SDKException(..., e). Also update
readImplicitOctetString to guard the ASN1TaggedObject.getInstance and
ASN1OctetString.getInstance calls with a try/catch for
IllegalArgumentException/IllegalStateException and throw an SDKException with a
clear message; refer to the methods unmarshalEnvelope and
readImplicitOctetString and the BouncyCastle calls ASN1Sequence.getInstance,
ASN1TaggedObject.getInstance, and ASN1OctetString.getInstance when locating
changes.
| HybridXWingKey("hpqt:xwing"), | ||
| HybridSecp256r1MLKEM768Key("hpqt:secp256r1-mlkem768"), | ||
| HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024"); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Hybrid algorithm enum values =="
rg -n --type java 'ALGORITHM_.*(XWING|MLKEM)|KAS_PUBLIC_KEY_ALG_ENUM_.*(XWING|MLKEM)'
echo
echo "== Call sites of KeyType.fromAlgorithm / fromPublicKeyAlgorithm =="
rg -n --type java '\bKeyType\.(fromAlgorithm|fromPublicKeyAlgorithm)\s*\('Repository: opentdf/java-sdk
Length of output: 95
🏁 Script executed:
cat -n sdk/src/main/java/io/opentdf/platform/sdk/KeyType.javaRepository: opentdf/java-sdk
Length of output: 4297
🏁 Script executed:
fd -e proto | head -20Repository: opentdf/java-sdk
Length of output: 42
🏁 Script executed:
rg -l "enum Algorithm|class Algorithm" --type java | head -10Repository: opentdf/java-sdk
Length of output: 42
🏁 Script executed:
rg "import io.opentdf.platform.policy.Algorithm|import io.opentdf.platform.policy.KasPublicKeyAlgEnum" --type java -l | head -10Repository: opentdf/java-sdk
Length of output: 410
🏁 Script executed:
rg "fromAlgorithm|fromPublicKeyAlgorithm" --type java -B 2 -A 2Repository: opentdf/java-sdk
Length of output: 5904
🏁 Script executed:
find . -name "*.proto" | xargs cat | grep -A 30 "enum Algorithm\|enum KasPublicKeyAlgEnum" | head -80Repository: opentdf/java-sdk
Length of output: 42
🏁 Script executed:
rg "enum ALGORITHM_|enum KAS_PUBLIC_KEY_ALG_ENUM_" --type java -A 5 | head -50Repository: opentdf/java-sdk
Length of output: 42
🏁 Script executed:
rg "ALGORITHM_|KAS_PUBLIC_KEY_ALG_ENUM_" --type java | grep -E "(case|=)" | head -30Repository: opentdf/java-sdk
Length of output: 1511
🏁 Script executed:
find . -path ./build -prune -o -type f -name "*.java" -exec grep -l "ALGORITHM_.*XWING\|ALGORITHM_.*MLKEM\|KAS_PUBLIC_KEY_ALG_ENUM.*XWING\|KAS_PUBLIC_KEY_ALG_ENUM.*MLKEM" {} \;Repository: opentdf/java-sdk
Length of output: 42
Add hybrid key type support to fromAlgorithm() and fromPublicKeyAlgorithm() factory methods to maintain API completeness.
The three new hybrid enum constants (HybridXWingKey, HybridSecp256r1MLKEM768Key, HybridSecp384r1MLKEM1024Key) are missing from both factory method switches. These methods are actively called in Config.java and Autoconfigure.java to map from protobuf enums to KeyType. When the corresponding protobuf enum values are added, any code path using those typed conversions will fail with IllegalArgumentException. Add matching cases to both switches to ensure complete coverage per the coding guideline to "keep public SDK APIs stable and additive where possible."
🤖 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/KeyType.java` around lines 18 - 20,
Add the three new hybrid enum constants to both factory switch statements so
fromAlgorithm(...) and fromPublicKeyAlgorithm(...) return the correct KeyType
for HybridXWingKey, HybridSecp256r1MLKEM768Key, and HybridSecp384r1MLKEM1024Key;
locate the switch in the KeyType enum's fromAlgorithm(...) method and the switch
in fromPublicKeyAlgorithm(...) and add matching case entries that map the
corresponding protobuf algorithm enum values to these KeyType constants to avoid
IllegalArgumentException when those protobuf values are encountered.




Add hybrid post-quantum key wrapping to the Java SDK so TDFs can be protected against "harvest now, decrypt later" attacks while preserving classical security
guarantees during the PQC transition.
Introduces three new KeyType values backed by hybrid KEMs:
When a KAS advertises one of these algorithms, TDF.upsertAndGetNewKeyAccess routes through HybridCrypto.wrapDEK, which performs both a classical ECDH/X25519 key
agreement and an ML-KEM encapsulation, combines the two shared secrets, and uses the result to wrap the DEK. A new hybrid-wrapped key-access type is emitted; the
ephemeral classical public key and ML-KEM ciphertext are packaged together inside an ASN.1 envelope stored in wrappedKey (rather than the separate ephemeralPublicKey
field used for EC-wrapped keys).
New supporting classes: HybridCrypto, HybridNISTKeyPair, XWingKeyPair, plus unit and TDF round-trip tests.
Summary by CodeRabbit