diff --git a/scripts/README.md b/scripts/README.md index 6aaab6e4..46687b0c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -102,13 +102,59 @@ attempted), 2 on misuse. | `keyType='null'` (manifest assertion) | You're on an old branch where `TDF.java` doesn't yet route hybrid algorithms. Pull the latest branch HEAD. | | `decrypt failed` after manifest passes | KAS-side rewrap doesn't yet support the `hybrid-wrapped` keyType. Check the platform branch has the matching server change. | -### Known SDK gap - -`KeyType.fromAlgorithm` and `KeyType.fromPublicKeyAlgorithm` -(`sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java`) don't yet map the -hybrid algorithm protobuf enums. Auto-discovery via the KAS registry -(`Config.KASInfo.fromKeyAccessServer`) will throw `IllegalArgumentException` -once the platform's proto definitions include `KAS_PUBLIC_KEY_ALG_ENUM_HPQT_*` -values. This script bypasses that path by using `--encap-key-type` explicitly; -extending the script to also exercise registry-discovery should wait until the -mapping is added. +## `test-mlkem.sh` + +End-to-end test of the Java SDK's pure ML-KEM (FIPS 203) key wrapping +(`mlkem:768`, `mlkem:1024`) against a locally running OpenTDF platform. +Same shape as `test-hybrid-pqc.sh` (encrypt → assert manifest → KAS rewrap +→ decrypt → diff) with two pure-ML-KEM specifics: + +- `keyAccess[0].type` is `"mlkem-wrapped"` — its own KAO scheme, distinct + from `"hybrid-wrapped"` and from RSA's `"wrapped"`. The KAS uses this + to skip HKDF on the wrap key (pure ML-KEM uses the 32-byte FIPS 203 + Decaps shared secret directly as the AES-256 key; HKDF is dropped per + the platform ADR + [`2026-06-16-mlkem-direct-key-wrap.md`](https://github.com/opentdf/platform/blob/main/adr/decisions/2026-06-16-mlkem-direct-key-wrap.md)). +- Pre-flight OIDs are the NIST FIPS 203 OIDs: + `2.16.840.1.101.3.4.4.2` for ML-KEM-768 and + `2.16.840.1.101.3.4.4.3` for ML-KEM-1024. + +The wire envelope (ASN.1 SEQUENCE of two implicit OCTET STRINGs — +`{ [0] kemCiphertext, [1] AES-GCM(IV ‖ DEK ‖ tag) }`) is byte-identical +to the hybrid path; the script's 0x30-first-byte check is the same +invariant `test-hybrid-pqc.sh` uses. + +### Run it + +```bash +# Full run — builds cmdline, pre-flight check, both variants +PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-mlkem.sh + +# One variant only +scripts/test-mlkem.sh --algorithms MLKEM768Key + +# Reuse an already-built cmdline jar (much faster on iterative runs) +scripts/test-mlkem.sh --skip-build +``` + +All other flags (`--platform-endpoint`, `--kas-url`, `--client-id`, +`--client-secret`, `--attr`, `--skip-kas-check`) match +`test-hybrid-pqc.sh` — see the configuration table above. + +### Prerequisites + +Same as `test-hybrid-pqc.sh`. The KAS-side requirement is that +`mlkem:768` (and optionally `mlkem:1024`) public keys are registered. + +### Known SDK gap (pure ML-KEM) + +`KeyType.fromAlgorithm` / `fromPublicKeyAlgorithm` don't yet map the pure +ML-KEM protobuf enums. The platform proto stubs we currently build against +(`protocol/go/v0.34.0`) only have the hybrid `ALGORITHM_HPQT_*` set — no +`ALGORITHM_MLKEM_768` / `_1024`. Until the platform release we depend on +adds those values, registry-discovery via +`Config.KASInfo.fromKeyAccessServer` will throw `IllegalArgumentException` +for ML-KEM. This script sidesteps it by passing `--encap-key-type=MLKEM*Key` +explicitly. When the proto bump lands, add two cases to each switch in +`KeyType.java` and the script can also be extended to exercise the +registry-discovery path. diff --git a/scripts/test-mlkem.sh b/scripts/test-mlkem.sh new file mode 100755 index 00000000..f3dab3dc --- /dev/null +++ b/scripts/test-mlkem.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# +# test-mlkem.sh — round-trip the Java SDK's pure ML-KEM (FIPS 203) key +# wrapping against a locally running OpenTDF platform. +# +# Per algorithm: encrypt → assert manifest → KAS rewrap → decrypt → diff. +# +# Differs from test-hybrid-pqc.sh in two places: +# * keyAccess[0].type == "mlkem-wrapped" (its own KAO scheme — distinct +# from "hybrid-wrapped" and from RSA's "wrapped"). The KAS uses this +# to skip HKDF on the wrap key. See platform PR #3562 and the ADR at +# adr/decisions/2026-06-16-mlkem-direct-key-wrap.md. +# * SPKI OIDs are the NIST FIPS 203 ones (2.16.840.1.101.3.4.4.{2,3}). +# Wire envelope (ASN.1 SEQUENCE of two implicit OCTET STRINGs) and the +# 0x30-prefix invariant match the hybrid path byte-for-byte. +# +# Prereqs: +# * Local platform up at $PLATFORM_ENDPOINT with ML-KEM KAS keys registered +# for mlkem:768 and (optionally) mlkem:1024 +# * java, mvn (JDK 17), unzip, jq on PATH +# * grpcurl optional (used only for the pre-flight key-publication check) +# +# Usage: +# scripts/test-mlkem.sh # full run, both variants +# scripts/test-mlkem.sh --skip-build # reuse existing jar +# scripts/test-mlkem.sh --skip-kas-check # skip grpcurl pre-flight +# scripts/test-mlkem.sh --algorithms MLKEM768Key # subset +# PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-mlkem.sh +# +# See scripts/README.md for a full prereq + troubleshooting guide. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +JAR="$REPO_ROOT/cmdline/target/cmdline.jar" + +PLATFORM_ENDPOINT="${PLATFORM_ENDPOINT:-http://localhost:8080}" +KAS_URL="${KAS_URL:-$PLATFORM_ENDPOINT}" +CLIENT_ID="${CLIENT_ID:-opentdf-sdk}" +CLIENT_SECRET="${CLIENT_SECRET:-secret}" +DATA_ATTR="${DATA_ATTR:-https://example.com/attr/attr1/value/value1}" +ALGORITHMS=(MLKEM768Key MLKEM1024Key) +SKIP_BUILD=0 +SKIP_KAS_CHECK=0 + +# With `set -u`, a bare `$2` for a value-taking flag with no argument would +# crash with "unbound variable" instead of the documented exit 2 misuse path. +require_opt_value() { + local opt="$1" + local val="${2-}" + if [[ -z "$val" || "$val" == --* ]]; then + echo "missing value for $opt" >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --skip-kas-check) SKIP_KAS_CHECK=1; shift ;; + --algorithms) require_opt_value "$1" "${2-}"; IFS=, read -r -a ALGORITHMS <<< "$2"; shift 2 ;; + --platform-endpoint) require_opt_value "$1" "${2-}"; PLATFORM_ENDPOINT="$2"; shift 2 ;; + --kas-url) require_opt_value "$1" "${2-}"; KAS_URL="$2"; shift 2 ;; + --attr) require_opt_value "$1" "${2-}"; DATA_ATTR="$2"; shift 2 ;; + --client-id) require_opt_value "$1" "${2-}"; CLIENT_ID="$2"; shift 2 ;; + --client-secret) require_opt_value "$1" "${2-}"; CLIENT_SECRET="$2"; shift 2 ;; + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +# Map KeyType enum name → the mlkem:* algorithm string the KAS expects. +# Function form (instead of `declare -A`) so this works on macOS bash 3.2. +alg_to_string() { + case "$1" in + MLKEM768Key) echo "mlkem:768" ;; + MLKEM1024Key) echo "mlkem:1024" ;; + *) return 1 ;; + esac +} + +# Map KeyType enum name → expected SPKI OID inside the standard PUBLIC KEY PEM +# (NIST FIPS 203). The pre-flight extracts via openssl asn1parse and compares. +alg_to_oid() { + case "$1" in + MLKEM768Key) echo "2.16.840.1.101.3.4.4.2" ;; + MLKEM1024Key) echo "2.16.840.1.101.3.4.4.3" ;; + *) return 1 ;; + esac +} + +WORK_DIR="$(mktemp -d -t mlkem-XXXXXX)" +trap 'rm -rf "$WORK_DIR"' EXIT + +if [[ -t 1 ]]; then + GREEN=$'\033[0;32m'; RED=$'\033[0;31m'; YELLOW=$'\033[0;33m'; RESET=$'\033[0m' +else + GREEN=''; RED=''; YELLOW=''; RESET='' +fi +pass() { echo "${GREEN}[OK]${RESET} $*"; } +fail() { echo "${RED}[FAIL]${RESET} $*"; } +info() { echo "${YELLOW}[..]${RESET} $*"; } + +require() { command -v "$1" >/dev/null 2>&1 || { fail "missing required tool: $1"; exit 2; }; } +require java; require unzip; require jq +[[ $SKIP_BUILD -eq 1 ]] || require mvn + +# Portable base64 decode: GNU/BusyBox accept `-d`, BSD/macOS prior to 12 use `-D`. +if printf 'MA==\n' | base64 -d >/dev/null 2>&1; then + BASE64_DECODE_FLAG="-d" +elif printf 'MA==\n' | base64 -D >/dev/null 2>&1; then + BASE64_DECODE_FLAG="-D" +else + fail "neither 'base64 -d' nor 'base64 -D' works on this system"; exit 2 +fi +b64decode() { base64 "$BASE64_DECODE_FLAG"; } + +run_cmdline() { + java -jar "$JAR" \ + --client-id="$CLIENT_ID" \ + --client-secret="$CLIENT_SECRET" \ + --platform-endpoint="$PLATFORM_ENDPOINT" \ + -h "$@" +} + +##### 1. Build +if [[ $SKIP_BUILD -eq 0 ]]; then + info "Building cmdline (mvn clean install -DskipTests)" + build_log="$WORK_DIR/build.log" + if ! (cd "$REPO_ROOT" && mvn --batch-mode clean install -DskipTests) > "$build_log" 2>&1; then + fail "Maven build failed. Tail of build log:" + tail -40 "$build_log" | sed 's/^/ /' + if grep -q "Buf API token" "$build_log" 2>/dev/null; then + fail "Hint: run 'buf registry login' or export BUF_INPUT_HTTPS_USERNAME / BUF_INPUT_HTTPS_PASSWORD before retrying." + fi + exit 1 + fi + pass "Build complete" +else + info "Skipping build (--skip-build)" +fi +[[ -f "$JAR" ]] || { fail "jar not found at $JAR — run without --skip-build"; exit 1; } + +##### 2. Pre-flight: confirm KAS publishes ML-KEM keys +if [[ $SKIP_KAS_CHECK -eq 0 ]] && command -v grpcurl >/dev/null 2>&1; then + info "Pre-flight: querying KAS for ML-KEM public keys" + host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}"; host="${host%/}" + for alg_name in "${ALGORITHMS[@]}"; do + if ! alg=$(alg_to_string "$alg_name"); then + fail "unknown algorithm: $alg_name"; exit 2 + fi + resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ + "$host" kas.AccessService/PublicKey 2>&1 || true) + pem=$(jq -r '.publicKey // empty' <<<"$resp" 2>/dev/null || true) + if [[ -z "$pem" ]]; then + fail "$alg: KAS returned no publicKey. Response was:" + echo "$resp" | head -5 | sed 's/^/ /' + fail "Is the platform running with the ML-KEM-capable KAS branch and the key registered?" + exit 1 + fi + first_line=$(echo "$pem" | head -1) + if [[ "$first_line" != *"BEGIN PUBLIC KEY"* ]]; then + fail "$alg: KAS returned a non-SPKI PEM (first line: $first_line)" + exit 1 + fi + expected_oid=$(alg_to_oid "$alg_name") + if command -v openssl >/dev/null 2>&1; then + actual_oid=$(printf '%s\n' "$pem" | openssl asn1parse 2>/dev/null \ + | awk '/OBJECT/ {sub(/^:/, "", $NF); print $NF; exit}') + if [[ -z "$actual_oid" ]]; then + fail "$alg: could not extract SPKI OID via openssl asn1parse" + exit 1 + fi + if [[ "$actual_oid" != "$expected_oid" ]]; then + fail "$alg: SPKI OID mismatch — expected $expected_oid, got $actual_oid" + exit 1 + fi + pass "$alg: KAS returns SPKI PEM with OID $actual_oid" + else + pass "$alg: KAS returns SPKI PEM (openssl not available; OID not verified)" + fi + done +else + info "Skipping KAS pre-flight check" +fi + +##### 3. Round-trip each algorithm +PAYLOAD="$WORK_DIR/payload" +printf 'pure ml-kem round-trip payload @ %s\n' "$(date)" > "$PAYLOAD" +PAYLOAD_BYTES=$(wc -c < "$PAYLOAD" | tr -d ' ') +info "Test payload: $PAYLOAD_BYTES bytes" +echo " --- plaintext ---" +sed 's/^/ /' < "$PAYLOAD" +echo " --- end plaintext ---" + +failures=() +for alg_name in "${ALGORITHMS[@]}"; do + tdf="$WORK_DIR/test-${alg_name}.tdf" + out="$WORK_DIR/out-${alg_name}" + enc_log="$WORK_DIR/encrypt-${alg_name}.log" + dec_log="$WORK_DIR/decrypt-${alg_name}.log" + + info "[$alg_name] encrypt" + if ! run_cmdline encrypt \ + --kas-url="$KAS_URL" \ + --mime-type=text/plain \ + --attr="$DATA_ATTR" \ + --autoconfigure=false \ + --encap-key-type="$alg_name" \ + -f "$PAYLOAD" > "$tdf" 2> "$enc_log"; then + fail "$alg_name: encrypt failed" + sed 's/^/ /' < "$enc_log" + failures+=("$alg_name (encrypt)") + continue + fi + + info "[$alg_name] verify manifest" + manifest_entry=$(unzip -l "$tdf" 2>/dev/null | awk '/manifest\.json$/ {print $NF; exit}') + if [[ -z "$manifest_entry" ]]; then + fail "$alg_name: no manifest.json entry inside $tdf" + failures+=("$alg_name (manifest entry missing)") + continue + fi + manifest=$(unzip -p "$tdf" "$manifest_entry") + # In Manifest.java, the Java field `keyType` is annotated with + # @SerializedName("type"), so the JSON key is "type" (not "keyType"). + keyType=$(jq -r '.encryptionInformation.keyAccess[0].type' <<<"$manifest") + ephem=$(jq -r '.encryptionInformation.keyAccess[0].ephemeralPublicKey // ""' <<<"$manifest") + wrapped=$(jq -r '.encryptionInformation.keyAccess[0].wrappedKey // ""' <<<"$manifest") + if [[ "$keyType" != "mlkem-wrapped" ]]; then + fail "$alg_name: type='$keyType' (expected 'mlkem-wrapped')" + echo " keyAccess[0]:" + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" 2>/dev/null | sed 's/^/ /' + failures+=("$alg_name (bad type: $keyType)") + continue + fi + if [[ -n "$ephem" ]]; then + fail "$alg_name: ephemeralPublicKey unexpectedly set ('$ephem')" + failures+=("$alg_name (stray ephemeralPublicKey)") + continue + fi + if [[ -z "$wrapped" ]]; then + fail "$alg_name: wrappedKey is empty" + failures+=("$alg_name (empty wrappedKey)") + continue + fi + # ASN.1 SEQUENCE always starts with 0x30 — same invariant the hybrid path checks. + first_byte=$(b64decode <<<"$wrapped" 2>/dev/null | od -An -tx1 -N1 | tr -d ' \n' || true) + if [[ "$first_byte" != "30" ]]; then + fail "$alg_name: wrappedKey does not start with ASN.1 SEQUENCE (got 0x$first_byte)" + failures+=("$alg_name (bad envelope)") + continue + fi + pass "$alg_name: manifest OK (mlkem-wrapped, ASN.1 envelope, no ephemeralPublicKey)" + echo " --- keyAccess[0] (KAO) ---" + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" | sed 's/^/ /' + echo " --- end keyAccess[0] ---" + + info "[$alg_name] decrypt (rewrap via KAS)" + if ! run_cmdline decrypt -f "$tdf" > "$out" 2> "$dec_log"; then + fail "$alg_name: decrypt failed" + sed 's/^/ /' < "$dec_log" + failures+=("$alg_name (decrypt)") + continue + fi + if ! diff -q "$PAYLOAD" "$out" >/dev/null; then + fail "$alg_name: decrypted payload differs from original" + echo " --- expected (first 200 bytes) ---" + head -c 200 "$PAYLOAD" | sed 's/^/ /' + echo + echo " --- got (first 200 bytes) ---" + head -c 200 "$out" | sed 's/^/ /' + echo + failures+=("$alg_name (payload mismatch)") + continue + fi + pass "$alg_name: round-trip OK" + out_bytes=$(wc -c < "$out" | tr -d ' ') + echo " --- decrypted ($out_bytes bytes) ---" + sed 's/^/ /' < "$out" + echo " --- end decrypted ---" +done + +echo +if [[ ${#failures[@]} -eq 0 ]]; then + echo "${GREEN}All ${#ALGORITHMS[@]} ML-KEM algorithm(s) passed round-trip.${RESET}" + exit 0 +else + echo "${RED}FAILURES (${#failures[@]}):${RESET}" + printf ' - %s\n' "${failures[@]}" + exit 1 +fi diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java index a1fc583d..cccad461 100644 --- a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java +++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java @@ -7,12 +7,14 @@ import java.util.Set; /** - * BouncyCastle-backed {@link KemProvider}. Supports the three hybrid PQC + * BouncyCastle-backed {@link KemProvider}. Supports the post-quantum * {@link KeyType}s currently defined in the SDK: *
Discovered by {@link io.opentdf.platform.sdk.spi.KemProviders} via
@@ -25,7 +27,9 @@ public final class BouncyCastleKemProvider implements KemProvider {
private static final Set Wire envelope is identical across both families — an ASN.1 DER SEQUENCE
+ * with two IMPLICIT context-tagged OCTET STRINGs (built via
+ * {@link #marshalEnvelope}):
+ * The DEK is AES-256-GCM sealed (12-byte IV prefix + 16-byte tag suffix).
+ * What differs between the two families is the AES-256 wrap key:
+ * The KAO {@code type} field disambiguates the two on the wire:
+ * {@code "hybrid-wrapped"} for hybrid, {@code "mlkem-wrapped"} for pure ML-KEM.
*/
final class HybridCrypto {
@@ -30,10 +45,10 @@ final class HybridCrypto {
private HybridCrypto() {}
/**
- * Wrap a DEK against a hybrid public-key PEM. Single dispatch site for all
- * hybrid algorithms — {@link BouncyCastleKemProvider} delegates here so a
- * new hybrid algorithm only needs one switch update.
- * Returns the ASN.1-encoded envelope used in {@code wrappedKey} for {@code hybrid-wrapped} key access.
+ * Wrap a DEK against a PQC public-key PEM. Single dispatch site for all
+ * algorithms ({@link BouncyCastleKemProvider} delegates here) so adding
+ * a new algorithm is one switch case in each direction. Output bytes
+ * are the raw envelope; caller base64-encodes for {@code keyAccess.wrappedKey}.
*/
static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
switch (keyType) {
@@ -45,8 +60,14 @@ static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
case HybridSecp384r1MLKEM1024Key:
return HybridNISTAlgorithm.P384_MLKEM1024.wrapDEK(
HybridNISTAlgorithm.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek);
+ case MLKEM768Key:
+ return MLKEMAlgorithm.MLKEM_768.wrapDEK(
+ MLKEMAlgorithm.MLKEM_768.pubKeyFromPem(publicKeyPEM), dek);
+ case MLKEM1024Key:
+ return MLKEMAlgorithm.MLKEM_1024.wrapDEK(
+ MLKEMAlgorithm.MLKEM_1024.pubKeyFromPem(publicKeyPEM), dek);
default:
- throw new SDKException("unsupported hybrid key type: " + keyType);
+ throw new SDKException("unsupported PQC key type: " + keyType);
}
}
@@ -64,8 +85,14 @@ static byte[] unwrapDEK(KeyType keyType, String privateKeyPEM, byte[] wrapped) {
case HybridSecp384r1MLKEM1024Key:
return HybridNISTAlgorithm.P384_MLKEM1024.unwrapDEK(
HybridNISTAlgorithm.P384_MLKEM1024.privateKeyFromPem(privateKeyPEM), wrapped);
+ case MLKEM768Key:
+ return MLKEMAlgorithm.MLKEM_768.unwrapDEK(
+ MLKEMAlgorithm.MLKEM_768.privateKeyFromPem(privateKeyPEM), wrapped);
+ case MLKEM1024Key:
+ return MLKEMAlgorithm.MLKEM_1024.unwrapDEK(
+ MLKEMAlgorithm.MLKEM_1024.privateKeyFromPem(privateKeyPEM), wrapped);
default:
- throw new SDKException("unsupported hybrid key type: " + keyType);
+ throw new SDKException("unsupported PQC key type: " + keyType);
}
}
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
index f9d36cde..f8545596 100644
--- 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
@@ -18,23 +18,30 @@
/**
* 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.
+ * ({@code OneAsymmetricKey}) encode/parse helpers for KEM public/private
+ * keys, plus RFC 5915 {@code ECPrivateKey} encode/parse for the EC half of
+ * NIST hybrid private keys. Despite the {@code Hybrid} in the class name,
+ * the encode/decode logic is algorithm-agnostic — it serves pure ML-KEM
+ * (FIPS 203) the same as the hybrid schemes.
*
- * The new (post-PR #3563) PEM format for all three hybrid algorithms is the
- * standard {@code -----BEGIN PUBLIC KEY-----} / {@code -----BEGIN PRIVATE KEY-----}
+ * The PEM format for all supported 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.
+ * correct scheme.
*
- * OIDs (params absent for all three):
+ * Hybrid OIDs (params absent for all three):
* Pure ML-KEM OIDs (NIST FIPS 203, params absent):
+ * 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.
*/
@@ -43,6 +50,8 @@ 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");
+ static final ASN1ObjectIdentifier OID_MLKEM768 = new ASN1ObjectIdentifier("2.16.840.1.101.3.4.4.2");
+ static final ASN1ObjectIdentifier OID_MLKEM1024 = new ASN1ObjectIdentifier("2.16.840.1.101.3.4.4.3");
private static final String PEM_TYPE_PUBLIC = "PUBLIC KEY";
private static final String PEM_TYPE_PRIVATE = "PRIVATE KEY";
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMAlgorithm.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMAlgorithm.java
new file mode 100644
index 00000000..09b727ca
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMAlgorithm.java
@@ -0,0 +1,182 @@
+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 java.security.SecureRandom;
+
+/**
+ * Stateless parameters + operations for a pure ML-KEM (FIPS 203) algorithm
+ * variant. Conforms to opentdf/platform PR #3562 (the open replacement for the
+ * closed PR #3491). The wire envelope is the same ASN.1 SEQUENCE the hybrid
+ * PQC path uses; the only thing that differs from {@link HybridNISTAlgorithm}
+ * is the AES wrap-key derivation — see {@link #wrapDEK(byte[], byte[])} below.
+ *
+ * Public key ({@link #publicKeySize()} bytes inside the SPKI BIT STRING):
+ * the raw ML-KEM encapsulation key, FIPS 203 encoded.
+ *
+ * Private key (64 bytes inside the PKCS#8 OCTET STRING):
+ * the ML-KEM seed {@code (d || z)} per FIPS 203 §6.
+ *
+ * Wrapped DEK envelope — same ASN.1 SEQUENCE as
+ * {@link HybridCrypto#marshalEnvelope(byte[], byte[])}:
+ * AES-256 wrap key: the 32-byte ML-KEM Decaps shared secret is used
+ * directly as the AES key — no HKDF. Rationale (per platform ADR
+ * {@code adr/decisions/2026-06-16-mlkem-direct-key-wrap.md}): HSM-backed KAS
+ * providers (Thales Luna T-Series firmware 7.15.1, strict-FIPS) can only emit
+ * the Decaps output as a non-extractable {@code CKK_AES} object, so an HKDF
+ * step would block HSM unwrap. FIPS 203 §6.3/§7.3 guarantees the Decaps
+ * output is a uniformly-random 32-byte string; HKDF would not add entropy.
+ * The {@code "mlkem-wrapped"} KAO type itself is the domain-separation tag
+ * HKDF's {@code info} would have provided. Hybrid PQC schemes still need HKDF
+ * because there the KDF is the combiner for the two shared-secret halves.
+ *
+ * The KAO field {@code type} is {@code "mlkem-wrapped"} (its own scheme —
+ * distinct from {@code "wrapped"} which RSA uses and {@code "hybrid-wrapped"}
+ * which the hybrid schemes use). {@code ephemeralPublicKey} is absent.
+ *
+ * ML-KEM primitives come from BouncyCastle's low-level API — JDK 11
+ * stdlib has no KEM API (added in JDK 21).
+ */
+public final class MLKEMAlgorithm {
+
+ public static final MLKEMAlgorithm MLKEM_768 = new MLKEMAlgorithm(
+ MLKEMParameters.ml_kem_768,
+ /* publicKeySize */ 1184,
+ /* ciphertextSize */ 1088,
+ HybridSpki.OID_MLKEM768,
+ KeyType.MLKEM768Key);
+
+ public static final MLKEMAlgorithm MLKEM_1024 = new MLKEMAlgorithm(
+ MLKEMParameters.ml_kem_1024,
+ /* publicKeySize */ 1568,
+ /* ciphertextSize */ 1568,
+ HybridSpki.OID_MLKEM1024,
+ KeyType.MLKEM1024Key);
+
+ /** Fixed 64-byte ML-KEM seed (d || z) per FIPS 203 — same for both variants. */
+ static final int SEED_SIZE = 64;
+
+ // SecureRandom is documented thread-safe; share one instance (java:S2119).
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private final MLKEMParameters mlkemParams;
+ private final int publicKeySize;
+ private final int ciphertextSize;
+ private final ASN1ObjectIdentifier oid;
+ private final KeyType keyType;
+
+ private MLKEMAlgorithm(MLKEMParameters mlkemParams, int publicKeySize, int ciphertextSize,
+ ASN1ObjectIdentifier oid, KeyType keyType) {
+ this.mlkemParams = mlkemParams;
+ this.publicKeySize = publicKeySize;
+ this.ciphertextSize = ciphertextSize;
+ this.oid = oid;
+ this.keyType = keyType;
+ }
+
+ public static MLKEMAlgorithm forKeyType(KeyType kt) {
+ switch (kt) {
+ case MLKEM768Key: return MLKEM_768;
+ case MLKEM1024Key: return MLKEM_1024;
+ default: throw new SDKException("not an ML-KEM key type: " + kt);
+ }
+ }
+
+ public int publicKeySize() { return publicKeySize; }
+ public int ciphertextSize() { return ciphertextSize; }
+ public KeyType keyType() { return keyType; }
+ ASN1ObjectIdentifier oid() { return oid; }
+
+ /** Generate a fresh keypair for this algorithm. */
+ public MLKEMKeyPair generate() {
+ MLKEMKeyPairGenerator gen = new MLKEMKeyPairGenerator();
+ gen.init(new MLKEMKeyGenerationParameters(SECURE_RANDOM, mlkemParams));
+ AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ byte[] pub = ((MLKEMPublicKeyParameters) kp.getPublic()).getEncoded();
+ byte[] seed = ((MLKEMPrivateKeyParameters) kp.getPrivate()).getSeed();
+ if (pub.length != publicKeySize) {
+ throw new SDKException("ML-KEM public key size " + pub.length + " != expected " + publicKeySize);
+ }
+ if (seed.length != SEED_SIZE) {
+ throw new SDKException("ML-KEM seed size " + seed.length + " != expected " + SEED_SIZE);
+ }
+ return new MLKEMKeyPair(this, pub, seed);
+ }
+
+ 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 != SEED_SIZE) {
+ throw new SDKException("invalid " + keyType + " private key seed size: got " + raw.length + " want " + SEED_SIZE);
+ }
+ return raw;
+ }
+
+ /**
+ * Encapsulate against {@code rawPub} (an ML-KEM encapsulation key) and AES-256-GCM
+ * wrap the {@code dek} using the Decaps shared secret directly (no HKDF). Returns
+ * the ASN.1 envelope bytes; caller base64-encodes for {@code keyAccess.wrappedKey}.
+ */
+ public byte[] wrapDEK(byte[] rawPub, byte[] dek) {
+ if (rawPub.length != publicKeySize) {
+ throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize);
+ }
+ MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub);
+ SecretWithEncapsulation enc = new MLKEMGenerator(SECURE_RANDOM).generateEncapsulated(pub);
+ byte[] wrapKey = enc.getSecret(); // 32-byte AES key, used directly (no HKDF)
+ byte[] ciphertext = enc.getEncapsulation();
+ if (ciphertext.length != ciphertextSize) {
+ throw new SDKException("ML-KEM ciphertext size " + ciphertext.length + " != expected " + ciphertextSize);
+ }
+ if (wrapKey.length != HybridCrypto.WRAP_KEY_SIZE) {
+ throw new SDKException("ML-KEM shared secret size " + wrapKey.length
+ + " != expected " + HybridCrypto.WRAP_KEY_SIZE);
+ }
+ byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
+ return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek);
+ }
+
+ /**
+ * Inverse of {@link #wrapDEK(byte[], byte[])}. Used by tests and any future
+ * client-side decap path; production decrypt defers to the KAS rewrap.
+ */
+ public byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
+ if (rawPriv.length != SEED_SIZE) {
+ throw new SDKException("invalid " + keyType + " private key seed size: got " + rawPriv.length + " want " + SEED_SIZE);
+ }
+ byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[] ciphertext = parts[0];
+ byte[] encryptedDek = parts[1];
+ if (ciphertext.length != ciphertextSize) {
+ throw new SDKException("invalid " + keyType + " ciphertext size: got " + ciphertext.length + " want " + ciphertextSize);
+ }
+
+ MLKEMPrivateKeyParameters priv = new MLKEMPrivateKeyParameters(mlkemParams, rawPriv);
+ byte[] wrapKey = new MLKEMExtractor(priv).extractSecret(ciphertext);
+ return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPair.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPair.java
new file mode 100644
index 00000000..d0a3e5ad
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPair.java
@@ -0,0 +1,40 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+/**
+ * Holds a pure ML-KEM (FIPS 203) keypair as raw bytes plus the {@link MLKEMAlgorithm}
+ * variant they belong to. Created exclusively by {@link MLKEMAlgorithm#generate()};
+ * the constructor stays package-private so external callers go through the
+ * algorithm dispatcher.
+ *
+ * {@code publicKey} is the raw FIPS 203 encapsulation key
+ * ({@link MLKEMAlgorithm#publicKeySize()} bytes). {@code privateKey} is the
+ * 64-byte seed {@code (d || z)} per FIPS 203 §6 — not the expanded private
+ * key. BC's {@code MLKEMPrivateKeyParameters} reconstructs the full key from
+ * the seed at decapsulation time.
+ */
+public final class MLKEMKeyPair {
+
+ private final MLKEMAlgorithm algorithm;
+ private final byte[] publicKey;
+ private final byte[] privateKey;
+
+ MLKEMKeyPair(MLKEMAlgorithm algorithm, byte[] publicKey, byte[] privateKey) {
+ this.algorithm = algorithm;
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ public MLKEMAlgorithm algorithm() { return algorithm; }
+ public byte[] getPublicKey() { return publicKey.clone(); }
+ public byte[] getPrivateKey() { return privateKey.clone(); }
+
+ /** SPKI {@code -----BEGIN PUBLIC KEY-----} block with the algorithm's OID. */
+ public String publicKeyInPemFormat() {
+ return HybridSpki.encodeSpkiPem(algorithm.oid(), publicKey);
+ }
+
+ /** PKCS#8 {@code -----BEGIN PRIVATE KEY-----} block carrying the 64-byte seed. */
+ public String privateKeyInPemFormat() {
+ return HybridSpki.encodePkcs8Pem(algorithm.oid(), privateKey);
+ }
+}
diff --git a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java
new file mode 100644
index 00000000..fbf6ae4a
--- /dev/null
+++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java
@@ -0,0 +1,149 @@
+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.MLKEMAlgorithm;
+import io.opentdf.platform.sdk.pqc.bc.MLKEMKeyPair;
+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.nio.charset.StandardCharsets;
+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 TDFHybridTest} for pure ML-KEM (FIPS 203). Creates a TDF
+ * using each ML-KEM KAS key type, then asserts the manifest's KeyAccess
+ * object has:
+ * 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.
+ *
+ *
+ *
+ *
*
*
+ *
+ *
+ *
* Wire format
+ *
+ * SEQUENCE { [0] IMPLICIT OCTET STRING mlkemCiphertext,
+ * [1] IMPLICIT OCTET STRING AES-GCM(iv(12) ‖ DEK ‖ tag(16)) }
+ *
+ *
+ *
+ */
+class TDFMLKEMTest {
+
+ 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