From e2e5581fe38113effa13e462090349eab53b5a40 Mon Sep 17 00:00:00 2001 From: sujan kota Date: Mon, 22 Jun 2026 19:31:17 -0400 Subject: [PATCH 1/3] feat(sdk): DSPX-3383 add pure ML-KEM-768 and ML-KEM-1024 key wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure ML-KEM (FIPS 203) over the same KemProvider SPI introduced by PR #368 for hybrid PQC. The BC-backed implementation lives in sdk-pqc-bc; the core sdk jar gets two enum values and an isMLKEM() predicate — no BC compile-time references, so the FIPS profile still builds cleanly without sdk-pqc-bc. Wire format (matches platform PR #3491): wrappedKey = base64(mlkem_ct || AES-GCM(IV(12)||DEK||tag(16))) wrap key = HKDF-SHA256(salt=SHA-256("TDF"), ikm=mlkem_ss, L=32) keyAccess.type = "wrapped" (reuses RSA slot; KAS disambiguates by alg) ephemeralPublicKey absent PEM is standard SPKI/PKCS#8 with the NIST FIPS 203 OIDs (2.16.840.1.101.3.4.4.{2,3}) inside the AlgorithmIdentifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/README.md | 62 +++- scripts/test-mlkem.sh | 304 ++++++++++++++++++ .../sdk/pqc/bc/BouncyCastleKemProvider.java | 14 +- .../platform/sdk/pqc/bc/HybridCrypto.java | 43 ++- .../platform/sdk/pqc/bc/HybridSpki.java | 25 +- .../platform/sdk/pqc/bc/MLKEMAlgorithm.java | 177 ++++++++++ .../platform/sdk/pqc/bc/MLKEMKeyPair.java | 40 +++ .../io/opentdf/platform/sdk/TDFMLKEMTest.java | 148 +++++++++ .../platform/sdk/pqc/bc/MLKEMKeyPairTest.java | 117 +++++++ .../java/io/opentdf/platform/sdk/KeyType.java | 24 +- .../java/io/opentdf/platform/sdk/TDF.java | 8 + 11 files changed, 926 insertions(+), 36 deletions(-) create mode 100755 scripts/test-mlkem.sh create mode 100644 sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMAlgorithm.java create mode 100644 sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPair.java create mode 100644 sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java create mode 100644 sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java diff --git a/scripts/README.md b/scripts/README.md index 6aaab6e4..aa6fdc87 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -102,13 +102,55 @@ 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 three pure-ML-KEM specifics: + +- `keyAccess[0].type` is `"wrapped"` (not `"hybrid-wrapped"`) — pure ML-KEM + reuses the RSA slot; the KAS disambiguates from RSA by the registered key + algorithm. +- `wrappedKey` is a raw concat `mlkemCiphertext || AES-GCM(IV(12) || DEK(32) + || tag(16))` rather than an ASN.1 SEQUENCE. The script asserts the exact + byte length: `ciphertextSize + 60` (1088+60 = 1148 for ML-KEM-768; + 1568+60 = 1628 for ML-KEM-1024). +- 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. + +### 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..48c42731 --- /dev/null +++ b/scripts/test-mlkem.sh @@ -0,0 +1,304 @@ +#!/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 three places: +# * Wire format: pure ML-KEM is raw concat (mlkem_ct || AES-GCM blob), not +# ASN.1 SEQUENCE. The manifest check below validates the wrappedKey +# length matches ciphertextSize + 28 bytes (IV(12) + tag(16) + DEK(?)). +# * keyAccess[0].type == "wrapped" (NOT "hybrid-wrapped"). Pure ML-KEM +# reuses the RSA slot; the KAS disambiguates by registered key algorithm. +# * SPKI OIDs are the NIST FIPS 203 ones (2.16.840.1.101.3.4.4.{2,3}). +# +# 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 +} + +# Map KeyType enum name → ML-KEM ciphertext size (FIPS 203). +alg_to_ct_size() { + case "$1" in + MLKEM768Key) echo 1088 ;; + MLKEM1024Key) echo 1568 ;; + *) 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://}" + 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" != "wrapped" ]]; then + fail "$alg_name: type='$keyType' (expected '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 + # Pure ML-KEM wire format: ML-KEM ciphertext (fixed size per variant) + # || AES-GCM(IV(12) || DEK(32) || tag(16)) = ciphertextSize + 60 bytes. + expected_ct_size=$(alg_to_ct_size "$alg_name") + expected_len=$((expected_ct_size + 12 + 32 + 16)) + actual_len=$(b64decode <<<"$wrapped" 2>/dev/null | wc -c | tr -d ' ') + if [[ "$actual_len" != "$expected_len" ]]; then + fail "$alg_name: wrappedKey length $actual_len bytes != expected $expected_len (ct=$expected_ct_size + 60)" + failures+=("$alg_name (bad wrappedKey length)") + continue + fi + pass "$alg_name: manifest OK (wrapped, $actual_len-byte 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 SUPPORTED = EnumSet.of( KeyType.HybridXWingKey, KeyType.HybridSecp256r1MLKEM768Key, - KeyType.HybridSecp384r1MLKEM1024Key); + KeyType.HybridSecp384r1MLKEM1024Key, + KeyType.MLKEM768Key, + KeyType.MLKEM1024Key); /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */ public BouncyCastleKemProvider() { diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java index c2cc5354..e744c32c 100644 --- a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java +++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java @@ -9,14 +9,21 @@ import java.security.NoSuchAlgorithmException; /** - * Dispatcher and shared helpers for hybrid post-quantum key wrapping - * (X-Wing and NIST EC + ML-KEM). + * Dispatcher and shared helpers for post-quantum key wrapping. Serves the + * three hybrid algorithms (X-Wing, NIST EC + ML-KEM) AND pure ML-KEM-768 / + * ML-KEM-1024 via the same {@link BouncyCastleKemProvider}. * - * Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs - * SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK } + *

