Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmdline/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,13 @@
<artifactId>sdk</artifactId>
<version>${project.version}</version>
</dependency>
<!-- BouncyCastle-backed HybridKeyWrapProvider SPI implementation.
Discovered at runtime via ServiceLoader; required for the
cmdline tool to wrap DEKs against hybrid PQ KAS keys. -->
<dependency>
<groupId>io.opentdf.platform</groupId>
<artifactId>sdk-hybrid-bouncycastle</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@
<id>develop</id>
<modules>
<module>sdk</module>
<module>sdk-hybrid-bouncycastle</module>
<module>cmdline</module>
<module>examples</module>
</modules>
Expand All @@ -297,6 +298,7 @@
<id>stage</id>
<modules>
<module>sdk</module>
<module>sdk-hybrid-bouncycastle</module>
</modules>
<activation>
<activeByDefault>false</activeByDefault>
Expand Down Expand Up @@ -329,6 +331,7 @@
<id>release</id>
<modules>
<module>sdk</module>
<module>sdk-hybrid-bouncycastle</module>
</modules>
<activation>
<activeByDefault>false</activeByDefault>
Expand Down Expand Up @@ -367,6 +370,7 @@
<id>coverage</id>
<modules>
<module>sdk</module>
<module>sdk-hybrid-bouncycastle</module>
</modules>
<properties>
<sonar.organization>opentdf</sonar.organization>
Expand Down
60 changes: 60 additions & 0 deletions sdk-hybrid-bouncycastle/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.opentdf.platform</groupId>
<artifactId>sdk-pom</artifactId>
<version>0.15.0</version>
</parent>
<artifactId>sdk-hybrid-bouncycastle</artifactId>
<name>io.opentdf.platform:sdk-hybrid-bouncycastle</name>
<description>BouncyCastle-backed HybridKeyWrapProvider SPI implementation
(X-Wing and NIST EC + ML-KEM hybrid post-quantum key wrapping).</description>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.opentdf.platform</groupId>
<artifactId>sdk</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Hybrid PQ key wrapping requires BC's low-level X-Wing and ML-KEM APIs
(no JDK 11 stdlib equivalent; provider-agnostic JCA APIs for KEM
are JDK 21+). See adr/0001 for context. -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<!-- Test-jar from sdk exposes FakeServicesBuilder and other package-private
test helpers needed by TDFHybridTest (which lives in package
io.opentdf.platform.sdk to keep access to the package-private TDF class). -->
<dependency>
<groupId>io.opentdf.platform</groupId>
<artifactId>sdk</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Comment on lines +30 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Java, switching on a null reference throws a NullPointerException. To prevent runtime failures, add defensive null checks for keyType, publicKeyPem, and dek before executing the switch statement.

    public byte[] wrapDEK(KeyType keyType, String publicKeyPem, byte[] dek) {
        if (keyType == null) {
            throw new SDKException("keyType cannot be null");
        }
        if (publicKeyPem == null) {
            throw new SDKException("publicKeyPem cannot be null");
        }
        if (dek == null) {
            throw new SDKException("dek cannot be null");
        }
        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);
}
}
Comment on lines +46 to +59
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Java, switching on a null reference throws a NullPointerException. To prevent runtime failures, add defensive null checks for keyType, privateKeyPem, and wrappedDek before executing the switch statement.

    public byte[] unwrapDEK(KeyType keyType, String privateKeyPem, byte[] wrappedDek) {
        if (keyType == null) {
            throw new SDKException("keyType cannot be null");
        }
        if (privateKeyPem == null) {
            throw new SDKException("privateKeyPem cannot be null");
        }
        if (wrappedDek == null) {
            throw new SDKException("wrappedDek cannot be null");
        }
        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);
        }
    }

}
Original file line number Diff line number Diff line change
@@ -1,48 +1,31 @@
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 }
*
* 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.
Expand Down Expand Up @@ -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; }
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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();
Expand All @@ -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()) {
Expand All @@ -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));
}

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Comment on lines +31 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Java, switching on a null reference throws a NullPointerException. Add a defensive null check for keyType before executing the switch statement.

    public static PemPair generate(KeyType keyType) {
        if (keyType == null) {
            throw new SDKException("keyType cannot be null");
        }
        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);
}
}
}
Loading
Loading