Discovered by {@link io.opentdf.platform.sdk.spi.KemProviders} via
+ * {@link java.util.ServiceLoader}; consumers don't construct this directly.
+ * Lives in the optional {@code sdk-pqc-bc} module so the core {@code sdk}
+ * jar has no compile-time dependency on BouncyCastle.
+ */
+public final class BouncyCastleKemProvider implements KemProvider {
+
+ private static final Set Duplicated rather than reusing {@code TDF.GLOBAL_KEY_SALT} because the
+ * {@code TDF} class is package-private to {@code io.opentdf.platform.sdk}
+ * and unreachable from this {@code pqc.bc} package. Computed once at class
+ * load and returned as a defensive clone (HKDF input must not mutate).
+ */
+ static byte[] defaultTDFSalt() {
+ return DEFAULT_TDF_SALT.clone();
+ }
+
+ private static final byte[] DEFAULT_TDF_SALT = computeDefaultTDFSalt();
+
+ private static byte[] computeDefaultTDFSalt() {
+ try {
+ MessageDigest d = MessageDigest.getInstance("SHA-256");
+ d.update("TDF".getBytes());
+ return d.digest();
+ } catch (NoSuchAlgorithmException e) {
+ throw new SDKException("SHA-256 not available", e);
+ }
+ }
+
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTAlgorithm.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTAlgorithm.java
new file mode 100644
index 00000000..a36a39f0
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTAlgorithm.java
@@ -0,0 +1,447 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.AesGcm;
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.crypto.SecretWithEncapsulation;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters;
+
+import javax.crypto.KeyAgreement;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.AlgorithmParameters;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPrivateKeySpec;
+import java.security.spec.ECPublicKeySpec;
+import java.util.Arrays;
+
+/**
+ * Stateless parameters + operations for a NIST hybrid PQC algorithm
+ * (P-256 + ML-KEM-768 or P-384 + ML-KEM-1024). Conforms to
+ * {@code draft-ietf-lamps-pq-composite-kem-14}.
+ *
+ * Public key ({@link #publicKeySize()} bytes inside the SPKI BIT STRING):
+ * Private key (variable length inside the PKCS#8 OCTET STRING):
+ * Hybrid ciphertext ({@link #ciphertextSize()} bytes, payload of the
+ * outer TDF ASN.1 envelope's first OCTET STRING):
+ * KEM combiner (per draft-14 §4.3):
+ * The outer TDF DEK envelope (ASN.1 {@code SEQUENCE { [0] OCTET STRING, [1]
+ * OCTET STRING }}) is unchanged.
+ *
+ * EC operations use stdlib JCA. ML-KEM operations use BouncyCastle's
+ * low-level API.
+ */
+public final class HybridNISTAlgorithm {
+
+ public static final HybridNISTAlgorithm P256_MLKEM768 = new HybridNISTAlgorithm(
+ "secp256r1",
+ /* ecPubSize */ 65,
+ /* ecPrivSize */ 32,
+ /* mlkemPubSize */ 1184,
+ /* mlkemCtSize */ 1088,
+ MLKEMParameters.ml_kem_768,
+ HybridSpki.OID_P256_MLKEM768,
+ "MLKEM768-P256",
+ KeyType.HybridSecp256r1MLKEM768Key);
+
+ public static final HybridNISTAlgorithm P384_MLKEM1024 = new HybridNISTAlgorithm(
+ "secp384r1",
+ /* ecPubSize */ 97,
+ /* ecPrivSize */ 48,
+ /* mlkemPubSize */ 1568,
+ /* mlkemCtSize */ 1568,
+ MLKEMParameters.ml_kem_1024,
+ HybridSpki.OID_P384_MLKEM1024,
+ "MLKEM1024-P384",
+ KeyType.HybridSecp384r1MLKEM1024Key);
+
+ /** Fixed 64-byte ML-KEM seed (d || z) per FIPS 203. */
+ static final int MLKEM_SEED_SIZE = 64;
+
+ private final String curveName;
+ private final int ecPubSize;
+ private final int ecPrivSize;
+ private final int mlkemPubSize;
+ private final int mlkemCtSize;
+ private final MLKEMParameters mlkemParams;
+ private final ASN1ObjectIdentifier oid;
+ private final byte[] label;
+ private final KeyType keyType;
+ private final ECParameterSpec ecParams;
+ private final int ecFieldByteSize;
+ private final int ecOrderBitLength;
+
+ private HybridNISTAlgorithm(String curveName, int ecPubSize, int ecPrivSize, int mlkemPubSize, int mlkemCtSize,
+ MLKEMParameters mlkemParams, ASN1ObjectIdentifier oid, String labelAscii,
+ KeyType keyType) {
+ this.curveName = curveName;
+ this.ecPubSize = ecPubSize;
+ this.ecPrivSize = ecPrivSize;
+ this.mlkemPubSize = mlkemPubSize;
+ this.mlkemCtSize = mlkemCtSize;
+ this.mlkemParams = mlkemParams;
+ this.oid = oid;
+ this.label = labelAscii.getBytes(StandardCharsets.US_ASCII);
+ this.keyType = keyType;
+ this.ecParams = ecParamsFor(curveName);
+ this.ecFieldByteSize = (this.ecParams.getCurve().getField().getFieldSize() + 7) / 8;
+ this.ecOrderBitLength = this.ecParams.getOrder().bitLength();
+ }
+
+ public int publicKeySize() { return mlkemPubSize + ecPubSize; }
+ public int ciphertextSize() { return mlkemCtSize + ecPubSize; }
+ public KeyType keyType() { return keyType; }
+ ASN1ObjectIdentifier oid() { return oid; }
+
+ /** Generate a fresh keypair for this algorithm. */
+ public 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();
+ byte[] mlPubBytes = ((MLKEMPublicKeyParameters) mkp.getPublic()).getEncoded();
+ byte[] mlSeed = ((MLKEMPrivateKeyParameters) mkp.getPrivate()).getSeed();
+
+ if (mlPubBytes.length != mlkemPubSize) {
+ throw new SDKException("ML-KEM public key size " + mlPubBytes.length + " != expected " + mlkemPubSize);
+ }
+ if (mlSeed.length != MLKEM_SEED_SIZE) {
+ throw new SDKException("ML-KEM seed size " + mlSeed.length + " != expected " + MLKEM_SEED_SIZE);
+ }
+
+ // draft-14: public key = mlkemPK || ecPoint; private key = mlkemSeed || ECPrivateKey(DER)
+ byte[] pub = HybridCrypto.concat(mlPubBytes, ec.publicPoint);
+ byte[] ecScalarDer = HybridSpki.encodeEcPrivateKey(new BigInteger(1, ec.scalar), ecOrderBitLength);
+ byte[] priv = HybridCrypto.concat(mlSeed, ecScalarDer);
+ return new HybridNISTKeyPair(this, pub, priv);
+ }
+
+ public byte[] pubKeyFromPem(String pem) {
+ byte[] raw = HybridSpki.decodeSpkiPem(pem, oid);
+ if (raw.length != publicKeySize()) {
+ throw new SDKException("invalid " + keyType + " public key size: got " + raw.length + " want " + publicKeySize());
+ }
+ return raw;
+ }
+
+ public byte[] privateKeyFromPem(String pem) {
+ byte[] raw = HybridSpki.decodePkcs8Pem(pem, oid);
+ if (raw.length <= MLKEM_SEED_SIZE) {
+ throw new SDKException("invalid " + keyType + " private key: " + raw.length
+ + " bytes, need > " + MLKEM_SEED_SIZE + " (mlkemSeed || ECPrivateKey)");
+ }
+ return raw;
+ }
+
+ public byte[] wrapDEK(byte[] rawPub, byte[] dek) {
+ if (rawPub.length != publicKeySize()) {
+ throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize());
+ }
+ // draft-14 split: mlkemPK || ecPoint
+ byte[] recipientMlPub = Arrays.copyOfRange(rawPub, 0, mlkemPubSize);
+ byte[] recipientEcPub = Arrays.copyOfRange(rawPub, mlkemPubSize, rawPub.length);
+
+ 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[] tradSS = computeEcdhSecret(ephemeralScalar, recipientEcPub);
+ byte[] tradCT = ephemeral.publicPoint; // ephemeral EC pub (the KEM ciphertext)
+ byte[] tradPK = recipientEcPub; // recipient's static EC pub
+
+ // ML-KEM encapsulate.
+ MLKEMPublicKeyParameters mlPub = new MLKEMPublicKeyParameters(mlkemParams, recipientMlPub);
+ SecretWithEncapsulation kemEnc = new MLKEMGenerator(random).generateEncapsulated(mlPub);
+ byte[] mlSS = kemEnc.getSecret();
+ byte[] mlCT = kemEnc.getEncapsulation();
+ if (mlCT.length != mlkemCtSize) {
+ throw new SDKException("ML-KEM ciphertext size " + mlCT.length + " != expected " + mlkemCtSize);
+ }
+
+ // draft-14 split: ciphertext = mlkemCT || ephemeralECPoint
+ byte[] hybridCt = HybridCrypto.concat(mlCT, tradCT);
+ byte[] wrapKey = deriveDraft14WrapKey(mlSS, tradSS, tradCT, tradPK);
+ byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
+ return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek);
+ }
+
+ public byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
+ if (rawPriv.length <= MLKEM_SEED_SIZE) {
+ throw new SDKException("invalid " + keyType + " private key: " + rawPriv.length
+ + " bytes, need > " + MLKEM_SEED_SIZE + " (mlkemSeed || ECPrivateKey)");
+ }
+ byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[] hybridCt = parts[0];
+ byte[] encryptedDek = parts[1];
+ if (hybridCt.length != ciphertextSize()) {
+ throw new SDKException("invalid " + keyType + " ciphertext size: got " + hybridCt.length + " want " + ciphertextSize());
+ }
+
+ // draft-14 split: ciphertext = mlkemCT || ephemeralECPoint
+ byte[] mlCT = Arrays.copyOfRange(hybridCt, 0, mlkemCtSize);
+ byte[] tradCT = Arrays.copyOfRange(hybridCt, mlkemCtSize, hybridCt.length);
+
+ // draft-14 split: private key = mlkemSeed || ECPrivateKey(DER)
+ byte[] mlSeed = Arrays.copyOfRange(rawPriv, 0, MLKEM_SEED_SIZE);
+ byte[] ecPrivateKeyDer = Arrays.copyOfRange(rawPriv, MLKEM_SEED_SIZE, rawPriv.length);
+ BigInteger ecScalar = HybridSpki.decodeEcPrivateKey(ecPrivateKeyDer);
+
+ byte[] tradSS = computeEcdhSecret(ecScalar, tradCT);
+
+ MLKEMPrivateKeyParameters mlPriv = new MLKEMPrivateKeyParameters(mlkemParams, mlSeed);
+ byte[] mlSS = new MLKEMExtractor(mlPriv).extractSecret(mlCT);
+
+ // To derive the wrap key we need tradPK (recipient's EC pub) too. Recover it from
+ // the EC scalar by multiplying with the curve generator and emitting the
+ // uncompressed point. The stdlib KeyPairGenerator path doesn't expose scalar*G
+ // directly, but ECDH against the generator yields just the x-coordinate; the
+ // simplest correct path is to derive the public point via KeyFactory + the
+ // generated curve params.
+ byte[] tradPK = derivePublicPointBytes(ecScalar);
+ byte[] wrapKey = deriveDraft14WrapKey(mlSS, tradSS, tradCT, tradPK);
+ return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
+ }
+
+ /**
+ * draft-ietf-lamps-pq-composite-kem-14 §4.3 combiner:
+ * {@code SHA3-256(mlkemSS || tradSS || tradCT || tradPK || Label)}.
+ * The 32-byte output is used directly as an AES-256 key — no HKDF.
+ */
+ private byte[] deriveDraft14WrapKey(byte[] mlkemSS, byte[] tradSS, byte[] tradCT, byte[] tradPK) {
+ try {
+ MessageDigest sha3 = MessageDigest.getInstance("SHA3-256");
+ sha3.update(mlkemSS);
+ sha3.update(tradSS);
+ sha3.update(tradCT);
+ sha3.update(tradPK);
+ sha3.update(label);
+ return sha3.digest();
+ } catch (NoSuchAlgorithmException e) {
+ throw new SDKException("SHA3-256 not available", e);
+ }
+ }
+
+ /** Resolve a named-curve {@link ECParameterSpec} via stdlib JCA. */
+ private static ECParameterSpec ecParamsFor(String curveName) {
+ try {
+ AlgorithmParameters ap = AlgorithmParameters.getInstance("EC");
+ ap.init(new ECGenParameterSpec(curveName));
+ return ap.getParameterSpec(ECParameterSpec.class);
+ } catch (Exception e) {
+ throw new SDKException("EC parameters not available for curve " + curveName, e);
+ }
+ }
+
+ /** Generate an EC keypair via stdlib and return scalar (padded) and uncompressed-point bytes. */
+ private EcKeypairBytes generateEcKeypairBytes(SecureRandom random) {
+ try {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
+ kpg.initialize(new ECGenParameterSpec(curveName), random);
+ KeyPair kp = kpg.generateKeyPair();
+ ECPrivateKey priv = (ECPrivateKey) kp.getPrivate();
+ ECPublicKey pub = (ECPublicKey) kp.getPublic();
+ byte[] scalar = toFixedLength(priv.getS(), ecPrivSize);
+ byte[] point = encodeUncompressedPoint(pub.getW(), ecFieldByteSize);
+ if (point.length != ecPubSize) {
+ throw new SDKException("encoded EC point size " + point.length + " != expected " + ecPubSize);
+ }
+ return new EcKeypairBytes(scalar, point);
+ } catch (Exception e) {
+ throw new SDKException("failed to generate EC keypair on " + curveName, e);
+ }
+ }
+
+ /** Standard ECDH via JCA: x-coordinate of {@code scalar * peerPoint}, fixed-size big-endian. */
+ private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint) {
+ try {
+ ECPoint peerPoint = decodeUncompressedPoint(peerUncompressedPoint, ecFieldByteSize);
+ ECPublicKeySpec peerSpec = new ECPublicKeySpec(peerPoint, ecParams);
+ ECPrivateKeySpec mySpec = new ECPrivateKeySpec(scalar, ecParams);
+ KeyFactory kf = KeyFactory.getInstance("EC");
+
+ KeyAgreement ka = KeyAgreement.getInstance("ECDH");
+ 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);
+ }
+ return raw;
+ } catch (Exception e) {
+ throw new SDKException("ECDH failed for " + curveName, e);
+ }
+ }
+
+ /**
+ * Recover an EC public point from a private scalar and emit it as uncompressed-point bytes.
+ * Used during unwrap to reconstruct {@code tradPK} for the draft-14 combiner — the recipient's
+ * static EC pub is not on the wire.
+ */
+ private byte[] derivePublicPointBytes(BigInteger scalar) {
+ try {
+ // Build an ECPrivateKey from the scalar, then ask the JCA to derive the matching
+ // ECPublicKey by generating it from the same KeyFactory. Stdlib doesn't expose
+ // scalar*G directly, but BC's EC math is reachable via ECPrivateKey → public.
+ KeyFactory kf = KeyFactory.getInstance("EC");
+ java.security.interfaces.ECPrivateKey priv =
+ (java.security.interfaces.ECPrivateKey) kf.generatePrivate(new ECPrivateKeySpec(scalar, ecParams));
+ // org.bouncycastle.jce.interfaces.ECPrivateKey would let us call .getQ() directly,
+ // but to avoid that BC dep here we use an ECDH self-trick: scalar * G yields the
+ // ephemeral public point, captured by encoding the matching public key.
+ // BC's JCE provider, if registered, would produce an ECPublicKey alongside; here we
+ // recover it via parameters.getGenerator() and stdlib BigInteger math.
+ BigInteger n = ecParams.getOrder();
+ BigInteger d = priv.getS().mod(n);
+ ECPoint w = scalarMultiplyGenerator(d);
+ return encodeUncompressedPoint(w, ecFieldByteSize);
+ } catch (Exception e) {
+ throw new SDKException("failed to recover EC public point on " + curveName, e);
+ }
+ }
+
+ /** Plain double-and-add scalar multiplication of the curve generator. */
+ private ECPoint scalarMultiplyGenerator(BigInteger k) {
+ java.security.spec.EllipticCurve curve = ecParams.getCurve();
+ java.security.spec.ECFieldFp field = (java.security.spec.ECFieldFp) curve.getField();
+ BigInteger p = field.getP();
+ BigInteger a = curve.getA();
+ ECPoint G = ecParams.getGenerator();
+ ECPoint result = null;
+ ECPoint addend = G;
+ for (int i = 0; i < k.bitLength(); i++) {
+ if (k.testBit(i)) {
+ result = (result == null) ? addend : pointAdd(result, addend, p, a);
+ }
+ addend = pointDouble(addend, p, a);
+ }
+ if (result == null) {
+ throw new SDKException("scalar is zero");
+ }
+ return result;
+ }
+
+ private static ECPoint pointAdd(ECPoint P, ECPoint Q, BigInteger p, BigInteger a) {
+ BigInteger x1 = P.getAffineX(), y1 = P.getAffineY();
+ BigInteger x2 = Q.getAffineX(), y2 = Q.getAffineY();
+ if (x1.equals(x2)) {
+ if (y1.add(y2).mod(p).signum() == 0) {
+ throw new SDKException("point at infinity not supported");
+ }
+ return pointDouble(P, p, a);
+ }
+ BigInteger s = y2.subtract(y1).multiply(x2.subtract(x1).modInverse(p)).mod(p);
+ BigInteger xr = s.modPow(BigInteger.valueOf(2), p).subtract(x1).subtract(x2).mod(p);
+ BigInteger yr = s.multiply(x1.subtract(xr)).subtract(y1).mod(p);
+ return new ECPoint(xr, yr);
+ }
+
+ private static ECPoint pointDouble(ECPoint P, BigInteger p, BigInteger a) {
+ BigInteger x = P.getAffineX();
+ BigInteger y = P.getAffineY();
+ if (y.signum() == 0) {
+ throw new SDKException("point at infinity not supported");
+ }
+ BigInteger two = BigInteger.valueOf(2);
+ BigInteger three = BigInteger.valueOf(3);
+ BigInteger s = x.modPow(two, p).multiply(three).add(a).mod(p)
+ .multiply(two.multiply(y).modInverse(p)).mod(p);
+ BigInteger xr = s.modPow(two, p).subtract(two.multiply(x)).mod(p);
+ BigInteger yr = s.multiply(x.subtract(xr)).subtract(y).mod(p);
+ return new ECPoint(xr, yr);
+ }
+
+ private static byte[] encodeUncompressedPoint(ECPoint w, int byteSize) {
+ byte[] x = toFixedLength(w.getAffineX(), byteSize);
+ byte[] y = toFixedLength(w.getAffineY(), byteSize);
+ byte[] out = new byte[1 + 2 * byteSize];
+ out[0] = 0x04;
+ System.arraycopy(x, 0, out, 1, byteSize);
+ System.arraycopy(y, 0, out, 1 + byteSize, byteSize);
+ return out;
+ }
+
+ private static ECPoint decodeUncompressedPoint(byte[] encoded, int byteSize) {
+ if (encoded.length != 1 + 2 * byteSize || encoded[0] != 0x04) {
+ throw new SDKException("invalid uncompressed EC point encoding (length=" + encoded.length
+ + ", lead=0x" + Integer.toHexString(encoded[0] & 0xFF) + ")");
+ }
+ BigInteger x = new BigInteger(1, Arrays.copyOfRange(encoded, 1, 1 + byteSize));
+ BigInteger y = new BigInteger(1, Arrays.copyOfRange(encoded, 1 + byteSize, 1 + 2 * byteSize));
+ return new ECPoint(x, y);
+ }
+
+ /** Convert a non-negative {@link BigInteger} to a fixed-length big-endian byte array. */
+ private static byte[] toFixedLength(BigInteger value, int length) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length == length) return bytes;
+ if (bytes.length > length) {
+ int excess = bytes.length - length;
+ for (int i = 0; i < excess; i++) {
+ if (bytes[i] != 0) {
+ throw new SDKException("value too large for width " + length);
+ }
+ }
+ return Arrays.copyOfRange(bytes, excess, bytes.length);
+ }
+ byte[] out = new byte[length];
+ System.arraycopy(bytes, 0, out, length - bytes.length, bytes.length);
+ return out;
+ }
+
+ private static byte[] leftPad(byte[] src, int width) {
+ if (src.length >= width) return src;
+ byte[] out = new byte[width];
+ System.arraycopy(src, 0, out, width - src.length, src.length);
+ return out;
+ }
+
+ private static final class EcKeypairBytes {
+ final byte[] scalar;
+ final byte[] publicPoint;
+ EcKeypairBytes(byte[] scalar, byte[] publicPoint) {
+ this.scalar = scalar;
+ this.publicPoint = publicPoint;
+ }
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java
new file mode 100644
index 00000000..42368c0c
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java
@@ -0,0 +1,40 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.KeyType;
+
+/**
+ * A keypair produced by {@link HybridNISTAlgorithm#generate}. Holds the raw
+ * public and private key bytes alongside a reference back to the algorithm
+ * that produced them (for size validation and PEM headers).
+ *
+ * This type carries key material only — algorithm-level operations
+ * ({@code wrapDEK}, {@code unwrapDEK}, {@code pubKeyFromPem}, etc.) live on
+ * {@link HybridNISTAlgorithm}. The split keeps the static algorithm templates
+ * from being type-indistinguishable from the keypairs they produce.
+ */
+public final class HybridNISTKeyPair {
+
+ private final HybridNISTAlgorithm algorithm;
+ private final byte[] publicKey;
+ private final byte[] privateKey;
+
+ HybridNISTKeyPair(HybridNISTAlgorithm algorithm, byte[] publicKey, byte[] privateKey) {
+ this.algorithm = algorithm;
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ public HybridNISTAlgorithm algorithm() { return algorithm; }
+ public KeyType keyType() { return algorithm.keyType(); }
+
+ public byte[] getPublicKey() { return publicKey.clone(); }
+ public byte[] getPrivateKey() { return privateKey.clone(); }
+
+ public String publicKeyInPemFormat() {
+ return HybridSpki.encodeSpkiPem(algorithm.oid(), publicKey);
+ }
+
+ public String privateKeyInPemFormat() {
+ return HybridSpki.encodePkcs8Pem(algorithm.oid(), privateKey);
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridSpki.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridSpki.java
new file mode 100644
index 00000000..f9d36cde
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridSpki.java
@@ -0,0 +1,174 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.SDKException;
+import org.bouncycastle.asn1.ASN1EncodableVector;
+import org.bouncycastle.asn1.ASN1Integer;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1Primitive;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.DERSequence;
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.asn1.sec.ECPrivateKey;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.Base64;
+
+/**
+ * SPKI ({@code SubjectPublicKeyInfo}, X.509) and PKCS#8
+ * ({@code OneAsymmetricKey}) encode/parse helpers for hybrid PQC keys, plus
+ * RFC 5915 {@code ECPrivateKey} encode/parse for the EC half of NIST hybrid
+ * private keys.
+ *
+ * The new (post-PR #3563) PEM format for all three hybrid algorithms is the
+ * standard {@code -----BEGIN PUBLIC KEY-----} / {@code -----BEGIN PRIVATE KEY-----}
+ * envelope; the {@link AlgorithmIdentifier} OID inside dispatches to the
+ * correct scheme. Custom block names like {@code SECP256R1 MLKEM768 PUBLIC KEY}
+ * and {@code XWING PUBLIC KEY} are gone.
+ *
+ * OIDs (params absent for all three):
+ * Uses BouncyCastle's ASN.1 helpers — already on the classpath via
+ * {@code sdk-pqc-bc}. No new BC compile-time surface area for the core sdk.
+ */
+final class HybridSpki {
+
+ static final ASN1ObjectIdentifier OID_P256_MLKEM768 = new ASN1ObjectIdentifier("1.3.6.1.5.5.7.6.59");
+ static final ASN1ObjectIdentifier OID_P384_MLKEM1024 = new ASN1ObjectIdentifier("1.3.6.1.5.5.7.6.63");
+ static final ASN1ObjectIdentifier OID_XWING = new ASN1ObjectIdentifier("1.3.6.1.4.1.62253.25722");
+
+ private static final String PEM_TYPE_PUBLIC = "PUBLIC KEY";
+ private static final String PEM_TYPE_PRIVATE = "PRIVATE KEY";
+
+ private HybridSpki() {}
+
+ /**
+ * Encode {@code rawPublicKey} as an SPKI PEM block with the given algorithm
+ * OID. The raw bytes go into the SPKI {@code subjectPublicKey} BIT STRING
+ * verbatim (no further wrapping).
+ */
+ static String encodeSpkiPem(ASN1ObjectIdentifier algorithmOid, byte[] rawPublicKey) {
+ try {
+ AlgorithmIdentifier algId = new AlgorithmIdentifier(algorithmOid);
+ SubjectPublicKeyInfo spki = new SubjectPublicKeyInfo(algId, rawPublicKey);
+ return pemEncode(PEM_TYPE_PUBLIC, spki.getEncoded("DER"));
+ } catch (IOException e) {
+ throw new SDKException("failed to encode SPKI for OID " + algorithmOid, e);
+ }
+ }
+
+ /**
+ * Encode {@code rawPrivateKey} as a PKCS#8 PEM block with the given algorithm
+ * OID. The raw bytes go into the PKCS#8 {@code privateKey} OCTET STRING
+ * verbatim (no further wrapping), matching the Go side's
+ * {@code OneAsymmetricKey.PrivateKey []byte} layout.
+ *
+ * Hand-built via {@link DERSequence} because BC's
+ * {@code PrivateKeyInfo(AlgorithmIdentifier, ASN1Encodable)} constructor
+ * DER-encodes the encodable first, which would add an inner OCTET STRING
+ * wrapper around our raw bytes.
+ */
+ static String encodePkcs8Pem(ASN1ObjectIdentifier algorithmOid, byte[] rawPrivateKey) {
+ try {
+ AlgorithmIdentifier algId = new AlgorithmIdentifier(algorithmOid);
+ ASN1EncodableVector v = new ASN1EncodableVector();
+ v.add(new ASN1Integer(0)); // version v1 = 0
+ v.add(algId); // privateKeyAlgorithm
+ v.add(new DEROctetString(rawPrivateKey)); // privateKey OCTET STRING (raw bytes)
+ byte[] der = new DERSequence(v).getEncoded("DER");
+ return pemEncode(PEM_TYPE_PRIVATE, der);
+ } catch (IOException e) {
+ throw new SDKException("failed to encode PKCS#8 for OID " + algorithmOid, e);
+ }
+ }
+
+ /**
+ * Decode an SPKI PEM block, validate the algorithm OID matches {@code expectedOid},
+ * and return the raw {@code subjectPublicKey} bytes.
+ */
+ static byte[] decodeSpkiPem(String pem, ASN1ObjectIdentifier expectedOid) {
+ byte[] der = stripPemAndDecode(pem, PEM_TYPE_PUBLIC);
+ try {
+ SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(der));
+ ASN1ObjectIdentifier actualOid = spki.getAlgorithm().getAlgorithm();
+ if (!expectedOid.equals(actualOid)) {
+ throw new SDKException("SPKI OID mismatch: expected " + expectedOid + ", got " + actualOid);
+ }
+ return spki.getPublicKeyData().getBytes();
+ } catch (IOException e) {
+ throw new SDKException("failed to parse SPKI", e);
+ }
+ }
+
+ /**
+ * Decode a PKCS#8 PEM block, validate the algorithm OID matches {@code expectedOid},
+ * and return the raw {@code privateKey} bytes.
+ */
+ static byte[] decodePkcs8Pem(String pem, ASN1ObjectIdentifier expectedOid) {
+ byte[] der = stripPemAndDecode(pem, PEM_TYPE_PRIVATE);
+ try {
+ PrivateKeyInfo pki = PrivateKeyInfo.getInstance(ASN1Primitive.fromByteArray(der));
+ ASN1ObjectIdentifier actualOid = pki.getPrivateKeyAlgorithm().getAlgorithm();
+ if (!expectedOid.equals(actualOid)) {
+ throw new SDKException("PKCS#8 OID mismatch: expected " + expectedOid + ", got " + actualOid);
+ }
+ return pki.getPrivateKey().getOctets();
+ } catch (IOException e) {
+ throw new SDKException("failed to parse PKCS#8", e);
+ }
+ }
+
+ /**
+ * Encode an EC private-key scalar as RFC 5915 {@code ECPrivateKey} DER —
+ * {@code SEQUENCE { version=1, privateKey OCTET STRING, ... }}. Used as the
+ * EC half of a NIST hybrid private key per draft-14.
+ */
+ static byte[] encodeEcPrivateKey(BigInteger scalar, int orderBitLength) {
+ try {
+ return new ECPrivateKey(orderBitLength, scalar).getEncoded("DER");
+ } catch (IOException e) {
+ throw new SDKException("failed to encode RFC 5915 ECPrivateKey", e);
+ }
+ }
+
+ /**
+ * Parse RFC 5915 {@code ECPrivateKey} DER and return the scalar as an unsigned
+ * {@link BigInteger}.
+ */
+ static BigInteger decodeEcPrivateKey(byte[] der) {
+ try {
+ ECPrivateKey ec = ECPrivateKey.getInstance(ASN1Primitive.fromByteArray(der));
+ return ec.getKey();
+ } catch (IOException e) {
+ throw new SDKException("failed to parse RFC 5915 ECPrivateKey", e);
+ }
+ }
+
+ private static String pemEncode(String type, byte[] der) {
+ String b64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(der);
+ return "-----BEGIN " + type + "-----\n" + b64 + "\n-----END " + type + "-----\n";
+ }
+
+ private static byte[] stripPemAndDecode(String pem, String expectedType) {
+ String header = "-----BEGIN " + expectedType + "-----";
+ String footer = "-----END " + expectedType + "-----";
+ int headerIdx = pem.indexOf(header);
+ int footerIdx = pem.indexOf(footer);
+ if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) {
+ throw new SDKException("failed to parse PEM block of type " + expectedType);
+ }
+ String body = pem.substring(headerIdx + header.length(), footerIdx).replaceAll("\\s", "");
+ try {
+ return Base64.getDecoder().decode(body);
+ } catch (IllegalArgumentException e) {
+ throw new SDKException("failed to base64-decode PEM body for " + expectedType, e);
+ }
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java
new file mode 100644
index 00000000..91900b90
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java
@@ -0,0 +1,108 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.AesGcm;
+import io.opentdf.platform.sdk.SDKException;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.crypto.SecretWithEncapsulation;
+import org.bouncycastle.pqc.crypto.xwing.XWingKEMExtractor;
+import org.bouncycastle.pqc.crypto.xwing.XWingKEMGenerator;
+import org.bouncycastle.pqc.crypto.xwing.XWingKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.xwing.XWingKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.xwing.XWingPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.xwing.XWingPublicKeyParameters;
+
+import java.security.SecureRandom;
+
+/**
+ * X-Wing (X25519 + ML-KEM-768) KEM with the ASN.1 envelope format used by TDF
+ * {@code hybrid-wrapped} key access objects. Mirrors {@code lib/ocrypto/xwing.go}.
+ *
+ * PEM is now standard SPKI/PKCS#8 with OID {@link HybridSpki#OID_XWING}
+ * ({@code 1.3.6.1.4.1.62253.25722}); custom {@code XWING PUBLIC KEY} block
+ * names are gone (per platform PR #3563, draft-connolly-cfrg-xwing-kem-10).
+ * The raw 1216-byte public key and 32-byte private seed are unchanged —
+ * they're just wrapped in SPKI/PKCS#8.
+ *
+ * The KEM combiner is unchanged (delegated to BC's X-Wing primitive); the
+ * TDF DEK wrap step still uses HKDF-SHA256 with the standard TDF salt.
+ */
+public final class XWingKeyPair {
+
+ static final int PUBLIC_KEY_SIZE = 1216;
+ /** X-Wing private key is a 32-byte seed; full X25519 + ML-KEM-768 components are derived at runtime. */
+ static final int PRIVATE_KEY_SEED_SIZE = 32;
+ static final int CIPHERTEXT_SIZE = 1120;
+ static final int SHARED_SECRET_SIZE = 32;
+
+ private final byte[] publicKey;
+ private final byte[] privateKey;
+
+ private XWingKeyPair(byte[] publicKey, byte[] privateKey) {
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ public static XWingKeyPair generate() {
+ XWingKeyPairGenerator gen = new XWingKeyPairGenerator();
+ gen.init(new XWingKeyGenerationParameters(new SecureRandom()));
+ AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ XWingPublicKeyParameters pub = (XWingPublicKeyParameters) kp.getPublic();
+ XWingPrivateKeyParameters priv = (XWingPrivateKeyParameters) kp.getPrivate();
+ return new XWingKeyPair(pub.getEncoded(), priv.getEncoded());
+ }
+
+ public String publicKeyInPemFormat() {
+ return HybridSpki.encodeSpkiPem(HybridSpki.OID_XWING, publicKey);
+ }
+
+ public String privateKeyInPemFormat() {
+ return HybridSpki.encodePkcs8Pem(HybridSpki.OID_XWING, privateKey);
+ }
+
+ public static byte[] pubKeyFromPem(String pem) {
+ byte[] raw = HybridSpki.decodeSpkiPem(pem, HybridSpki.OID_XWING);
+ if (raw.length != PUBLIC_KEY_SIZE) {
+ throw new SDKException("invalid X-Wing public key size: got " + raw.length + " want " + PUBLIC_KEY_SIZE);
+ }
+ return raw;
+ }
+
+ public static byte[] privateKeyFromPem(String pem) {
+ byte[] raw = HybridSpki.decodePkcs8Pem(pem, HybridSpki.OID_XWING);
+ if (raw.length != PRIVATE_KEY_SEED_SIZE) {
+ throw new SDKException("invalid X-Wing private key seed size: got " + raw.length + " want " + PRIVATE_KEY_SEED_SIZE);
+ }
+ return raw;
+ }
+
+ public static byte[] wrapDEK(byte[] rawPub, byte[] dek) {
+ if (rawPub.length != PUBLIC_KEY_SIZE) {
+ throw new SDKException("invalid X-Wing public key size: got " + rawPub.length + " want " + PUBLIC_KEY_SIZE);
+ }
+ XWingPublicKeyParameters pub = new XWingPublicKeyParameters(rawPub);
+ SecretWithEncapsulation enc = new XWingKEMGenerator(new SecureRandom()).generateEncapsulated(pub);
+ byte[] sharedSecret = enc.getSecret();
+ byte[] ciphertext = enc.getEncapsulation();
+
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
+ return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek);
+ }
+
+ public static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
+ if (rawPriv.length != PRIVATE_KEY_SEED_SIZE) {
+ throw new SDKException("invalid X-Wing private key size: got " + rawPriv.length + " want " + PRIVATE_KEY_SEED_SIZE);
+ }
+ byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[] ciphertext = parts[0];
+ byte[] encryptedDek = parts[1];
+ if (ciphertext.length != CIPHERTEXT_SIZE) {
+ throw new SDKException("invalid X-Wing ciphertext size: got " + ciphertext.length + " want " + CIPHERTEXT_SIZE);
+ }
+
+ XWingPrivateKeyParameters priv = new XWingPrivateKeyParameters(rawPriv);
+ byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext);
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
+ }
+}
diff --git a/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider b/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider
new file mode 100644
index 00000000..cacd6a83
--- /dev/null
+++ b/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider
@@ -0,0 +1 @@
+io.opentdf.platform.sdk.pqc.bc.BouncyCastleKemProvider
diff --git a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
new file mode 100644
index 00000000..84b88242
--- /dev/null
+++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
@@ -0,0 +1,160 @@
+package io.opentdf.platform.sdk;
+
+import io.opentdf.platform.policy.KeyAccessServer;
+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.pqc.bc.HybridNISTAlgorithm;
+import io.opentdf.platform.sdk.pqc.bc.HybridNISTKeyPair;
+import io.opentdf.platform.sdk.pqc.bc.XWingKeyPair;
+import com.connectrpc.ResponseMessage;
+import com.connectrpc.UnaryBlockingCall;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+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:
+ * Implementations live in optional sibling modules (e.g. {@code sdk-pqc-bc}
+ * for the BouncyCastle-backed providers) and are discovered at runtime via
+ * {@link java.util.ServiceLoader}. The core {@code sdk} module has no
+ * compile-time dependency on any PQC library — this keeps the core jar
+ * provider-agnostic per ADR 0001 and lets the {@code fips} Maven profile
+ * exclude PQC entirely.
+ *
+ * Wire-format contract: {@link #wrapDEK} returns the raw bytes that go
+ * into {@code keyAccess.wrappedKey} (after base64 encoding by the caller).
+ * The exact byte layout per {@link KeyType} is fixed by the platform spec
+ * and must be byte-compatible with the Go SDK / KAS counterparts.
+ */
+public interface KemProvider {
+
+ /**
+ * @return the {@link KeyType}s this provider can wrap and unwrap.
+ * Used by {@link KemProviders} to build the dispatch table at
+ * registration time. The returned set must be non-null and should
+ * be unmodifiable and safe for concurrent iteration —
+ * {@link KemProviders} reads it once at registration and may
+ * retain a reference. {@link java.util.EnumSet#of} or
+ * {@link java.util.Collections#unmodifiableSet} are both fine.
+ */
+ Set On first access, scans the classpath for
+ * {@code META-INF/services/io.opentdf.platform.sdk.spi.KemProvider} entries
+ * and builds an unmodifiable {@code KeyType → KemProvider} map. If multiple
+ * providers claim the same {@link KeyType}, the first one discovered wins
+ * (deterministic per classpath order) and a warning is logged.
+ *
+ * {@link #get(KeyType)} throws {@link SDKException} with a clear message
+ * directing the user to add the relevant provider module (typically
+ * {@code sdk-pqc-bc}) when no provider is registered for the requested
+ * {@link KeyType}. This is the FIPS-safe failure mode: no
+ * {@code NoClassDefFoundError}, no startup-time linkage to BouncyCastle.
+ */
+public final class KemProviders {
+
+ private static final Logger logger = LoggerFactory.getLogger(KemProviders.class);
+
+ private static volatile MapWire format
+ *
+ * mlkemEncapsulationKey ‖ ecPointUncompressed
+ *
+ * mlkemSeed(64) ‖ ECPrivateKey(RFC 5915 DER)
+ *
+ * mlkemCiphertext ‖ ephemeralECPointUncompressed
+ *
+ * wrapKey = SHA3-256(mlkemSS ‖ tradSS ‖ tradCT ‖ tradPK ‖ Label)
+ * where {@code tradSS} is the ECDH shared secret (x-coordinate, left-padded
+ * to the curve's field size), {@code tradCT} is the ephemeral EC public key
+ * (uncompressed), {@code tradPK} is the recipient's EC public key
+ * (uncompressed), and {@code Label} is the scheme-specific ASCII string
+ * (e.g. {@code "MLKEM768-P256"}). The 32-byte SHA3-256 output is used
+ * directly as the AES-256 wrap key — no HKDF step.
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+class TDFHybridTest {
+
+ private static KeyAccessServerRegistryServiceClient kasRegistryService;
+
+ @BeforeAll
+ static void setupMocks() {
+ kasRegistryService = mock(KeyAccessServerRegistryServiceClient.class);
+ ListKeyAccessServersResponse mockResponse = ListKeyAccessServersResponse.newBuilder()
+ .addKeyAccessServers(KeyAccessServer.newBuilder().setUri("https://kas.example.com").build())
+ .build();
+ when(kasRegistryService.listKeyAccessServersBlocking(any(ListKeyAccessServersRequest.class), any()))
+ .thenReturn(new UnaryBlockingCall<>() {
+ @Override
+ public ResponseMessage