Wire format differs by family. Hybrid algorithms use an ASN.1 DER + * SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs (built via + * {@link #marshalEnvelope}). Pure ML-KEM uses a raw concat of + * {@code mlkemCiphertext || AES-GCM(nonce||ct||tag)} — the KAS knows the + * fixed ciphertext size from the registered key's algorithm, so no envelope + * framing is needed. * - * 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. + *

The AES-256 wrap key derivation + * ({@code HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty)}) and + * the AES-256-GCM DEK encryption ({@code 12-byte IV || ciphertext || 16-byte tag}) + * are shared across both families. */ final class HybridCrypto { @@ -30,10 +37,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 +52,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 +77,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..25fd48b0 --- /dev/null +++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/MLKEMAlgorithm.java @@ -0,0 +1,177 @@ +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; +import java.util.Arrays; + +/** + * Stateless parameters + operations for a pure ML-KEM (FIPS 203) algorithm + * variant. The wire format and combiner are the bare ML-KEM KEM output run + * through HKDF-SHA256, in contrast with {@link HybridNISTAlgorithm} (which + * combines ML-KEM with an EC half per draft-ietf-lamps-pq-composite-kem-14). + * + *

Wire format

+ * + *

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 (no ASN.1 framing — the KAS knows the + * algorithm from the registered key, so the {@link #ciphertextSize()} prefix + * is unambiguous): + *

mlkemCiphertext ‖ AES-GCM(nonce(12) ‖ encryptedDEK ‖ tag(16))
+ * + *

KEM combiner: + *

wrapKey = HKDF-SHA256(salt = SHA-256("TDF"), ikm = mlkemSharedSecret, L = 32)
+ * The 32-byte output is used directly as the AES-256 key. + * + *

The KAO field {@code type} stays {@code "wrapped"} (reuses the RSA slot; + * the KAS disambiguates from RSA by looking up the registered key's + * algorithm). {@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}. Returns the raw wire bytes: {@code ciphertext || AES-GCM(nonce||ct||tag)}. + * 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[] sharedSecret = enc.getSecret(); + byte[] ciphertext = enc.getEncapsulation(); + if (ciphertext.length != ciphertextSize) { + throw new SDKException("ML-KEM ciphertext size " + ciphertext.length + " != expected " + ciphertextSize); + } + + byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret); + byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes(); + byte[] out = new byte[ciphertextSize + encryptedDek.length]; + System.arraycopy(ciphertext, 0, out, 0, ciphertextSize); + System.arraycopy(encryptedDek, 0, out, ciphertextSize, encryptedDek.length); + return out; + } + + /** + * 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[] wrappedBlob) { + if (rawPriv.length != SEED_SIZE) { + throw new SDKException("invalid " + keyType + " private key seed size: got " + rawPriv.length + " want " + SEED_SIZE); + } + if (wrappedBlob.length <= ciphertextSize) { + throw new SDKException(keyType + " wrapped blob too short: got " + wrappedBlob.length + + ", need > " + ciphertextSize); + } + byte[] ciphertext = Arrays.copyOfRange(wrappedBlob, 0, ciphertextSize); + byte[] encryptedDek = Arrays.copyOfRange(wrappedBlob, ciphertextSize, wrappedBlob.length); + + MLKEMPrivateKeyParameters priv = new MLKEMPrivateKeyParameters(mlkemParams, rawPriv); + byte[] sharedSecret = new MLKEMExtractor(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/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..95815ecc --- /dev/null +++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFMLKEMTest.java @@ -0,0 +1,148 @@ +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: + *

+ */ +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 execute() { + return new ResponseMessage.Success<>(mockResponse, + Collections.emptyMap(), Collections.emptyMap()); + } + + @Override + public void cancel() { + // No-op: the mock call is synchronous and already complete by the time + // the SDK could call cancel(); nothing to interrupt. + } + }); + } + + @Test + void createKeyAccessWithMLKEM768() throws Exception { + assertRoundTripFor(MLKEMAlgorithm.MLKEM_768, KeyType.MLKEM768Key, "mlkem768-kid"); + } + + @Test + void createKeyAccessWithMLKEM1024() throws Exception { + assertRoundTripFor(MLKEMAlgorithm.MLKEM_1024, KeyType.MLKEM1024Key, "mlkem1024-kid"); + } + + private void assertRoundTripFor(MLKEMAlgorithm algo, KeyType keyType, String kid) throws Exception { + MLKEMKeyPair kp = algo.generate(); + Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess( + keyType, kp.publicKeyInPemFormat(), kid); + + // Pure ML-KEM reuses the RSA-style "wrapped" slot — KAS disambiguates by algorithm. + assertThat(ka.keyType).isEqualTo("wrapped"); + assertThat(ka.ephemeralPublicKey).isNull(); + assertThat(ka.wrappedKey).isNotEmpty(); + + byte[] wrapped = Base64.getDecoder().decode(ka.wrappedKey); + // wrappedKey = mlkemCiphertext (fixed) || AES-GCM(iv(12) || dek(32) || tag(16)) + assertThat(wrapped.length).isEqualTo(algo.ciphertextSize() + 12 + 32 + 16); + + byte[] privRaw = algo.privateKeyFromPem(kp.privateKeyInPemFormat()); + byte[] symKey = algo.unwrapDEK(privRaw, wrapped); + assertThat(symKey).hasSize(32); + } + + /** + * Build a fake KAS that returns {@code (algorithm, publicKeyPem)} as its public key, then + * call {@code TDF.createTDF} on a small plaintext and return the single KeyAccess produced + * in the manifest. + */ + private Manifest.KeyAccess createTDFAndGetFirstKeyAccess(KeyType keyType, String publicKeyPem, String kid) + throws Exception { + Config.KASInfo kasInfo = new Config.KASInfo(); + kasInfo.URL = "https://kas.example.com"; + kasInfo.KID = kid; + kasInfo.Algorithm = keyType.toString(); + kasInfo.PublicKey = publicKeyPem; + + SDK.KAS fakeKas = new SDK.KAS() { + @Override + public void close() {} + + @Override + public Config.KASInfo getPublicKey(Config.KASInfo info) { + Config.KASInfo copy = info.clone(); + copy.Algorithm = keyType.toString(); + copy.PublicKey = publicKeyPem; + copy.KID = kid; + return copy; + } + + @Override + public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) { + throw new UnsupportedOperationException("KAS unwrap is not exercised by pure-MLKEM TDF creation tests"); + } + + @Override + public KASKeyCache getKeyCache() { + return new KASKeyCache(); + } + }; + + Config.TDFConfig config = Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(kasInfo)); + + InputStream plaintext = new ByteArrayInputStream("mlkem hello".getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream tdfOut = new ByteArrayOutputStream(); + + TDF tdf = new TDF(new FakeServicesBuilder() + .setKas(fakeKas) + .setKeyAccessServerRegistryService(kasRegistryService) + .build()); + + Manifest manifest = tdf.createTDF(plaintext, tdfOut, config).getManifest(); + List kaos = manifest.encryptionInformation.keyAccessObj; + assertThat(kaos).hasSize(1); + return kaos.get(0); + } +} diff --git a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java new file mode 100644 index 00000000..6d89c123 --- /dev/null +++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java @@ -0,0 +1,117 @@ +package io.opentdf.platform.sdk.pqc.bc; + +import io.opentdf.platform.sdk.SDKException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for pure ML-KEM-768 / ML-KEM-1024 key wrapping. Mirrors the + * shape of {@code HybridCryptoTest} but for FIPS 203 standalone (no EC + * combiner) — generate keypair → SPKI/PKCS#8 PEM round-trip → wrap DEK → + * unwrap DEK → assert byte-equal. The unwrap path also acts as a wire-format + * guard: if the raw-concat marshal/unmarshal drifts, the round-trip fails. + */ +class MLKEMKeyPairTest { + + private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + + private static Stream variants() { + return Stream.of( + Arguments.of(MLKEMAlgorithm.MLKEM_768), + Arguments.of(MLKEMAlgorithm.MLKEM_1024)); + } + + @ParameterizedTest + @MethodSource("variants") + void roundTrip(MLKEMAlgorithm algo) { + MLKEMKeyPair kp = algo.generate(); + + String pubPem = kp.publicKeyInPemFormat(); + String privPem = kp.privateKeyInPemFormat(); + // Standard SPKI/PKCS#8 envelope; the FIPS 203 OID + // (2.16.840.1.101.3.4.4.2 / .3) sits inside the AlgorithmIdentifier. + assertTrue(pubPem.startsWith("-----BEGIN PUBLIC KEY-----"), "public PEM header"); + assertTrue(privPem.startsWith("-----BEGIN PRIVATE KEY-----"), "private PEM header"); + + byte[] rawPub = algo.pubKeyFromPem(pubPem); + byte[] rawPriv = algo.privateKeyFromPem(privPem); + assertEquals(algo.publicKeySize(), rawPub.length); + assertEquals(MLKEMAlgorithm.SEED_SIZE, rawPriv.length); + assertArrayEquals(kp.getPublicKey(), rawPub, "public key round-trip"); + assertArrayEquals(kp.getPrivateKey(), rawPriv, "private key round-trip"); + + byte[] wrapped = algo.wrapDEK(rawPub, DEK); + assertNotNull(wrapped); + // Pure ML-KEM uses raw concat — no ASN.1 SEQUENCE prefix. The blob is + // ciphertext (fixed) || AES-GCM(iv(12) || ct || tag(16)) = 12 + dek + 16 = dek + 28. + assertEquals(algo.ciphertextSize() + DEK.length + 12 + 16, wrapped.length, + "wrapped blob length"); + + byte[] unwrapped = algo.unwrapDEK(rawPriv, wrapped); + assertArrayEquals(DEK, unwrapped, "DEK round-trip"); + } + + @ParameterizedTest + @MethodSource("variants") + void rejectsWrongOIDInPem(MLKEMAlgorithm algo) { + // 768 keypair, decoded with the 1024 OID expectation (or vice versa) — should fail. + MLKEMAlgorithm other = (algo == MLKEMAlgorithm.MLKEM_768) + ? MLKEMAlgorithm.MLKEM_1024 : MLKEMAlgorithm.MLKEM_768; + String pem = other.generate().publicKeyInPemFormat(); + SDKException ex = assertThrows(SDKException.class, () -> algo.pubKeyFromPem(pem)); + // Message comes from HybridSpki.decodeSpkiPem; just confirm it mentions OID/algorithm mismatch. + assertTrue(ex.getMessage().toLowerCase().contains("oid") + || ex.getMessage().toLowerCase().contains("mismatch"), + "expected OID-mismatch message, got: " + ex.getMessage()); + } + + @ParameterizedTest + @MethodSource("variants") + void rejectsWrongSizedPublicKey(MLKEMAlgorithm algo) { + byte[] tooShort = new byte[algo.publicKeySize() - 1]; + SDKException ex = assertThrows(SDKException.class, () -> algo.wrapDEK(tooShort, DEK)); + assertTrue(ex.getMessage().contains("public key size"), ex.getMessage()); + } + + @ParameterizedTest + @MethodSource("variants") + void rejectsTruncatedWrappedBlob(MLKEMAlgorithm algo) { + MLKEMKeyPair kp = algo.generate(); + byte[] wrapped = algo.wrapDEK(kp.getPublicKey(), DEK); + // Cut off the AES-GCM tail entirely — leaves only the KEM ciphertext. + byte[] truncated = Arrays.copyOfRange(wrapped, 0, algo.ciphertextSize()); + SDKException ex = assertThrows(SDKException.class, + () -> algo.unwrapDEK(kp.getPrivateKey(), truncated)); + assertTrue(ex.getMessage().contains("too short"), ex.getMessage()); + } + + @ParameterizedTest + @MethodSource("variants") + void rejectsTamperedCiphertext(MLKEMAlgorithm algo) { + MLKEMKeyPair kp = algo.generate(); + byte[] wrapped = algo.wrapDEK(kp.getPublicKey(), DEK); + // Flip a bit in the ML-KEM ciphertext. ML-KEM is IND-CCA2: extracted secret + // becomes pseudorandom (different from the wrap key), so AES-GCM auth fails. + wrapped[0] ^= 0x01; + // Either AesGcm throws (auth fail) or unwrap produces wrong DEK; both acceptable. + // We assert the round-trip is broken: if no exception, the DEK must differ. + try { + byte[] result = algo.unwrapDEK(kp.getPrivateKey(), wrapped); + assertNotEquals(0, Arrays.compare(DEK, result), "tampering should not yield the original DEK"); + } catch (Exception expected) { + // expected: AES-GCM tag verification failure surfaces as SDKException + } + } +} 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 47270101..13b78e70 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java @@ -32,7 +32,19 @@ public enum KeyType { * {@code sdk-pqc-bc} (or another {@link io.opentdf.platform.sdk.spi.KemProvider} * implementation) on the runtime classpath; not available under the fips Maven profile. */ - HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024"); + HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024"), + /** + * Pure ML-KEM-768 (FIPS 203). Requires {@code sdk-pqc-bc} (or another + * {@link io.opentdf.platform.sdk.spi.KemProvider} implementation) on the runtime + * classpath; not available under the fips Maven profile. + */ + MLKEM768Key("mlkem:768"), + /** + * Pure ML-KEM-1024 (FIPS 203). Requires {@code sdk-pqc-bc} (or another + * {@link io.opentdf.platform.sdk.spi.KemProvider} implementation) on the runtime + * classpath; not available under the fips Maven profile. + */ + MLKEM1024Key("mlkem:1024"); private final String keyType; private final ECCurve curve; @@ -136,4 +148,14 @@ public boolean isHybrid() { return false; } } + + public boolean isMLKEM() { + switch (this) { + case MLKEM768Key: + case MLKEM1024Key: + return true; + default: + return false; + } + } } 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 05923083..36cf88c6 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -237,6 +237,14 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA keyAccess.keyType = kHybridWrapped; // ephemeralPublicKey intentionally left null — the ephemeral material is // carried inside the ASN.1 envelope in wrappedKey. + } else if (keyType.isMLKEM()) { + // Pure ML-KEM (FIPS 203). Same KemProviders dispatch as hybrid, but + // keyAccess.type stays "wrapped" — reuses the RSA slot. KAS disambiguates + // from RSA via the registered key's algorithm. Wire format inside the + // base64'd wrappedKey: mlkem_ciphertext || AES-GCM(nonce||ct||tag). + byte[] wrapped = KemProviders.get(keyType).wrapDEK(keyType, kasInfo.PublicKey, symKey); + keyAccess.wrappedKey = Base64.getEncoder().encodeToString(wrapped); + keyAccess.keyType = kWrapped; } else if (keyType.isEc()) { var ecKeyWrappedKeyInfo = createECWrappedKey(kasInfo, symKey, keyType); keyAccess.wrappedKey = ecKeyWrappedKeyInfo.wrappedKey; From c4f8ec0e925bf4dc7761e926c1d2af34311dcf9d Mon Sep 17 00:00:00 2001 From: sujan kota Date: Tue, 23 Jun 2026 10:13:30 -0400 Subject: [PATCH 2/3] conform pure ML-KEM to platform PR #3562 (mlkem-wrapped, no HKDF) --- scripts/README.md | 22 +++--- scripts/test-mlkem.sh | 41 +++++------- .../platform/sdk/pqc/bc/HybridCrypto.java | 28 +++++--- .../platform/sdk/pqc/bc/MLKEMAlgorithm.java | 67 ++++++++++--------- .../io/opentdf/platform/sdk/TDFMLKEMTest.java | 17 ++--- .../platform/sdk/pqc/bc/MLKEMKeyPairTest.java | 43 +++++------- .../java/io/opentdf/platform/sdk/TDF.java | 11 +-- 7 files changed, 115 insertions(+), 114 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index aa6fdc87..46687b0c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -107,19 +107,23 @@ attempted), 2 on misuse. 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 three pure-ML-KEM specifics: - -- `keyAccess[0].type` is `"wrapped"` (not `"hybrid-wrapped"`) — pure ML-KEM - reuses the RSA slot; the KAS disambiguates from RSA by the registered key - algorithm. -- `wrappedKey` is a raw concat `mlkemCiphertext || AES-GCM(IV(12) || DEK(32) - || tag(16))` rather than an ASN.1 SEQUENCE. The script asserts the exact - byte length: `ciphertextSize + 60` (1088+60 = 1148 for ML-KEM-768; - 1568+60 = 1628 for ML-KEM-1024). +→ 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 diff --git a/scripts/test-mlkem.sh b/scripts/test-mlkem.sh index 48c42731..713e7998 100755 --- a/scripts/test-mlkem.sh +++ b/scripts/test-mlkem.sh @@ -5,13 +5,14 @@ # # Per algorithm: encrypt → assert manifest → KAS rewrap → decrypt → diff. # -# Differs from test-hybrid-pqc.sh in three places: -# * Wire format: pure ML-KEM is raw concat (mlkem_ct || AES-GCM blob), not -# ASN.1 SEQUENCE. The manifest check below validates the wrappedKey -# length matches ciphertextSize + 28 bytes (IV(12) + tag(16) + DEK(?)). -# * keyAccess[0].type == "wrapped" (NOT "hybrid-wrapped"). Pure ML-KEM -# reuses the RSA slot; the KAS disambiguates by registered key algorithm. +# 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 @@ -89,15 +90,6 @@ alg_to_oid() { esac } -# Map KeyType enum name → ML-KEM ciphertext size (FIPS 203). -alg_to_ct_size() { - case "$1" in - MLKEM768Key) echo 1088 ;; - MLKEM1024Key) echo 1568 ;; - *) return 1 ;; - esac -} - WORK_DIR="$(mktemp -d -t mlkem-XXXXXX)" trap 'rm -rf "$WORK_DIR"' EXIT @@ -236,8 +228,8 @@ for alg_name in "${ALGORITHMS[@]}"; do 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" != "wrapped" ]]; then - fail "$alg_name: type='$keyType' (expected 'wrapped')" + 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)") @@ -253,17 +245,14 @@ for alg_name in "${ALGORITHMS[@]}"; do failures+=("$alg_name (empty wrappedKey)") continue fi - # Pure ML-KEM wire format: ML-KEM ciphertext (fixed size per variant) - # || AES-GCM(IV(12) || DEK(32) || tag(16)) = ciphertextSize + 60 bytes. - expected_ct_size=$(alg_to_ct_size "$alg_name") - expected_len=$((expected_ct_size + 12 + 32 + 16)) - actual_len=$(b64decode <<<"$wrapped" 2>/dev/null | wc -c | tr -d ' ') - if [[ "$actual_len" != "$expected_len" ]]; then - fail "$alg_name: wrappedKey length $actual_len bytes != expected $expected_len (ct=$expected_ct_size + 60)" - failures+=("$alg_name (bad wrappedKey length)") + # 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 (wrapped, $actual_len-byte envelope, no ephemeralPublicKey)" + 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] ---" diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java index e744c32c..361ec2d3 100644 --- a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java +++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java @@ -13,17 +13,25 @@ * three hybrid algorithms (X-Wing, NIST EC + ML-KEM) AND pure ML-KEM-768 / * ML-KEM-1024 via the same {@link BouncyCastleKemProvider}. * - *

Wire format differs by family. Hybrid algorithms use an ASN.1 DER - * SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs (built via - * {@link #marshalEnvelope}). Pure ML-KEM uses a raw concat of - * {@code mlkemCiphertext || AES-GCM(nonce||ct||tag)} — the KAS knows the - * fixed ciphertext size from the registered key's algorithm, so no envelope - * framing is needed. + *

Wire envelope is identical across both families — an ASN.1 DER SEQUENCE + * with two IMPLICIT context-tagged OCTET STRINGs (built via + * {@link #marshalEnvelope}): + *

SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK }
* - *

The AES-256 wrap key derivation - * ({@code HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty)}) and - * the AES-256-GCM DEK encryption ({@code 12-byte IV || ciphertext || 16-byte tag}) - * are shared across both families. + *

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: + *

    + *
  • Hybrid: HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), + * info=empty) — the KDF is load-bearing as the combiner for the two + * shared-secret halves.
  • + *
  • Pure ML-KEM: the 32-byte FIPS 203 Decaps shared secret is used + * directly — no KDF. See + * {@link MLKEMAlgorithm#wrapDEK(byte[], byte[])} and platform ADR + * {@code 2026-06-16-mlkem-direct-key-wrap.md}.
  • + *
+ * + *

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 { 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 index 25fd48b0..09b727ca 100644 --- 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 @@ -15,13 +15,13 @@ import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters; import java.security.SecureRandom; -import java.util.Arrays; /** * Stateless parameters + operations for a pure ML-KEM (FIPS 203) algorithm - * variant. The wire format and combiner are the bare ML-KEM KEM output run - * through HKDF-SHA256, in contrast with {@link HybridNISTAlgorithm} (which - * combines ML-KEM with an EC half per draft-ietf-lamps-pq-composite-kem-14). + * 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. * *

Wire format

* @@ -31,18 +31,25 @@ *

Private key (64 bytes inside the PKCS#8 OCTET STRING): * the ML-KEM seed {@code (d || z)} per FIPS 203 §6. * - *

Wrapped DEK envelope (no ASN.1 framing — the KAS knows the - * algorithm from the registered key, so the {@link #ciphertextSize()} prefix - * is unambiguous): - *

mlkemCiphertext ‖ AES-GCM(nonce(12) ‖ encryptedDEK ‖ tag(16))
+ *

Wrapped DEK envelope — same ASN.1 SEQUENCE as + * {@link HybridCrypto#marshalEnvelope(byte[], byte[])}: + *

SEQUENCE { [0] IMPLICIT OCTET STRING mlkemCiphertext,
+ *            [1] IMPLICIT OCTET STRING AES-GCM(iv(12) ‖ DEK ‖ tag(16)) }
* - *

KEM combiner: - *

wrapKey = HKDF-SHA256(salt = SHA-256("TDF"), ikm = mlkemSharedSecret, L = 32)
- * The 32-byte output is used directly as the AES-256 key. + *

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} stays {@code "wrapped"} (reuses the RSA slot; - * the KAS disambiguates from RSA by looking up the registered key's - * algorithm). {@code ephemeralPublicKey} is absent. + *

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). @@ -131,8 +138,8 @@ public byte[] privateKeyFromPem(String pem) { /** * Encapsulate against {@code rawPub} (an ML-KEM encapsulation key) and AES-256-GCM - * wrap the {@code dek}. Returns the raw wire bytes: {@code ciphertext || AES-GCM(nonce||ct||tag)}. - * Caller base64-encodes for {@code keyAccess.wrappedKey}. + * 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) { @@ -140,38 +147,36 @@ public byte[] wrapDEK(byte[] rawPub, byte[] dek) { } MLKEMPublicKeyParameters pub = new MLKEMPublicKeyParameters(mlkemParams, rawPub); SecretWithEncapsulation enc = new MLKEMGenerator(SECURE_RANDOM).generateEncapsulated(pub); - byte[] sharedSecret = enc.getSecret(); + 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); } - - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret); + 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(); - byte[] out = new byte[ciphertextSize + encryptedDek.length]; - System.arraycopy(ciphertext, 0, out, 0, ciphertextSize); - System.arraycopy(encryptedDek, 0, out, ciphertextSize, encryptedDek.length); - return out; + 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[] wrappedBlob) { + 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); } - if (wrappedBlob.length <= ciphertextSize) { - throw new SDKException(keyType + " wrapped blob too short: got " + wrappedBlob.length - + ", need > " + ciphertextSize); + 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); } - byte[] ciphertext = Arrays.copyOfRange(wrappedBlob, 0, ciphertextSize); - byte[] encryptedDek = Arrays.copyOfRange(wrappedBlob, ciphertextSize, wrappedBlob.length); MLKEMPrivateKeyParameters priv = new MLKEMPrivateKeyParameters(mlkemParams, rawPriv); - byte[] sharedSecret = new MLKEMExtractor(priv).extractSecret(ciphertext); - byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret); + byte[] wrapKey = new MLKEMExtractor(priv).extractSecret(ciphertext); return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek)); } } 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 index 95815ecc..fbf6ae4a 100644 --- 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 @@ -29,11 +29,13 @@ * using each ML-KEM KAS key type, then asserts the manifest's KeyAccess * object has: *

    - *
  • {@code keyType == "wrapped"} (NOT "hybrid-wrapped"; pure ML-KEM - * reuses the RSA slot — the KAS disambiguates by registered algorithm)
  • + *
  • {@code keyType == "mlkem-wrapped"} — its own KAO scheme, distinct + * from RSA's {@code "wrapped"} and the hybrid {@code "hybrid-wrapped"}. + * The KAS uses this to skip HKDF on the wrap key. See platform PR + * #3562 and {@code adr/decisions/2026-06-16-mlkem-direct-key-wrap.md}.
  • *
  • {@code ephemeralPublicKey == null}
  • - *
  • a {@code wrappedKey} that round-trips back to the 32-byte payload - * key via the matching private key.
  • + *
  • a {@code wrappedKey} (ASN.1 envelope, base64'd) that round-trips + * back to the 32-byte payload key via the matching private key.
  • *
*/ class TDFMLKEMTest { @@ -77,14 +79,13 @@ private void assertRoundTripFor(MLKEMAlgorithm algo, KeyType keyType, String kid Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess( keyType, kp.publicKeyInPemFormat(), kid); - // Pure ML-KEM reuses the RSA-style "wrapped" slot — KAS disambiguates by algorithm. - assertThat(ka.keyType).isEqualTo("wrapped"); + assertThat(ka.keyType).isEqualTo("mlkem-wrapped"); assertThat(ka.ephemeralPublicKey).isNull(); assertThat(ka.wrappedKey).isNotEmpty(); byte[] wrapped = Base64.getDecoder().decode(ka.wrappedKey); - // wrappedKey = mlkemCiphertext (fixed) || AES-GCM(iv(12) || dek(32) || tag(16)) - assertThat(wrapped.length).isEqualTo(algo.ciphertextSize() + 12 + 32 + 16); + // ASN.1 SEQUENCE tag (same envelope as hybrid). Round-trip is the wire-format guard. + assertThat(wrapped[0]).isEqualTo((byte) 0x30); byte[] privRaw = algo.privateKeyFromPem(kp.privateKeyInPemFormat()); byte[] symKey = algo.unwrapDEK(privRaw, wrapped); diff --git a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java index 6d89c123..db1faa73 100644 --- a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java +++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/MLKEMKeyPairTest.java @@ -6,12 +6,10 @@ import org.junit.jupiter.params.provider.MethodSource; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,7 +19,8 @@ * shape of {@code HybridCryptoTest} but for FIPS 203 standalone (no EC * combiner) — generate keypair → SPKI/PKCS#8 PEM round-trip → wrap DEK → * unwrap DEK → assert byte-equal. The unwrap path also acts as a wire-format - * guard: if the raw-concat marshal/unmarshal drifts, the round-trip fails. + * guard: if the ASN.1 envelope marshal/unmarshal drifts, the round-trip + * fails. */ class MLKEMKeyPairTest { @@ -54,10 +53,8 @@ void roundTrip(MLKEMAlgorithm algo) { byte[] wrapped = algo.wrapDEK(rawPub, DEK); assertNotNull(wrapped); - // Pure ML-KEM uses raw concat — no ASN.1 SEQUENCE prefix. The blob is - // ciphertext (fixed) || AES-GCM(iv(12) || ct || tag(16)) = 12 + dek + 16 = dek + 28. - assertEquals(algo.ciphertextSize() + DEK.length + 12 + 16, wrapped.length, - "wrapped blob length"); + // ASN.1 SEQUENCE — first byte is the SEQUENCE tag, same as the hybrid envelope. + assertEquals((byte) 0x30, wrapped[0], "ASN.1 SEQUENCE tag"); byte[] unwrapped = algo.unwrapDEK(rawPriv, wrapped); assertArrayEquals(DEK, unwrapped, "DEK round-trip"); @@ -87,14 +84,13 @@ void rejectsWrongSizedPublicKey(MLKEMAlgorithm algo) { @ParameterizedTest @MethodSource("variants") - void rejectsTruncatedWrappedBlob(MLKEMAlgorithm algo) { + void rejectsMalformedEnvelope(MLKEMAlgorithm algo) { MLKEMKeyPair kp = algo.generate(); - byte[] wrapped = algo.wrapDEK(kp.getPublicKey(), DEK); - // Cut off the AES-GCM tail entirely — leaves only the KEM ciphertext. - byte[] truncated = Arrays.copyOfRange(wrapped, 0, algo.ciphertextSize()); - SDKException ex = assertThrows(SDKException.class, - () -> algo.unwrapDEK(kp.getPrivateKey(), truncated)); - assertTrue(ex.getMessage().contains("too short"), ex.getMessage()); + // A two-byte blob can't possibly be a valid SEQUENCE-of-two-OCTET-STRINGs envelope. + // HybridCrypto.unmarshalEnvelope should reject before we even reach AES-GCM. + byte[] malformed = new byte[] { (byte) 0x30, 0x00 }; + assertThrows(SDKException.class, + () -> algo.unwrapDEK(kp.getPrivateKey(), malformed)); } @ParameterizedTest @@ -102,16 +98,13 @@ void rejectsTruncatedWrappedBlob(MLKEMAlgorithm algo) { void rejectsTamperedCiphertext(MLKEMAlgorithm algo) { MLKEMKeyPair kp = algo.generate(); byte[] wrapped = algo.wrapDEK(kp.getPublicKey(), DEK); - // Flip a bit in the ML-KEM ciphertext. ML-KEM is IND-CCA2: extracted secret - // becomes pseudorandom (different from the wrap key), so AES-GCM auth fails. - wrapped[0] ^= 0x01; - // Either AesGcm throws (auth fail) or unwrap produces wrong DEK; both acceptable. - // We assert the round-trip is broken: if no exception, the DEK must differ. - try { - byte[] result = algo.unwrapDEK(kp.getPrivateKey(), wrapped); - assertNotEquals(0, Arrays.compare(DEK, result), "tampering should not yield the original DEK"); - } catch (Exception expected) { - // expected: AES-GCM tag verification failure surfaces as SDKException - } + // Flip a bit in the AES-GCM tag (last byte of the envelope) — the envelope + // still parses as valid ASN.1, but AES-GCM auth fails on unwrap. Avoids + // touching the ASN.1 headers (wrapped[0..~6]) which would surface as a + // parse error instead of the more interesting auth-failure path. + wrapped[wrapped.length - 1] ^= 0x01; + assertThrows(Exception.class, + () -> algo.unwrapDEK(kp.getPrivateKey(), wrapped), + "tampered AES-GCM tag must not unwrap"); } } 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 36cf88c6..8827cb85 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -89,6 +89,7 @@ private static byte[] tdfECKeySaltCompute() { private static final String kWrapped = "wrapped"; private static final String kECWrapped = "ec-wrapped"; private static final String kHybridWrapped = "hybrid-wrapped"; + private static final String kMlkemWrapped = "mlkem-wrapped"; private static final String kKasProtocol = "kas"; private static final int kGcmIvSize = 12; private static final int kAesBlockSize = 16; @@ -238,13 +239,13 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA // ephemeralPublicKey intentionally left null — the ephemeral material is // carried inside the ASN.1 envelope in wrappedKey. } else if (keyType.isMLKEM()) { - // Pure ML-KEM (FIPS 203). Same KemProviders dispatch as hybrid, but - // keyAccess.type stays "wrapped" — reuses the RSA slot. KAS disambiguates - // from RSA via the registered key's algorithm. Wire format inside the - // base64'd wrappedKey: mlkem_ciphertext || AES-GCM(nonce||ct||tag). + // Pure ML-KEM (FIPS 203). Same KemProviders dispatch and same ASN.1 + // envelope as hybrid, but its own KAO scheme ("mlkem-wrapped") so the + // KAS knows to skip HKDF on the wrap-key derivation — see + // platform PR #3562 and adr/decisions/2026-06-16-mlkem-direct-key-wrap.md. byte[] wrapped = KemProviders.get(keyType).wrapDEK(keyType, kasInfo.PublicKey, symKey); keyAccess.wrappedKey = Base64.getEncoder().encodeToString(wrapped); - keyAccess.keyType = kWrapped; + keyAccess.keyType = kMlkemWrapped; } else if (keyType.isEc()) { var ecKeyWrappedKeyInfo = createECWrappedKey(kasInfo, symKey, keyType); keyAccess.wrappedKey = ecKeyWrappedKeyInfo.wrappedKey; From f42b22635ed893f3ecb3c384d03cacd7fadf2993 Mon Sep 17 00:00:00 2001 From: sujankota Date: Tue, 23 Jun 2026 11:16:12 -0400 Subject: [PATCH 3/3] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- scripts/test-mlkem.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-mlkem.sh b/scripts/test-mlkem.sh index 713e7998..f3dab3dc 100755 --- a/scripts/test-mlkem.sh +++ b/scripts/test-mlkem.sh @@ -145,7 +145,7 @@ fi ##### 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="${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