diff --git a/cmdline/pom.xml b/cmdline/pom.xml
index 3f82579a..dc575d21 100644
--- a/cmdline/pom.xml
+++ b/cmdline/pom.xml
@@ -77,5 +77,13 @@
sdk
${project.version}
+
+
+ io.opentdf.platform
+ sdk-hybrid-bouncycastle
+ ${project.version}
+
diff --git a/pom.xml b/pom.xml
index 6ce3488c..cf7786e7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -286,6 +286,7 @@
develop
sdk
+ sdk-hybrid-bouncycastle
cmdline
examples
@@ -297,6 +298,7 @@
stage
sdk
+ sdk-hybrid-bouncycastle
false
@@ -329,6 +331,7 @@
release
sdk
+ sdk-hybrid-bouncycastle
false
@@ -367,6 +370,7 @@
coverage
sdk
+ sdk-hybrid-bouncycastle
opentdf
diff --git a/sdk-hybrid-bouncycastle/pom.xml b/sdk-hybrid-bouncycastle/pom.xml
new file mode 100644
index 00000000..ec8efe4a
--- /dev/null
+++ b/sdk-hybrid-bouncycastle/pom.xml
@@ -0,0 +1,60 @@
+
+
+ 4.0.0
+
+ io.opentdf.platform
+ sdk-pom
+ 0.15.0
+
+ sdk-hybrid-bouncycastle
+ io.opentdf.platform:sdk-hybrid-bouncycastle
+ BouncyCastle-backed HybridKeyWrapProvider SPI implementation
+ (X-Wing and NIST EC + ML-KEM hybrid post-quantum key wrapping).
+ jar
+
+ UTF-8
+
+
+
+ io.opentdf.platform
+ sdk
+ ${project.version}
+
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.27.7
+ test
+
+
+
+ io.opentdf.platform
+ sdk
+ ${project.version}
+ test-jar
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.2.0
+ test
+
+
+
diff --git a/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/BouncyCastleHybridKeyWrapProvider.java b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/BouncyCastleHybridKeyWrapProvider.java
new file mode 100644
index 00000000..2ed1d350
--- /dev/null
+++ b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/BouncyCastleHybridKeyWrapProvider.java
@@ -0,0 +1,60 @@
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+
+import io.opentdf.platform.sdk.HybridKeyWrapProvider;
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+
+/**
+ * BouncyCastle-backed {@link HybridKeyWrapProvider} covering X-Wing,
+ * P-256 + ML-KEM-768, and P-384 + ML-KEM-1024. Discovered at runtime via
+ * {@code META-INF/services/io.opentdf.platform.sdk.HybridKeyWrapProvider}.
+ */
+public final class BouncyCastleHybridKeyWrapProvider implements HybridKeyWrapProvider {
+
+ @Override
+ public boolean supports(KeyType keyType) {
+ if (keyType == null) {
+ return false;
+ }
+ switch (keyType) {
+ case HybridXWingKey:
+ case HybridSecp256r1MLKEM768Key:
+ case HybridSecp384r1MLKEM1024Key:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public byte[] wrapDEK(KeyType keyType, String publicKeyPem, byte[] dek) {
+ switch (keyType) {
+ case HybridXWingKey:
+ return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPem), dek);
+ case HybridSecp256r1MLKEM768Key:
+ return HybridNISTKeyPair.P256_MLKEM768.wrapDEK(
+ HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPem), dek);
+ case HybridSecp384r1MLKEM1024Key:
+ return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(
+ HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPem), dek);
+ default:
+ throw new SDKException("unsupported hybrid key type: " + keyType);
+ }
+ }
+
+ @Override
+ public byte[] unwrapDEK(KeyType keyType, String privateKeyPem, byte[] wrappedDek) {
+ switch (keyType) {
+ case HybridXWingKey:
+ return XWingKeyPair.unwrapDEK(XWingKeyPair.privateKeyFromPem(privateKeyPem), wrappedDek);
+ case HybridSecp256r1MLKEM768Key:
+ return HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(
+ HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(privateKeyPem), wrappedDek);
+ case HybridSecp384r1MLKEM1024Key:
+ return HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(
+ HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(privateKeyPem), wrappedDek);
+ default:
+ throw new SDKException("unsupported hybrid key type: " + keyType);
+ }
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridEnvelope.java
similarity index 83%
rename from sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java
rename to sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridEnvelope.java
index f2b776f6..040d3111 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/HybridCrypto.java
+++ b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridEnvelope.java
@@ -1,12 +1,15 @@
-package io.opentdf.platform.sdk;
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+
+import io.opentdf.platform.sdk.ECKeyPair;
+import io.opentdf.platform.sdk.SDKException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
- * Dispatcher and shared helpers for hybrid post-quantum key wrapping
- * (X-Wing and NIST EC + ML-KEM).
+ * Shared envelope codec and key-derivation helpers for hybrid post-quantum
+ * key wrapping (X-Wing and NIST EC + ML-KEM).
*
* Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs
* SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK }
@@ -14,35 +17,15 @@
* Derived AES-256 wrap key: HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty).
* EncryptedDEK: AES-256-GCM(wrapKey).encrypt(DEK) with 12-byte IV prefix + 16-byte tag.
*/
-final class HybridCrypto {
+final class HybridEnvelope {
static final int WRAP_KEY_SIZE = 32;
- // ASN.1 tag bytes used by the envelope.
private static final int TAG_SEQUENCE = 0x30;
private static final int TAG_CONTEXT_PRIMITIVE_0 = 0x80;
private static final int TAG_CONTEXT_PRIMITIVE_1 = 0x81;
- private HybridCrypto() {}
-
- /**
- * Wrap a DEK against a hybrid public-key PEM. Dispatches across X-Wing and NIST hybrid types.
- * Returns the ASN.1-encoded envelope used in {@code wrappedKey} for {@code hybrid-wrapped} key access.
- */
- static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
- switch (keyType) {
- case HybridXWingKey:
- return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPEM), dek);
- case HybridSecp256r1MLKEM768Key:
- return HybridNISTKeyPair.P256_MLKEM768.wrapDEK(
- HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPEM), dek);
- case HybridSecp384r1MLKEM1024Key:
- return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(
- HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek);
- default:
- throw new SDKException("unsupported hybrid key type: " + keyType);
- }
- }
+ private HybridEnvelope() {}
/**
* Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK.
@@ -113,7 +96,6 @@ private static byte[] encodeLength(int len) {
if (len < 0x80) {
return new byte[] { (byte) len };
}
- // Long form: 0x80 | numBytes, then big-endian length bytes.
int numBytes = 0;
int tmp = len;
while (tmp > 0) { numBytes++; tmp >>>= 8; }
@@ -133,8 +115,6 @@ private static int readLength(Cursor c) {
}
int numBytes = first & 0x7F;
if (numBytes == 0 || numBytes > 4) {
- // indefinite-length (numBytes == 0) is BER-only; DER rejects it.
- // > 4 would overflow a positive 32-bit int and is implausible for our envelope.
throw new SDKException("invalid ASN.1 length encoding: numBytes=" + numBytes);
}
int len = 0;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridNISTKeyPair.java
similarity index 93%
rename from sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java
rename to sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridNISTKeyPair.java
index 2b83eea5..d93184e1 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/HybridNISTKeyPair.java
+++ b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridNISTKeyPair.java
@@ -1,4 +1,8 @@
-package io.opentdf.platform.sdk;
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+
+import io.opentdf.platform.sdk.AesGcm;
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.bouncycastle.crypto.SecretWithEncapsulation;
@@ -130,10 +134,8 @@ private HybridNISTKeyPair(HybridNISTKeyPair params, byte[] publicKey, byte[] pri
HybridNISTKeyPair generate() {
SecureRandom random = new SecureRandom();
- // EC half — stdlib KeyPairGenerator gives us scalar + point in one call.
EcKeypairBytes ec = generateEcKeypairBytes(random);
- // ML-KEM half — BC's low-level API; no JDK 11 stdlib alternative.
MLKEMKeyPairGenerator mlGen = new MLKEMKeyPairGenerator();
mlGen.init(new MLKEMKeyGenerationParameters(random, mlkemParams));
AsymmetricCipherKeyPair mkp = mlGen.generateKeyPair();
@@ -153,22 +155,22 @@ HybridNISTKeyPair generate() {
}
String publicKeyInPemFormat() {
- return HybridCrypto.rawToPem(pubPemBlock, publicKey, publicKeySize());
+ return HybridEnvelope.rawToPem(pubPemBlock, publicKey, publicKeySize());
}
String privateKeyInPemFormat() {
- return HybridCrypto.rawToPem(privPemBlock, privateKey, privateKeySize());
+ return HybridEnvelope.rawToPem(privPemBlock, privateKey, privateKeySize());
}
byte[] getPublicKey() { return publicKey == null ? null : publicKey.clone(); }
byte[] getPrivateKey() { return privateKey == null ? null : privateKey.clone(); }
byte[] pubKeyFromPem(String pem) {
- return HybridCrypto.decodeSizedPemBlock(pem, pubPemBlock, publicKeySize());
+ return HybridEnvelope.decodeSizedPemBlock(pem, pubPemBlock, publicKeySize());
}
byte[] privateKeyFromPem(String pem) {
- return HybridCrypto.decodeSizedPemBlock(pem, privPemBlock, privateKeySize());
+ return HybridEnvelope.decodeSizedPemBlock(pem, privPemBlock, privateKeySize());
}
byte[] wrapDEK(byte[] rawPub, byte[] dek) {
@@ -180,12 +182,10 @@ byte[] wrapDEK(byte[] rawPub, byte[] dek) {
SecureRandom random = new SecureRandom();
- // ECDH: generate ephemeral keypair, compute shared secret, ship the ephemeral point.
EcKeypairBytes ephemeral = generateEcKeypairBytes(random);
BigInteger ephemeralScalar = new BigInteger(1, ephemeral.scalar);
byte[] ecdhSecret = computeEcdhSecret(ephemeralScalar, recipientEcPub);
- // ML-KEM encapsulate.
MLKEMPublicKeyParameters mlPub = new MLKEMPublicKeyParameters(mlkemParams, recipientMlPub);
SecretWithEncapsulation kemEnc = new MLKEMGenerator(random).generateEncapsulated(mlPub);
byte[] mlSecret = kemEnc.getSecret();
@@ -196,16 +196,16 @@ byte[] wrapDEK(byte[] rawPub, byte[] dek) {
byte[] combinedSecret = concat(ecdhSecret, mlSecret);
byte[] hybridCt = concat(ephemeral.publicPoint, mlCiphertext);
- byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret);
+ byte[] wrapKey = HybridEnvelope.deriveWrapKey(combinedSecret);
byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
- return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek);
+ return HybridEnvelope.marshalEnvelope(hybridCt, encryptedDek);
}
byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
if (rawPriv.length != privateKeySize()) {
throw new SDKException("invalid " + keyType + " private key size: got " + rawPriv.length + " want " + privateKeySize());
}
- byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[][] parts = HybridEnvelope.unmarshalEnvelope(wrappedDer);
byte[] hybridCt = parts[0];
byte[] encryptedDek = parts[1];
if (hybridCt.length != ciphertextSize()) {
@@ -225,7 +225,7 @@ byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
byte[] mlSecret = new MLKEMExtractor(mlPriv).extractSecret(mlCiphertext);
byte[] combinedSecret = concat(ecdhSecret, mlSecret);
- byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret);
+ byte[] wrapKey = HybridEnvelope.deriveWrapKey(combinedSecret);
return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
}
@@ -273,7 +273,6 @@ private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint
ka.init(kf.generatePrivate(mySpec));
ka.doPhase(kf.generatePublic(peerSpec), /* lastPhase */ true);
byte[] raw = ka.generateSecret();
- // JCA may strip leading zeros; left-pad to the field size to match Go's crypto/ecdh ECDH output.
if (raw.length != ecFieldByteSize) {
raw = leftPad(raw, ecFieldByteSize);
}
diff --git a/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridTestKeys.java b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridTestKeys.java
new file mode 100644
index 00000000..6de96568
--- /dev/null
+++ b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridTestKeys.java
@@ -0,0 +1,49 @@
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+
+/**
+ * Test-support helper for generating hybrid post-quantum keypairs as PEM-encoded
+ * pairs. Provided as a public utility on the published jar so the {@code sdk}
+ * module's tests (and downstream consumers writing their own round-trip tests)
+ * can exercise the SPI without importing BouncyCastle directly.
+ */
+public final class HybridTestKeys {
+
+ /** Holder for a PEM-encoded hybrid keypair. */
+ public static final class PemPair {
+ public final String publicKeyPem;
+ public final String privateKeyPem;
+
+ PemPair(String publicKeyPem, String privateKeyPem) {
+ this.publicKeyPem = publicKeyPem;
+ this.privateKeyPem = privateKeyPem;
+ }
+ }
+
+ private HybridTestKeys() {}
+
+ /**
+ * Generate a fresh hybrid keypair for the given type and return both halves
+ * as PEM blocks suitable for use with {@link io.opentdf.platform.sdk.HybridKeyWrapProvider}.
+ */
+ public static PemPair generate(KeyType keyType) {
+ switch (keyType) {
+ case HybridXWingKey: {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ return new PemPair(kp.publicKeyInPemFormat(), kp.privateKeyInPemFormat());
+ }
+ case HybridSecp256r1MLKEM768Key: {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate();
+ return new PemPair(kp.publicKeyInPemFormat(), kp.privateKeyInPemFormat());
+ }
+ case HybridSecp384r1MLKEM1024Key: {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate();
+ return new PemPair(kp.publicKeyInPemFormat(), kp.privateKeyInPemFormat());
+ }
+ default:
+ throw new SDKException("not a hybrid key type: " + keyType);
+ }
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/XWingKeyPair.java
similarity index 81%
rename from sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java
rename to sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/XWingKeyPair.java
index 7eed601e..fdc96bec 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/XWingKeyPair.java
+++ b/sdk-hybrid-bouncycastle/src/main/java/io/opentdf/platform/sdk/hybrid/bouncycastle/XWingKeyPair.java
@@ -1,4 +1,7 @@
-package io.opentdf.platform.sdk;
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+
+import io.opentdf.platform.sdk.AesGcm;
+import io.opentdf.platform.sdk.SDKException;
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
import org.bouncycastle.crypto.SecretWithEncapsulation;
@@ -44,19 +47,19 @@ static XWingKeyPair generate() {
}
String publicKeyInPemFormat() {
- return HybridCrypto.rawToPem(PEM_BLOCK_PUBLIC_KEY, publicKey, PUBLIC_KEY_SIZE);
+ return HybridEnvelope.rawToPem(PEM_BLOCK_PUBLIC_KEY, publicKey, PUBLIC_KEY_SIZE);
}
String privateKeyInPemFormat() {
- return HybridCrypto.rawToPem(PEM_BLOCK_PRIVATE_KEY, privateKey, PRIVATE_KEY_SIZE);
+ return HybridEnvelope.rawToPem(PEM_BLOCK_PRIVATE_KEY, privateKey, PRIVATE_KEY_SIZE);
}
static byte[] pubKeyFromPem(String pem) {
- return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PUBLIC_KEY, PUBLIC_KEY_SIZE);
+ return HybridEnvelope.decodeSizedPemBlock(pem, PEM_BLOCK_PUBLIC_KEY, PUBLIC_KEY_SIZE);
}
static byte[] privateKeyFromPem(String pem) {
- return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PRIVATE_KEY, PRIVATE_KEY_SIZE);
+ return HybridEnvelope.decodeSizedPemBlock(pem, PEM_BLOCK_PRIVATE_KEY, PRIVATE_KEY_SIZE);
}
static byte[] wrapDEK(byte[] rawPub, byte[] dek) {
@@ -68,16 +71,16 @@ static byte[] wrapDEK(byte[] rawPub, byte[] dek) {
byte[] sharedSecret = enc.getSecret();
byte[] ciphertext = enc.getEncapsulation();
- byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ byte[] wrapKey = HybridEnvelope.deriveWrapKey(sharedSecret);
byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
- return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek);
+ return HybridEnvelope.marshalEnvelope(ciphertext, encryptedDek);
}
static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
if (rawPriv.length != PRIVATE_KEY_SIZE) {
throw new SDKException("invalid X-Wing private key size: got " + rawPriv.length + " want " + PRIVATE_KEY_SIZE);
}
- byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[][] parts = HybridEnvelope.unmarshalEnvelope(wrappedDer);
byte[] ciphertext = parts[0];
byte[] encryptedDek = parts[1];
if (ciphertext.length != CIPHERTEXT_SIZE) {
@@ -86,7 +89,7 @@ static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
XWingPrivateKeyParameters priv = new XWingPrivateKeyParameters(rawPriv);
byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext);
- byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ byte[] wrapKey = HybridEnvelope.deriveWrapKey(sharedSecret);
return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
}
}
diff --git a/sdk-hybrid-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HybridKeyWrapProvider b/sdk-hybrid-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HybridKeyWrapProvider
new file mode 100644
index 00000000..add42723
--- /dev/null
+++ b/sdk-hybrid-bouncycastle/src/main/resources/META-INF/services/io.opentdf.platform.sdk.HybridKeyWrapProvider
@@ -0,0 +1 @@
+io.opentdf.platform.sdk.hybrid.bouncycastle.BouncyCastleHybridKeyWrapProvider
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java b/sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
similarity index 62%
rename from sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
rename to sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
index c5966a3c..6cd0e5fc 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
+++ b/sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
@@ -4,6 +4,7 @@
import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClient;
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest;
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersResponse;
+import io.opentdf.platform.sdk.hybrid.bouncycastle.HybridTestKeys;
import com.connectrpc.ResponseMessage;
import com.connectrpc.UnaryBlockingCall;
import org.junit.jupiter.api.BeforeAll;
@@ -22,14 +23,11 @@
import static org.mockito.Mockito.when;
/**
- * Mirrors {@code sdk/tdf_hybrid_test.go}. Creates a TDF using each hybrid KAS key type,
- * then asserts the resulting manifest's KeyAccess object has:
- *
- * - {@code keyType == "hybrid-wrapped"}
- * - {@code ephemeralPublicKey == null} (ephemeral material lives in the wrappedKey envelope)
- * - a {@code wrappedKey} that round-trips back to the original payload key via the matching
- * private key.
- *
+ * Lives in package {@code io.opentdf.platform.sdk} so it can construct the
+ * package-private {@link TDF} class directly and use {@code FakeServicesBuilder}
+ * from the sdk test-jar. Keypair material and unwrap are routed through the
+ * {@link HybridKeyWrapProvider} SPI (resolved via {@link HybridKeyWrapResolver})
+ * so this test doesn't import BouncyCastle types itself.
*/
class TDFHybridTest {
@@ -56,55 +54,33 @@ public void cancel() {}
@Test
void createKeyAccessWithXWingKey() throws Exception {
- XWingKeyPair kp = XWingKeyPair.generate();
- Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
- KeyType.HybridXWingKey, kp.publicKeyInPemFormat(), "xwing-kid");
- assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
- assertThat(ka.ephemeralPublicKey).isNull();
- assertThat(ka.wrappedKey).isNotEmpty();
-
- // Round-trip: unwrap with the matching private key — confirms wire format is valid.
- byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
- byte[] privRaw = XWingKeyPair.privateKeyFromPem(kp.privateKeyInPemFormat());
- byte[] symKey = XWingKeyPair.unwrapDEK(privRaw, wrappedDer);
- assertThat(symKey).hasSize(32);
+ roundTripThroughSPI(KeyType.HybridXWingKey, "xwing-kid");
}
@Test
void createKeyAccessWithP256MLKEM768Key() throws Exception {
- HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate();
- Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
- KeyType.HybridSecp256r1MLKEM768Key, kp.publicKeyInPemFormat(), "p256mlkem768-kid");
- assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
- assertThat(ka.ephemeralPublicKey).isNull();
- assertThat(ka.wrappedKey).isNotEmpty();
-
- byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
- byte[] privRaw = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(kp.privateKeyInPemFormat());
- byte[] symKey = HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(privRaw, wrappedDer);
- assertThat(symKey).hasSize(32);
+ roundTripThroughSPI(KeyType.HybridSecp256r1MLKEM768Key, "p256mlkem768-kid");
}
@Test
void createKeyAccessWithP384MLKEM1024Key() throws Exception {
- HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate();
- Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
- KeyType.HybridSecp384r1MLKEM1024Key, kp.publicKeyInPemFormat(), "p384mlkem1024-kid");
+ roundTripThroughSPI(KeyType.HybridSecp384r1MLKEM1024Key, "p384mlkem1024-kid");
+ }
+
+ private void roundTripThroughSPI(KeyType keyType, String kid) throws Exception {
+ HybridTestKeys.PemPair kp = HybridTestKeys.generate(keyType);
+
+ Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(keyType, kp.publicKeyPem, kid);
assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
assertThat(ka.ephemeralPublicKey).isNull();
assertThat(ka.wrappedKey).isNotEmpty();
byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
- byte[] privRaw = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(kp.privateKeyInPemFormat());
- byte[] symKey = HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(privRaw, wrappedDer);
+ HybridKeyWrapProvider provider = HybridKeyWrapResolver.get(keyType);
+ byte[] symKey = provider.unwrapDEK(keyType, kp.privateKeyPem, wrappedDer);
assertThat(symKey).hasSize(32);
}
- /**
- * Build a fake KAS that returns {@code (algorithm, publicKeyPem)} as its public key, then
- * call {@code TDF.createTDF} on a 32-byte plaintext and return the single KeyAccess produced
- * in the manifest.
- */
private Manifest.KeyAccess createTDFAndGetFirstKeyAccess(KeyType keyType, String publicKeyPem, String kid)
throws Exception {
Config.KASInfo kasInfo = new Config.KASInfo();
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java b/sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridCryptoTest.java
similarity index 74%
rename from sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java
rename to sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridCryptoTest.java
index 44afc618..dc4e2f89 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/HybridCryptoTest.java
+++ b/sdk-hybrid-bouncycastle/src/test/java/io/opentdf/platform/sdk/hybrid/bouncycastle/HybridCryptoTest.java
@@ -1,5 +1,8 @@
-package io.opentdf.platform.sdk;
+package io.opentdf.platform.sdk.hybrid.bouncycastle;
+import io.opentdf.platform.sdk.HybridKeyWrapProvider;
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
@@ -11,15 +14,6 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-/**
- * Unit tests for hybrid post-quantum key wrapping. Mirrors
- * {@code lib/ocrypto/xwing_test.go} and {@code lib/ocrypto/hybrid_nist_test.go}.
- *
- * Each scheme is exercised through a full round-trip: generate keypair → PEM
- * round-trip → wrap DEK → unwrap DEK → assert equal. The unwrap path is
- * also used as a wire-format guard: if marshal/unmarshal drift, the round-trip
- * fails.
- */
class HybridCryptoTest {
private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes();
@@ -40,7 +34,6 @@ void xwingRoundTrip() {
byte[] wrapped = XWingKeyPair.wrapDEK(rawPub, DEK);
assertNotNull(wrapped);
- // ASN.1 SEQUENCE header byte
assertEquals((byte) 0x30, wrapped[0]);
byte[] unwrapped = XWingKeyPair.unwrapDEK(rawPriv, wrapped);
@@ -121,37 +114,55 @@ void pemBlockTypeMismatchRejected() {
void pemBodySizeMismatchRejected() {
XWingKeyPair kp = XWingKeyPair.generate();
String pem = kp.publicKeyInPemFormat();
- // Truncate one base64 char inside the body — yields wrong byte length after decode.
int headerEnd = pem.indexOf('\n') + 1;
String truncated = pem.substring(0, headerEnd) + pem.substring(headerEnd + 4);
assertThrows(SDKException.class, () -> XWingKeyPair.pubKeyFromPem(truncated));
}
@Test
- void dispatcherSelectsCorrectScheme() {
- // Round-trip via the public HybridCrypto.wrapDEK dispatcher for each key type.
+ void providerDispatcherSelectsCorrectScheme() {
+ HybridKeyWrapProvider provider = new BouncyCastleHybridKeyWrapProvider();
+
XWingKeyPair xw = XWingKeyPair.generate();
- byte[] xwWrapped = HybridCrypto.wrapDEK(KeyType.HybridXWingKey, xw.publicKeyInPemFormat(), DEK);
- byte[] xwPriv = XWingKeyPair.privateKeyFromPem(xw.privateKeyInPemFormat());
- assertArrayEquals(DEK, XWingKeyPair.unwrapDEK(xwPriv, xwWrapped));
+ byte[] xwWrapped = provider.wrapDEK(KeyType.HybridXWingKey, xw.publicKeyInPemFormat(), DEK);
+ byte[] xwUnwrapped = provider.unwrapDEK(KeyType.HybridXWingKey, xw.privateKeyInPemFormat(), xwWrapped);
+ assertArrayEquals(DEK, xwUnwrapped);
HybridNISTKeyPair p256 = HybridNISTKeyPair.P256_MLKEM768.generate();
- byte[] p256Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp256r1MLKEM768Key,
+ byte[] p256Wrapped = provider.wrapDEK(KeyType.HybridSecp256r1MLKEM768Key,
p256.publicKeyInPemFormat(), DEK);
- byte[] p256Priv = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(p256.privateKeyInPemFormat());
- assertArrayEquals(DEK, HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(p256Priv, p256Wrapped));
+ byte[] p256Unwrapped = provider.unwrapDEK(KeyType.HybridSecp256r1MLKEM768Key,
+ p256.privateKeyInPemFormat(), p256Wrapped);
+ assertArrayEquals(DEK, p256Unwrapped);
HybridNISTKeyPair p384 = HybridNISTKeyPair.P384_MLKEM1024.generate();
- byte[] p384Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp384r1MLKEM1024Key,
+ byte[] p384Wrapped = provider.wrapDEK(KeyType.HybridSecp384r1MLKEM1024Key,
p384.publicKeyInPemFormat(), DEK);
- byte[] p384Priv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(p384.privateKeyInPemFormat());
- assertArrayEquals(DEK, HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(p384Priv, p384Wrapped));
+ byte[] p384Unwrapped = provider.unwrapDEK(KeyType.HybridSecp384r1MLKEM1024Key,
+ p384.privateKeyInPemFormat(), p384Wrapped);
+ assertArrayEquals(DEK, p384Unwrapped);
}
@Test
- void dispatcherRejectsNonHybridKeyType() {
+ void providerRejectsNonHybridKeyType() {
+ HybridKeyWrapProvider provider = new BouncyCastleHybridKeyWrapProvider();
assertThrows(SDKException.class,
- () -> HybridCrypto.wrapDEK(KeyType.RSA2048Key, "not-a-real-pem", DEK));
+ () -> provider.wrapDEK(KeyType.RSA2048Key, "not-a-real-pem", DEK));
+ }
+
+ @Test
+ void providerSupportsReturnsTrueForHybridTypesOnly() {
+ HybridKeyWrapProvider provider = new BouncyCastleHybridKeyWrapProvider();
+ assertTrue(provider.supports(KeyType.HybridXWingKey));
+ assertTrue(provider.supports(KeyType.HybridSecp256r1MLKEM768Key));
+ assertTrue(provider.supports(KeyType.HybridSecp384r1MLKEM1024Key));
+ for (KeyType kt : KeyType.values()) {
+ if (kt != KeyType.HybridXWingKey
+ && kt != KeyType.HybridSecp256r1MLKEM768Key
+ && kt != KeyType.HybridSecp384r1MLKEM1024Key) {
+ assertEquals(false, provider.supports(kt), "supports() should be false for " + kt);
+ }
+ }
}
@Test
diff --git a/sdk/pom.xml b/sdk/pom.xml
index 7f35e454..9daf30fb 100644
--- a/sdk/pom.xml
+++ b/sdk/pom.xml
@@ -474,6 +474,18 @@
@{argLine} ${java.security.properties.test}
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ test-jar
+
+ test-jar
+
+
+
+
@@ -483,13 +495,10 @@
true
-
-
- org.bouncycastle
- bcprov-jdk18on
-
+
org.bouncycastle
bcpkix-jdk18on
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapProvider.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapProvider.java
new file mode 100644
index 00000000..d8b52f1c
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapProvider.java
@@ -0,0 +1,27 @@
+package io.opentdf.platform.sdk;
+
+/**
+ * Service Provider Interface for hybrid post-quantum key wrapping (X-Wing and
+ * NIST EC + ML-KEM). Implementations are discovered at runtime via
+ * {@link java.util.ServiceLoader} so the {@code sdk} jar itself carries no
+ * compile-time dependency on the underlying crypto library.
+ *
+ * The reference implementation is {@code io.opentdf.platform:sdk-hybrid-bouncycastle},
+ * which provides X-Wing and {@code P-256/384 + ML-KEM} via BouncyCastle.
+ */
+public interface HybridKeyWrapProvider {
+
+ /** Whether this provider can wrap/unwrap for the given hybrid key type. */
+ boolean supports(KeyType keyType);
+
+ /**
+ * Wrap a 32-byte DEK against a hybrid public-key PEM, returning the
+ * ASN.1 envelope used as {@code wrappedKey} for {@code hybrid-wrapped} key access.
+ */
+ byte[] wrapDEK(KeyType keyType, String publicKeyPem, byte[] dek);
+
+ /**
+ * Unwrap a DEK from an ASN.1 envelope using a hybrid private-key PEM.
+ */
+ byte[] unwrapDEK(KeyType keyType, String privateKeyPem, byte[] wrappedDek);
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapResolver.java b/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapResolver.java
new file mode 100644
index 00000000..7a576b01
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/HybridKeyWrapResolver.java
@@ -0,0 +1,45 @@
+package io.opentdf.platform.sdk;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+
+/**
+ * Locates {@link HybridKeyWrapProvider} implementations via
+ * {@link ServiceLoader} and dispatches on {@link KeyType}. The provider list
+ * is loaded once on first access (holder-class idiom) using the resolver's own
+ * classloader for deterministic behavior under shaded jars.
+ */
+final class HybridKeyWrapResolver {
+
+ private HybridKeyWrapResolver() {}
+
+ private static final class Holder {
+ static final List PROVIDERS = load();
+
+ private static List load() {
+ List out = new ArrayList<>();
+ for (HybridKeyWrapProvider p : ServiceLoader.load(
+ HybridKeyWrapProvider.class,
+ HybridKeyWrapResolver.class.getClassLoader())) {
+ out.add(p);
+ }
+ return out;
+ }
+ }
+
+ /**
+ * Return the first registered provider that supports {@code keyType}.
+ *
+ * @throws SDKException if no provider is registered for the requested type.
+ */
+ static HybridKeyWrapProvider get(KeyType keyType) {
+ for (HybridKeyWrapProvider p : Holder.PROVIDERS) {
+ if (p.supports(keyType)) {
+ return p;
+ }
+ }
+ throw new SDKException("No HybridKeyWrapProvider registered for " + keyType
+ + ". Add io.opentdf.platform:sdk-hybrid-bouncycastle to your classpath.");
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index cb97c901..be0167d0 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -228,7 +228,7 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA
var keyType = KeyType.fromString(algorithm);
if (keyType.isHybrid()) {
- byte[] wrapped = HybridCrypto.wrapDEK(keyType, kasInfo.PublicKey, symKey);
+ byte[] wrapped = HybridKeyWrapResolver.get(keyType).wrapDEK(keyType, kasInfo.PublicKey, symKey);
keyAccess.wrappedKey = Base64.getEncoder().encodeToString(wrapped);
keyAccess.keyType = kHybridWrapped;
// ephemeralPublicKey intentionally left null — the ephemeral material is