diff --git a/sdk/pom.xml b/sdk/pom.xml
index 64497996..7ba768da 100644
--- a/sdk/pom.xml
+++ b/sdk/pom.xml
@@ -16,7 +16,7 @@
2.1.0
0.7.2
4.12.0
- protocol/go/v0.16.0
+ DSPX-2399-platform-proto
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
index 06be4cf6..70d99a68 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
@@ -14,7 +14,8 @@ public enum KeyType {
RSA4096Key("rsa:4096"),
EC256Key("ec:secp256r1", SECP256R1),
EC384Key("ec:secp384r1", SECP384R1),
- EC521Key("ec:secp521r1", SECP521R1);
+ EC521Key("ec:secp521r1", SECP521R1),
+ MLKEM768Key("mlkem:768");
private final String keyType;
private final ECCurve curve;
@@ -65,6 +66,8 @@ public static KeyType fromAlgorithm(Algorithm algorithm) {
return KeyType.EC384Key;
case ALGORITHM_EC_P521:
return KeyType.EC521Key;
+ case ALGORITHM_ML_KEM_768:
+ return KeyType.MLKEM768Key;
default:
throw new IllegalArgumentException("Unsupported algorithm: " + algorithm);
}
@@ -85,6 +88,8 @@ public static KeyType fromPublicKeyAlgorithm(KasPublicKeyAlgEnum algorithm) {
return KeyType.EC384Key;
case KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP521R1:
return KeyType.EC521Key;
+ case KAS_PUBLIC_KEY_ALG_ENUM_MLKEM_768:
+ return KeyType.MLKEM768Key;
default:
throw new IllegalArgumentException("Unsupported algorithm: " + algorithm);
}
@@ -93,4 +98,8 @@ public static KeyType fromPublicKeyAlgorithm(KasPublicKeyAlgEnum algorithm) {
public boolean isEc() {
return this.curve != null;
}
+
+ public boolean isMlkem() {
+ return this == MLKEM768Key;
+ }
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/MLKEMEncryption.java b/sdk/src/main/java/io/opentdf/platform/sdk/MLKEMEncryption.java
new file mode 100644
index 00000000..70bce45c
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/MLKEMEncryption.java
@@ -0,0 +1,60 @@
+package io.opentdf.platform.sdk;
+
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.crypto.SecretWithEncapsulation;
+import org.bouncycastle.crypto.util.PublicKeyFactory;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.security.SecureRandom;
+
+/**
+ * Handles ML-KEM-768 key encapsulation for wrapping a symmetric DEK.
+ *
+ * Wire format: base64(ml_kem_ciphertext [1088 bytes] || aes_gcm_wrapped_dek)
+ * No ephemeralPublicKey field; KeyAccess type is "wrapped".
+ */
+class MLKEMEncryption {
+
+ /** ML-KEM-768 ciphertext is always 1088 bytes. */
+ static final int CIPHERTEXT_SIZE = 1088;
+
+ private final MLKEMPublicKeyParameters publicKeyParams;
+
+ MLKEMEncryption(String pemPublicKey) {
+ try {
+ PEMParser parser = new PEMParser(new StringReader(pemPublicKey));
+ SubjectPublicKeyInfo spki = (SubjectPublicKeyInfo) parser.readObject();
+ parser.close();
+ publicKeyParams = (MLKEMPublicKeyParameters) PublicKeyFactory.createKey(spki);
+ } catch (IOException e) {
+ throw new SDKException("error parsing ML-KEM-768 public key", e);
+ } catch (ClassCastException e) {
+ throw new SDKException("public key is not an ML-KEM key", e);
+ }
+ }
+
+ /**
+ * Encapsulates against the KAS ML-KEM-768 public key and AES-GCM wraps the DEK.
+ *
+ * @return ciphertext (1088 bytes) concatenated with the AES-GCM wrapped DEK
+ */
+ byte[] encapsulateAndWrap(byte[] dek) {
+ MLKEMGenerator kemGen = new MLKEMGenerator(new SecureRandom());
+ SecretWithEncapsulation swe = kemGen.generateEncapsulated(publicKeyParams);
+
+ byte[] ciphertext = swe.getEncapsulation();
+ byte[] sharedSecret = swe.getSecret();
+
+ byte[] sessionKey = ECKeyPair.calculateHKDF(TDF.GLOBAL_KEY_SALT, sharedSecret);
+ byte[] aesWrappedDek = new AesGcm(sessionKey).encrypt(dek).asBytes();
+
+ byte[] combined = new byte[ciphertext.length + aesWrappedDek.length];
+ System.arraycopy(ciphertext, 0, combined, 0, ciphertext.length);
+ System.arraycopy(aesWrappedDek, 0, combined, ciphertext.length, aesWrappedDek.length);
+ return combined;
+ }
+}
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 3ee4ba22..9e77d5ef 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -233,6 +233,9 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA
keyAccess.wrappedKey = ecKeyWrappedKeyInfo.wrappedKey;
keyAccess.ephemeralPublicKey = ecKeyWrappedKeyInfo.publicKey;
keyAccess.keyType = kECWrapped;
+ } else if (keyType.isMlkem()) {
+ keyAccess.wrappedKey = createMLKEMWrappedKey(kasInfo, symKey);
+ keyAccess.keyType = kWrapped;
} else {
keyAccess.wrappedKey = createRSAWrappedKey(kasInfo, symKey);
keyAccess.keyType = kWrapped;
@@ -264,6 +267,11 @@ private String createRSAWrappedKey(Config.KASInfo kasInfo, byte[] symKey) {
byte[] wrappedKey = asymEncrypt.encrypt(symKey);
return Base64.getEncoder().encodeToString(wrappedKey);
}
+
+ private String createMLKEMWrappedKey(Config.KASInfo kasInfo, byte[] symKey) {
+ MLKEMEncryption mlkem = new MLKEMEncryption(kasInfo.PublicKey);
+ return Base64.getEncoder().encodeToString(mlkem.encapsulateAndWrap(symKey));
+ }
}
private static final Base64.Decoder decoder = Base64.getDecoder();
diff --git a/xtest/sdk/java/cli.sh b/xtest/sdk/java/cli.sh
new file mode 100644
index 00000000..d7707600
--- /dev/null
+++ b/xtest/sdk/java/cli.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Cross-SDK test CLI helper for the OpenTDF Java SDK.
+# Called by the xtest harness to check feature support and run encrypt/decrypt ops.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+JAR="${REPO_ROOT}/cmdline/target/cmdline.jar"
+
+_jar_help() {
+ java -jar "${JAR}" "$@" --help 2>&1 || true
+}
+
+case "${1:-}" in
+ supports)
+ feature="${2:-}"
+ case "$feature" in
+ mechanism-mlkem)
+ # mlkem:768 is a valid --encap-key-type value; picocli lists it in the
+ # encrypt help as a COMPLETION-CANDIDATE from KeyType.MLKEM768Key.toString()
+ _jar_help encrypt | grep -q "mlkem:768"
+ ;;
+ *)
+ exit 1
+ ;;
+ esac
+ ;;
+ encrypt)
+ shift
+ java -jar "${JAR}" encrypt "$@"
+ ;;
+ decrypt)
+ shift
+ java -jar "${JAR}" decrypt "$@"
+ ;;
+ *)
+ echo "usage: $0 {supports |encrypt ...|decrypt ...}" >&2
+ exit 1
+ ;;
+esac