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