`.
+3. Asserts the resulting TDF manifest has:
+ - `keyAccess[0].type == "hybrid-wrapped"`
+ - `keyAccess[0].ephemeralPublicKey` empty (the ephemeral material is
+ carried inside the ASN.1 envelope in `wrappedKey`)
+ - `keyAccess[0].wrappedKey` starts with the ASN.1 SEQUENCE byte `0x30`
+4. Decrypts the TDF (this is the step that actually exercises hybrid
+ decapsulation on the KAS rewrap path).
+5. Diffs the decrypted payload against the original.
+
+On success the script also prints the plaintext, the full `keyAccess[0]`
+(KAO), and the decrypted output for each algorithm so you can eyeball the
+artifacts.
+
+### Prerequisites
+
+| Requirement | Notes |
+|---|---|
+| **JDK 17** | The project's Kotlin compiler can't parse newer JDK version strings. Use Corretto/Temurin/etc. 17. On macOS: `export JAVA_HOME=$(/usr/libexec/java_home -v 17)`. |
+| **Maven 3.9+** | Project uses standard `mvn clean install`. |
+| **Buf token** | Proto generation requires auth. Either `buf registry login` once, or export `BUF_INPUT_HTTPS_USERNAME` / `BUF_INPUT_HTTPS_PASSWORD`. |
+| **`sdk-pqc-bc` module on the classpath** | The BC-backed hybrid PQC implementation lives in the optional `sdk-pqc-bc` sibling module. `cmdline` declares it at runtime scope, so the test script picks it up automatically through `ServiceLoader`. FIPS deployments should omit `sdk-pqc-bc` and accept that hybrid PQC is unavailable in that mode — `TDF.createKeyAccess` throws a clean `SDKException` directing the user to add it. |
+| **Local platform with PQC support** | `opentdf/platform` checked out on a branch that implements `hpqt:*` KAS keys + the `hybrid-wrapped` rewrap path. See the platform repo for bring-up (`docker compose` / `make start`). |
+| **Hybrid KAS keys registered** | The local platform must have a KAS key registered for each `hpqt:*` algorithm you intend to test. Use `otdfctl` (or platform tooling) to register them. |
+| **CLI tools** | `java`, `mvn`, `unzip`, `jq` on `PATH`. `grpcurl` optional but recommended (drives the pre-flight check). |
+
+### Run it
+
+From the repo root:
+
+```bash
+# Full run — builds cmdline, pre-flight check, all 3 algorithms
+PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-hybrid-pqc.sh
+
+# Reuse an already-built cmdline jar (much faster on iterative runs)
+scripts/test-hybrid-pqc.sh --skip-build
+
+# One algorithm only
+scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey
+
+# Multiple specific algorithms (comma-separated)
+scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey,HybridSecp256r1MLKEM768Key
+
+# Skip the grpcurl pre-flight (use when grpcurl isn't installed)
+scripts/test-hybrid-pqc.sh --skip-kas-check
+```
+
+### Configuration
+
+All defaults match the existing CI workflow (`.github/workflows/checks.yaml`).
+Override via flag or env var:
+
+| Flag / Env | Default | Description |
+|---|---|---|
+| `--platform-endpoint` / `PLATFORM_ENDPOINT` | `http://localhost:8080` | Platform base URL |
+| `--kas-url` / `KAS_URL` | same as platform endpoint | KAS URL passed to cmdline `encrypt` |
+| `--client-id` / `CLIENT_ID` | `opentdf-sdk` | OIDC client id |
+| `--client-secret` / `CLIENT_SECRET` | `secret` | OIDC client secret |
+| `--attr` / `DATA_ATTR` | `https://example.com/attr/attr1/value/value1` | Attribute FQN attached to encrypt |
+| `--algorithms` | all three | Comma-separated subset of `KeyType` enum names |
+| `--skip-build` | (off) | Reuse `cmdline/target/cmdline.jar` |
+| `--skip-kas-check` | (off) | Skip the `grpcurl` pre-flight |
+
+### Expected output
+
+```
+[OK] hpqt:xwing: KAS returns hybrid PEM (-----BEGIN XWING PUBLIC KEY-----)
+[OK] hpqt:secp256r1-mlkem768: KAS returns hybrid PEM (-----BEGIN SECP256R1 MLKEM768 PUBLIC KEY-----)
+[OK] hpqt:secp384r1-mlkem1024: KAS returns hybrid PEM (-----BEGIN SECP384R1 MLKEM1024 PUBLIC KEY-----)
+...
+[OK] HybridXWingKey: manifest OK (hybrid-wrapped, ASN.1 envelope, no ephemeralPublicKey)
+[OK] HybridXWingKey: round-trip OK
+...
+All 3 hybrid algorithm(s) passed round-trip.
+```
+
+Exit code is 0 on success, 1 on any algorithm failure (other algorithms still
+attempted), 2 on misuse.
+
+### Troubleshooting
+
+| Symptom | Likely cause / fix |
+|---|---|
+| `Maven build failed ... Buf API token` | Run `buf registry login`, or export `BUF_INPUT_HTTPS_USERNAME` and `BUF_INPUT_HTTPS_PASSWORD`. |
+| `Maven build failed ... Kotlin ... isAtLeastJava9` (stack trace) | JDK too new. `export JAVA_HOME=$(/usr/libexec/java_home -v 17)` and rerun. |
+| `KAS returned no publicKey` | Platform isn't running, or isn't reachable at `$PLATFORM_ENDPOINT`. |
+| `KAS returned a non-hybrid PEM` | The platform is up but no hybrid KAS key is registered for that algorithm. Register one and rerun. |
+| `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.
diff --git a/scripts/test-hybrid-pqc.sh b/scripts/test-hybrid-pqc.sh
new file mode 100755
index 00000000..9ec76875
--- /dev/null
+++ b/scripts/test-hybrid-pqc.sh
@@ -0,0 +1,241 @@
+#!/usr/bin/env bash
+#
+# test-hybrid-pqc.sh — round-trip the Java SDK's hybrid post-quantum key
+# wrapping against a locally running OpenTDF platform.
+#
+# Per algorithm: encrypt → assert manifest → KAS rewrap → decrypt → diff.
+#
+# Prereqs:
+# * Local platform up at $PLATFORM_ENDPOINT with hybrid KAS keys registered
+# for hpqt:xwing, hpqt:secp256r1-mlkem768, hpqt:secp384r1-mlkem1024
+# * java, mvn (JDK 17), unzip, jq on PATH
+# * grpcurl optional (used only for the pre-flight key-publication check)
+#
+# Usage:
+# scripts/test-hybrid-pqc.sh # full run, all 3 algorithms
+# scripts/test-hybrid-pqc.sh --skip-build # reuse existing jar
+# scripts/test-hybrid-pqc.sh --skip-kas-check # skip grpcurl pre-flight
+# scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey # subset
+# PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-hybrid-pqc.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=(HybridXWingKey HybridSecp256r1MLKEM768Key HybridSecp384r1MLKEM1024Key)
+SKIP_BUILD=0
+SKIP_KAS_CHECK=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --skip-build) SKIP_BUILD=1; shift ;;
+ --skip-kas-check) SKIP_KAS_CHECK=1; shift ;;
+ --algorithms) IFS=, read -r -a ALGORITHMS <<< "$2"; shift 2 ;;
+ --platform-endpoint) PLATFORM_ENDPOINT="$2"; shift 2 ;;
+ --kas-url) KAS_URL="$2"; shift 2 ;;
+ --attr) DATA_ATTR="$2"; shift 2 ;;
+ --client-id) CLIENT_ID="$2"; shift 2 ;;
+ --client-secret) 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 hpqt:* 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
+ HybridXWingKey) echo "hpqt:xwing" ;;
+ HybridSecp256r1MLKEM768Key) echo "hpqt:secp256r1-mlkem768" ;;
+ HybridSecp384r1MLKEM1024Key) echo "hpqt:secp384r1-mlkem1024" ;;
+ *) return 1 ;;
+ esac
+}
+
+WORK_DIR="$(mktemp -d -t hybrid-pqc-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
+
+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 hybrid keys
+if [[ $SKIP_KAS_CHECK -eq 0 ]] && command -v grpcurl >/dev/null 2>&1; then
+ info "Pre-flight: querying KAS for hybrid 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 PQC-capable KAS branch and the key registered?"
+ exit 1
+ fi
+ # Hybrid PEMs have XWING or MLKEM markers; RSA/EC PEMs don't.
+ first_line=$(echo "$pem" | head -1)
+ if [[ "$first_line" != *"XWING"* && "$first_line" != *"MLKEM"* && "$first_line" != *"HPQT"* && "$first_line" != *"HYBRID"* ]]; then
+ fail "$alg: KAS returned a non-hybrid PEM (first line: $first_line)"
+ fail "The KAS doesn't appear to have a hybrid key registered for $alg"
+ exit 1
+ fi
+ pass "$alg: KAS returns hybrid PEM ($first_line)"
+ done
+else
+ info "Skipping KAS pre-flight check"
+fi
+
+##### 3. Round-trip each algorithm
+PAYLOAD="$WORK_DIR/payload"
+printf 'hybrid pqc 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" != "hybrid-wrapped" ]]; then
+ fail "$alg_name: type='$keyType' (expected 'hybrid-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 HybridCryptoTest checks.
+ first_byte=$(base64 -d <<<"$wrapped" 2>/dev/null | xxd -p -l 1 || 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 (hybrid-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[@]} hybrid 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/pom.xml b/sdk-pqc-bc/pom.xml
new file mode 100644
index 00000000..be7a47bf
--- /dev/null
+++ b/sdk-pqc-bc/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+ io.opentdf.platform
+ sdk-pom
+ 0.15.0
+
+ sdk-pqc-bc
+ jar
+ io.opentdf.platform:sdk-pqc-bc
+
+ BouncyCastle-backed implementations of the OpenTDF Java SDK's KemProvider SPI
+ (hybrid PQC: X-Wing, ECDH+ML-KEM-768, ECDH+ML-KEM-1024). Discovered at runtime
+ via java.util.ServiceLoader. FIPS deployments should omit this module — BC's
+ regular jar collides with bc-fips and the non-FIPS BC classes this module
+ imports are absent from bc-fips.
+
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+ io.opentdf.platform
+ sdk
+ ${project.version}
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.13
+
+
+
+
+ io.opentdf.platform
+ sdk
+ ${project.version}
+ test-jar
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.27.7
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.2.0
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ test
+
+
+ org.apache.logging.log4j
+ log4j-api
+ test
+
+
+
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
new file mode 100644
index 00000000..28f14e11
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/BouncyCastleKemProvider.java
@@ -0,0 +1,70 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+import io.opentdf.platform.sdk.spi.KemProvider;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * BouncyCastle-backed {@link KemProvider}. Supports the three hybrid PQC
+ * {@link KeyType}s currently defined in the SDK:
+ *
+ * - {@link KeyType#HybridXWingKey} (X-Wing)
+ * - {@link KeyType#HybridSecp256r1MLKEM768Key}
+ * - {@link KeyType#HybridSecp384r1MLKEM1024Key}
+ *
+ *
+ * Discovered by {@link io.opentdf.platform.sdk.spi.KemProviders} via
+ * {@link java.util.ServiceLoader}; consumers don't construct this directly.
+ * Lives in the optional {@code sdk-pqc-bc} module so the core {@code sdk}
+ * jar has no compile-time dependency on BouncyCastle.
+ */
+public final class BouncyCastleKemProvider implements KemProvider {
+
+ private static final Set SUPPORTED = EnumSet.of(
+ KeyType.HybridXWingKey,
+ KeyType.HybridSecp256r1MLKEM768Key,
+ KeyType.HybridSecp384r1MLKEM1024Key);
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public BouncyCastleKemProvider() {}
+
+ @Override
+ public Set supportedKeyTypes() {
+ return SUPPORTED;
+ }
+
+ @Override
+ public byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
+ switch (keyType) {
+ case HybridXWingKey:
+ return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPEM), dek);
+ case HybridSecp256r1MLKEM768Key:
+ return HybridNISTKeyPair.P256_MLKEM768.wrapDEK(
+ HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPEM), dek);
+ case HybridSecp384r1MLKEM1024Key:
+ return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(
+ HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek);
+ default:
+ throw new SDKException("BouncyCastleKemProvider does not handle: " + keyType);
+ }
+ }
+
+ @Override
+ public byte[] unwrapDEK(KeyType keyType, String privateKeyPEM, byte[] wrapped) {
+ switch (keyType) {
+ case HybridXWingKey:
+ return XWingKeyPair.unwrapDEK(XWingKeyPair.privateKeyFromPem(privateKeyPEM), wrapped);
+ case HybridSecp256r1MLKEM768Key:
+ return HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(
+ HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(privateKeyPEM), wrapped);
+ case HybridSecp384r1MLKEM1024Key:
+ return HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(
+ HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(privateKeyPEM), wrapped);
+ default:
+ throw new SDKException("BouncyCastleKemProvider does not handle: " + keyType);
+ }
+ }
+}
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
new file mode 100644
index 00000000..a6dfddbb
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridCrypto.java
@@ -0,0 +1,229 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.ECKeyPair;
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+/**
+ * Dispatcher and shared helpers for hybrid post-quantum key wrapping
+ * (X-Wing and NIST EC + ML-KEM).
+ *
+ * Wire format: ASN.1 DER SEQUENCE with two IMPLICIT context-tagged OCTET STRINGs
+ * SEQUENCE { [0] IMPLICIT OCTET STRING ciphertext, [1] IMPLICIT OCTET STRING encryptedDEK }
+ *
+ * Derived AES-256 wrap key: HKDF-SHA256(combinedSecret, salt=SHA-256("TDF"), info=empty).
+ * EncryptedDEK: AES-256-GCM(wrapKey).encrypt(DEK) with 12-byte IV prefix + 16-byte tag.
+ */
+final class HybridCrypto {
+
+ static final int WRAP_KEY_SIZE = 32;
+
+ // ASN.1 tag bytes used by the envelope.
+ private static final int TAG_SEQUENCE = 0x30;
+ private static final int TAG_CONTEXT_PRIMITIVE_0 = 0x80;
+ private static final int TAG_CONTEXT_PRIMITIVE_1 = 0x81;
+
+ private HybridCrypto() {}
+
+ /**
+ * Wrap a DEK against a hybrid public-key PEM. Dispatches across X-Wing and NIST hybrid types.
+ * Returns the ASN.1-encoded envelope used in {@code wrappedKey} for {@code hybrid-wrapped} key access.
+ */
+ static byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek) {
+ switch (keyType) {
+ case HybridXWingKey:
+ return XWingKeyPair.wrapDEK(XWingKeyPair.pubKeyFromPem(publicKeyPEM), dek);
+ case HybridSecp256r1MLKEM768Key:
+ return HybridNISTKeyPair.P256_MLKEM768.wrapDEK(
+ HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(publicKeyPEM), dek);
+ case HybridSecp384r1MLKEM1024Key:
+ return HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(
+ HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(publicKeyPEM), dek);
+ default:
+ throw new SDKException("unsupported hybrid key type: " + keyType);
+ }
+ }
+
+ /**
+ * Build the ASN.1 envelope from a hybrid KEM ciphertext and the AES-GCM(iv||ct) encrypted DEK.
+ */
+ static byte[] marshalEnvelope(byte[] hybridCiphertext, byte[] encryptedDEK) {
+ byte[] body = concat(
+ encodeTLV(TAG_CONTEXT_PRIMITIVE_0, hybridCiphertext),
+ encodeTLV(TAG_CONTEXT_PRIMITIVE_1, encryptedDEK));
+ return encodeTLV(TAG_SEQUENCE, body);
+ }
+
+ /**
+ * Parse the ASN.1 envelope. Returns {@code [hybridCiphertext, encryptedDEK]}.
+ * Rejects trailing bytes (matches the Go {@code asn1.Unmarshal} strict behaviour).
+ */
+ static byte[][] unmarshalEnvelope(byte[] der) {
+ Cursor c = new Cursor(der, 0);
+ int tag = c.readByte();
+ if (tag != TAG_SEQUENCE) {
+ throw new SDKException("expected ASN.1 SEQUENCE (0x30), got 0x" + Integer.toHexString(tag));
+ }
+ int seqLen = readLength(c);
+ int seqEnd = c.pos + seqLen;
+ if (seqEnd > der.length) {
+ throw new SDKException("hybrid wrapped key envelope length exceeds buffer");
+ }
+ if (seqEnd != der.length) {
+ throw new SDKException("hybrid wrapped key envelope has trailing bytes");
+ }
+ byte[] hybridCt = readImplicitOctetString(c, 0);
+ byte[] encDek = readImplicitOctetString(c, 1);
+ if (c.pos != seqEnd) {
+ throw new SDKException("hybrid wrapped key envelope SEQUENCE has trailing bytes");
+ }
+ return new byte[][] { hybridCt, encDek };
+ }
+
+ private static byte[] readImplicitOctetString(Cursor c, int expectedTagNo) {
+ int expectedTag = TAG_CONTEXT_PRIMITIVE_0 | expectedTagNo;
+ int tag = c.readByte();
+ if (tag != expectedTag) {
+ throw new SDKException("expected context tag " + expectedTagNo
+ + " (0x" + Integer.toHexString(expectedTag) + ") but got 0x" + Integer.toHexString(tag));
+ }
+ int len = readLength(c);
+ if (c.pos + len > c.buf.length) {
+ throw new SDKException("context-tagged element length exceeds buffer");
+ }
+ byte[] out = new byte[len];
+ System.arraycopy(c.buf, c.pos, out, 0, len);
+ c.pos += len;
+ return out;
+ }
+
+ private static byte[] encodeTLV(int tag, byte[] content) {
+ byte[] lenBytes = encodeLength(content.length);
+ byte[] out = new byte[1 + lenBytes.length + content.length];
+ out[0] = (byte) tag;
+ System.arraycopy(lenBytes, 0, out, 1, lenBytes.length);
+ System.arraycopy(content, 0, out, 1 + lenBytes.length, content.length);
+ return out;
+ }
+
+ private static byte[] encodeLength(int len) {
+ if (len < 0) {
+ throw new SDKException("negative ASN.1 length: " + len);
+ }
+ if (len < 0x80) {
+ return new byte[] { (byte) len };
+ }
+ // Long form: 0x80 | numBytes, then big-endian length bytes.
+ int numBytes = 0;
+ int tmp = len;
+ while (tmp > 0) { numBytes++; tmp >>>= 8; }
+ byte[] out = new byte[1 + numBytes];
+ out[0] = (byte) (0x80 | numBytes);
+ for (int i = numBytes; i > 0; i--) {
+ out[i] = (byte) (len & 0xFF);
+ len >>>= 8;
+ }
+ return out;
+ }
+
+ private static int readLength(Cursor c) {
+ int first = c.readByte();
+ if ((first & 0x80) == 0) {
+ return first;
+ }
+ int numBytes = first & 0x7F;
+ if (numBytes == 0 || numBytes > 4) {
+ // indefinite-length (numBytes == 0) is BER-only; DER rejects it.
+ // > 4 would overflow a positive 32-bit int and is implausible for our envelope.
+ throw new SDKException("invalid ASN.1 length encoding: numBytes=" + numBytes);
+ }
+ int len = 0;
+ for (int i = 0; i < numBytes; i++) {
+ len = (len << 8) | c.readByte();
+ }
+ if (len < 0) {
+ throw new SDKException("ASN.1 length overflowed signed int");
+ }
+ return len;
+ }
+
+ private static final class Cursor {
+ final byte[] buf;
+ int pos;
+ Cursor(byte[] buf, int pos) { this.buf = buf; this.pos = pos; }
+ int readByte() {
+ if (pos >= buf.length) {
+ throw new SDKException("unexpected end of ASN.1 input at offset " + pos);
+ }
+ return buf[pos++] & 0xFF;
+ }
+ }
+
+ private static byte[] concat(byte[] a, byte[] b) {
+ byte[] out = new byte[a.length + b.length];
+ System.arraycopy(a, 0, out, 0, a.length);
+ System.arraycopy(b, 0, out, a.length, b.length);
+ return out;
+ }
+
+ /**
+ * HKDF-SHA256 → 32-byte AES wrap key. Delegates to
+ * {@link ECKeyPair#calculateHKDF(byte[], byte[])} (HKDF-Extract + Expand,
+ * empty info, L = 32 — the parameters all three hybrid algorithms use).
+ */
+ static byte[] deriveWrapKey(byte[] combinedSecret) {
+ return ECKeyPair.calculateHKDF(defaultTDFSalt(), combinedSecret);
+ }
+
+ /**
+ * SHA-256("TDF") — matches the Go {@code defaultTDFSalt()} and Java {@code TDF.GLOBAL_KEY_SALT}.
+ */
+ static byte[] defaultTDFSalt() {
+ try {
+ MessageDigest d = MessageDigest.getInstance("SHA-256");
+ d.update("TDF".getBytes());
+ return d.digest();
+ } catch (NoSuchAlgorithmException e) {
+ throw new SDKException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * Encode a raw key into a PEM block with the given header type.
+ */
+ static String rawToPem(String blockType, byte[] raw, int expectedSize) {
+ if (raw.length != expectedSize) {
+ throw new SDKException("invalid " + blockType + " size: got " + raw.length + " want " + expectedSize);
+ }
+ String b64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(raw);
+ return "-----BEGIN " + blockType + "-----\n" + b64 + "\n-----END " + blockType + "-----\n";
+ }
+
+ /**
+ * Decode a PEM block of the expected type and content size. Strict on header type and size.
+ */
+ static byte[] decodeSizedPemBlock(String pem, String expectedType, int expectedSize) {
+ String header = "-----BEGIN " + expectedType + "-----";
+ String footer = "-----END " + expectedType + "-----";
+ int headerIdx = pem.indexOf(header);
+ int footerIdx = pem.indexOf(footer);
+ if (headerIdx < 0 || footerIdx < 0 || footerIdx <= headerIdx) {
+ throw new SDKException("failed to parse PEM formatted " + expectedType);
+ }
+ String body = pem.substring(headerIdx + header.length(), footerIdx).replaceAll("\\s", "");
+ byte[] raw;
+ try {
+ raw = Base64.getDecoder().decode(body);
+ } catch (IllegalArgumentException e) {
+ throw new SDKException("failed to base64-decode " + expectedType + " PEM body", e);
+ }
+ if (raw.length != expectedSize) {
+ throw new SDKException("invalid " + expectedType + " size: got " + raw.length + " want " + expectedSize);
+ }
+ return raw;
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java
new file mode 100644
index 00000000..2d4cf2df
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/HybridNISTKeyPair.java
@@ -0,0 +1,349 @@
+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.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.crypto.SecretWithEncapsulation;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMExtractor;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMGenerator;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.mlkem.MLKEMPublicKeyParameters;
+
+import javax.crypto.KeyAgreement;
+import java.math.BigInteger;
+import java.security.AlgorithmParameters;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPrivateKeySpec;
+import java.security.spec.ECPublicKeySpec;
+import java.util.Arrays;
+
+/**
+ * NIST hybrid post-quantum key wrapping (P-256 + ML-KEM-768 and P-384 + ML-KEM-1024).
+ *
+ * Wire layout of the wrapped DEK:
+ *
+ * SEQUENCE {
+ * [0] IMPLICIT OCTET STRING hybridCiphertext -- ephemeralECPoint || mlkemCiphertext
+ * [1] IMPLICIT OCTET STRING encryptedDEK -- AES-256-GCM(iv||ct||tag)
+ * }
+ *
+ * with {@code wrapKey = HKDF-SHA256(ecdhSecret || mlkemSecret, salt = SHA-256("TDF"))}.
+ *
+ * Raw key encoding:
+ *
+ * - Public key: {@code uncompressedECPoint || mlkemEncapsulationKey}
+ * - Private key: {@code paddedECScalar || mlkemSeed(64B)}
+ *
+ *
+ * EC operations use only stdlib JCA. ML-KEM operations use BouncyCastle's
+ * low-level API because no JDK 11 stdlib KEM API exists (added in JDK 21).
+ */
+public final class HybridNISTKeyPair {
+
+ public static final HybridNISTKeyPair P256_MLKEM768 = new HybridNISTKeyPair(
+ "secp256r1",
+ /* ecPubSize */ 65,
+ /* ecPrivSize */ 32,
+ /* mlkemPubSize */ 1184,
+ /* mlkemCtSize */ 1088,
+ MLKEMParameters.ml_kem_768,
+ "SECP256R1 MLKEM768 PUBLIC KEY",
+ "SECP256R1 MLKEM768 PRIVATE KEY",
+ KeyType.HybridSecp256r1MLKEM768Key);
+
+ public static final HybridNISTKeyPair P384_MLKEM1024 = new HybridNISTKeyPair(
+ "secp384r1",
+ /* ecPubSize */ 97,
+ /* ecPrivSize */ 48,
+ /* mlkemPubSize */ 1568,
+ /* mlkemCtSize */ 1568,
+ MLKEMParameters.ml_kem_1024,
+ "SECP384R1 MLKEM1024 PUBLIC KEY",
+ "SECP384R1 MLKEM1024 PRIVATE KEY",
+ KeyType.HybridSecp384r1MLKEM1024Key);
+
+ /** Fixed 64-byte ML-KEM seed (d || z) per FIPS 203. */
+ static final int MLKEM_SEED_SIZE = 64;
+
+ private final String curveName;
+ private final int ecPubSize;
+ private final int ecPrivSize;
+ private final int mlkemPubSize;
+ private final int mlkemCtSize;
+ private final MLKEMParameters mlkemParams;
+ private final String pubPemBlock;
+ private final String privPemBlock;
+ private final KeyType keyType;
+ private final ECParameterSpec ecParams;
+ private final int ecFieldByteSize;
+
+ private final byte[] publicKey;
+ private final byte[] privateKey;
+
+ private HybridNISTKeyPair(String curveName, int ecPubSize, int ecPrivSize, int mlkemPubSize, int mlkemCtSize,
+ MLKEMParameters mlkemParams, String pubPemBlock, String privPemBlock, KeyType keyType) {
+ this.curveName = curveName;
+ this.ecPubSize = ecPubSize;
+ this.ecPrivSize = ecPrivSize;
+ this.mlkemPubSize = mlkemPubSize;
+ this.mlkemCtSize = mlkemCtSize;
+ this.mlkemParams = mlkemParams;
+ this.pubPemBlock = pubPemBlock;
+ this.privPemBlock = privPemBlock;
+ this.keyType = keyType;
+ this.ecParams = ecParamsFor(curveName);
+ this.ecFieldByteSize = (this.ecParams.getCurve().getField().getFieldSize() + 7) / 8;
+ this.publicKey = null;
+ this.privateKey = null;
+ }
+
+ private HybridNISTKeyPair(HybridNISTKeyPair params, byte[] publicKey, byte[] privateKey) {
+ this.curveName = params.curveName;
+ this.ecPubSize = params.ecPubSize;
+ this.ecPrivSize = params.ecPrivSize;
+ this.mlkemPubSize = params.mlkemPubSize;
+ this.mlkemCtSize = params.mlkemCtSize;
+ this.mlkemParams = params.mlkemParams;
+ this.pubPemBlock = params.pubPemBlock;
+ this.privPemBlock = params.privPemBlock;
+ this.keyType = params.keyType;
+ this.ecParams = params.ecParams;
+ this.ecFieldByteSize = params.ecFieldByteSize;
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ int publicKeySize() { return ecPubSize + mlkemPubSize; }
+ int privateKeySize() { return ecPrivSize + MLKEM_SEED_SIZE; }
+ int ciphertextSize() { return ecPubSize + mlkemCtSize; }
+ KeyType keyType() { return keyType; }
+
+ public HybridNISTKeyPair generate() {
+ SecureRandom random = new SecureRandom();
+
+ // EC half — stdlib KeyPairGenerator gives us scalar + point in one call.
+ EcKeypairBytes ec = generateEcKeypairBytes(random);
+
+ // ML-KEM half — BC's low-level API; no JDK 11 stdlib alternative.
+ MLKEMKeyPairGenerator mlGen = new MLKEMKeyPairGenerator();
+ mlGen.init(new MLKEMKeyGenerationParameters(random, mlkemParams));
+ AsymmetricCipherKeyPair mkp = mlGen.generateKeyPair();
+ byte[] mlPubBytes = ((MLKEMPublicKeyParameters) mkp.getPublic()).getEncoded();
+ byte[] mlSeed = ((MLKEMPrivateKeyParameters) mkp.getPrivate()).getSeed();
+
+ if (mlPubBytes.length != mlkemPubSize) {
+ throw new SDKException("ML-KEM public key size " + mlPubBytes.length + " != expected " + mlkemPubSize);
+ }
+ if (mlSeed.length != MLKEM_SEED_SIZE) {
+ throw new SDKException("ML-KEM seed size " + mlSeed.length + " != expected " + MLKEM_SEED_SIZE);
+ }
+
+ byte[] pub = concat(ec.publicPoint, mlPubBytes);
+ byte[] priv = concat(ec.scalar, mlSeed);
+ return new HybridNISTKeyPair(this, pub, priv);
+ }
+
+ public String publicKeyInPemFormat() {
+ return HybridCrypto.rawToPem(pubPemBlock, publicKey, publicKeySize());
+ }
+
+ public String privateKeyInPemFormat() {
+ return HybridCrypto.rawToPem(privPemBlock, privateKey, privateKeySize());
+ }
+
+ public byte[] getPublicKey() { return publicKey == null ? null : publicKey.clone(); }
+ public byte[] getPrivateKey() { return privateKey == null ? null : privateKey.clone(); }
+
+ public byte[] pubKeyFromPem(String pem) {
+ return HybridCrypto.decodeSizedPemBlock(pem, pubPemBlock, publicKeySize());
+ }
+
+ public byte[] privateKeyFromPem(String pem) {
+ return HybridCrypto.decodeSizedPemBlock(pem, privPemBlock, privateKeySize());
+ }
+
+ public byte[] wrapDEK(byte[] rawPub, byte[] dek) {
+ if (rawPub.length != publicKeySize()) {
+ throw new SDKException("invalid " + keyType + " public key size: got " + rawPub.length + " want " + publicKeySize());
+ }
+ byte[] recipientEcPub = Arrays.copyOfRange(rawPub, 0, ecPubSize);
+ byte[] recipientMlPub = Arrays.copyOfRange(rawPub, ecPubSize, rawPub.length);
+
+ SecureRandom random = new SecureRandom();
+
+ // ECDH: generate ephemeral keypair, compute shared secret, ship the ephemeral point.
+ EcKeypairBytes ephemeral = generateEcKeypairBytes(random);
+ BigInteger ephemeralScalar = new BigInteger(1, ephemeral.scalar);
+ byte[] ecdhSecret = computeEcdhSecret(ephemeralScalar, recipientEcPub);
+
+ // ML-KEM encapsulate.
+ MLKEMPublicKeyParameters mlPub = new MLKEMPublicKeyParameters(mlkemParams, recipientMlPub);
+ SecretWithEncapsulation kemEnc = new MLKEMGenerator(random).generateEncapsulated(mlPub);
+ byte[] mlSecret = kemEnc.getSecret();
+ byte[] mlCiphertext = kemEnc.getEncapsulation();
+ if (mlCiphertext.length != mlkemCtSize) {
+ throw new SDKException("ML-KEM ciphertext size " + mlCiphertext.length + " != expected " + mlkemCtSize);
+ }
+
+ byte[] combinedSecret = concat(ecdhSecret, mlSecret);
+ byte[] hybridCt = concat(ephemeral.publicPoint, mlCiphertext);
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret);
+ byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
+ return HybridCrypto.marshalEnvelope(hybridCt, encryptedDek);
+ }
+
+ public byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
+ if (rawPriv.length != privateKeySize()) {
+ throw new SDKException("invalid " + keyType + " private key size: got " + rawPriv.length + " want " + privateKeySize());
+ }
+ byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[] hybridCt = parts[0];
+ byte[] encryptedDek = parts[1];
+ if (hybridCt.length != ciphertextSize()) {
+ throw new SDKException("invalid " + keyType + " ciphertext size: got " + hybridCt.length + " want " + ciphertextSize());
+ }
+
+ byte[] ephemeralEcPub = Arrays.copyOfRange(hybridCt, 0, ecPubSize);
+ byte[] mlCiphertext = Arrays.copyOfRange(hybridCt, ecPubSize, hybridCt.length);
+
+ byte[] ecScalarBytes = Arrays.copyOfRange(rawPriv, 0, ecPrivSize);
+ byte[] mlSeed = Arrays.copyOfRange(rawPriv, ecPrivSize, rawPriv.length);
+
+ BigInteger ecScalar = new BigInteger(1, ecScalarBytes);
+ byte[] ecdhSecret = computeEcdhSecret(ecScalar, ephemeralEcPub);
+
+ MLKEMPrivateKeyParameters mlPriv = new MLKEMPrivateKeyParameters(mlkemParams, mlSeed);
+ byte[] mlSecret = new MLKEMExtractor(mlPriv).extractSecret(mlCiphertext);
+
+ byte[] combinedSecret = concat(ecdhSecret, mlSecret);
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(combinedSecret);
+ return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
+ }
+
+ /** Resolve a named-curve {@link ECParameterSpec} via stdlib JCA. */
+ private static ECParameterSpec ecParamsFor(String curveName) {
+ try {
+ AlgorithmParameters ap = AlgorithmParameters.getInstance("EC");
+ ap.init(new ECGenParameterSpec(curveName));
+ return ap.getParameterSpec(ECParameterSpec.class);
+ } catch (Exception e) {
+ throw new SDKException("EC parameters not available for curve " + curveName, e);
+ }
+ }
+
+ /**
+ * Generate an EC keypair via stdlib and return scalar (padded) and uncompressed-point bytes.
+ */
+ private EcKeypairBytes generateEcKeypairBytes(SecureRandom random) {
+ try {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
+ kpg.initialize(new ECGenParameterSpec(curveName), random);
+ KeyPair kp = kpg.generateKeyPair();
+ ECPrivateKey priv = (ECPrivateKey) kp.getPrivate();
+ ECPublicKey pub = (ECPublicKey) kp.getPublic();
+ byte[] scalar = toFixedLength(priv.getS(), ecPrivSize);
+ byte[] point = encodeUncompressedPoint(pub.getW(), ecFieldByteSize);
+ if (point.length != ecPubSize) {
+ throw new SDKException("encoded EC point size " + point.length + " != expected " + ecPubSize);
+ }
+ return new EcKeypairBytes(scalar, point);
+ } catch (Exception e) {
+ throw new SDKException("failed to generate EC keypair on " + curveName, e);
+ }
+ }
+
+ /** Standard ECDH via JCA: x-coordinate of {@code scalar * peerPoint}, fixed-size big-endian. */
+ private byte[] computeEcdhSecret(BigInteger scalar, byte[] peerUncompressedPoint) {
+ try {
+ ECPoint peerPoint = decodeUncompressedPoint(peerUncompressedPoint, ecFieldByteSize);
+ ECPublicKeySpec peerSpec = new ECPublicKeySpec(peerPoint, ecParams);
+ ECPrivateKeySpec mySpec = new ECPrivateKeySpec(scalar, ecParams);
+ KeyFactory kf = KeyFactory.getInstance("EC");
+
+ KeyAgreement ka = KeyAgreement.getInstance("ECDH");
+ ka.init(kf.generatePrivate(mySpec));
+ ka.doPhase(kf.generatePublic(peerSpec), /* lastPhase */ true);
+ byte[] raw = ka.generateSecret();
+ // JCA may strip leading zeros; left-pad to the field size to match Go's crypto/ecdh ECDH output.
+ if (raw.length != ecFieldByteSize) {
+ raw = leftPad(raw, ecFieldByteSize);
+ }
+ return raw;
+ } catch (Exception e) {
+ throw new SDKException("ECDH failed for " + curveName, e);
+ }
+ }
+
+ private static byte[] encodeUncompressedPoint(ECPoint w, int byteSize) {
+ byte[] x = toFixedLength(w.getAffineX(), byteSize);
+ byte[] y = toFixedLength(w.getAffineY(), byteSize);
+ byte[] out = new byte[1 + 2 * byteSize];
+ out[0] = 0x04;
+ System.arraycopy(x, 0, out, 1, byteSize);
+ System.arraycopy(y, 0, out, 1 + byteSize, byteSize);
+ return out;
+ }
+
+ private static ECPoint decodeUncompressedPoint(byte[] encoded, int byteSize) {
+ if (encoded.length != 1 + 2 * byteSize || encoded[0] != 0x04) {
+ throw new SDKException("invalid uncompressed EC point encoding (length=" + encoded.length
+ + ", lead=0x" + Integer.toHexString(encoded[0] & 0xFF) + ")");
+ }
+ BigInteger x = new BigInteger(1, Arrays.copyOfRange(encoded, 1, 1 + byteSize));
+ BigInteger y = new BigInteger(1, Arrays.copyOfRange(encoded, 1 + byteSize, 1 + 2 * byteSize));
+ return new ECPoint(x, y);
+ }
+
+ /** Convert a non-negative {@link BigInteger} to a fixed-length big-endian byte array. */
+ private static byte[] toFixedLength(BigInteger value, int length) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length == length) return bytes;
+ if (bytes.length > length) {
+ int excess = bytes.length - length;
+ for (int i = 0; i < excess; i++) {
+ if (bytes[i] != 0) {
+ throw new SDKException("value too large for width " + length);
+ }
+ }
+ return Arrays.copyOfRange(bytes, excess, bytes.length);
+ }
+ byte[] out = new byte[length];
+ System.arraycopy(bytes, 0, out, length - bytes.length, bytes.length);
+ return out;
+ }
+
+ private static byte[] leftPad(byte[] src, int width) {
+ if (src.length >= width) return src;
+ byte[] out = new byte[width];
+ System.arraycopy(src, 0, out, width - src.length, src.length);
+ return out;
+ }
+
+ private static byte[] concat(byte[] a, byte[] b) {
+ byte[] out = new byte[a.length + b.length];
+ System.arraycopy(a, 0, out, 0, a.length);
+ System.arraycopy(b, 0, out, a.length, b.length);
+ return out;
+ }
+
+ private static final class EcKeypairBytes {
+ final byte[] scalar;
+ final byte[] publicPoint;
+ EcKeypairBytes(byte[] scalar, byte[] publicPoint) {
+ this.scalar = scalar;
+ this.publicPoint = publicPoint;
+ }
+ }
+}
diff --git a/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java
new file mode 100644
index 00000000..eee16f06
--- /dev/null
+++ b/sdk-pqc-bc/src/main/java/io/opentdf/platform/sdk/pqc/bc/XWingKeyPair.java
@@ -0,0 +1,94 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.AesGcm;
+import io.opentdf.platform.sdk.SDKException;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.crypto.SecretWithEncapsulation;
+import org.bouncycastle.pqc.crypto.xwing.XWingKEMExtractor;
+import org.bouncycastle.pqc.crypto.xwing.XWingKEMGenerator;
+import org.bouncycastle.pqc.crypto.xwing.XWingKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.xwing.XWingKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.xwing.XWingPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.xwing.XWingPublicKeyParameters;
+
+import java.security.SecureRandom;
+
+/**
+ * X-Wing (X25519 + ML-KEM-768) KEM with the ASN.1 envelope format used by TDF
+ * {@code hybrid-wrapped} key access objects. Mirrors {@code lib/ocrypto/xwing.go}.
+ */
+public final class XWingKeyPair {
+
+ static final String PEM_BLOCK_PUBLIC_KEY = "XWING PUBLIC KEY";
+ static final String PEM_BLOCK_PRIVATE_KEY = "XWING PRIVATE KEY";
+
+ static final int PUBLIC_KEY_SIZE = 1216;
+ /** X-Wing private key is a 32-byte seed; full X25519 + ML-KEM-768 components are derived at runtime. */
+ static final int PRIVATE_KEY_SIZE = 32;
+ static final int CIPHERTEXT_SIZE = 1120;
+ static final int SHARED_SECRET_SIZE = 32;
+
+ private final byte[] publicKey;
+ private final byte[] privateKey;
+
+ private XWingKeyPair(byte[] publicKey, byte[] privateKey) {
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ public static XWingKeyPair generate() {
+ XWingKeyPairGenerator gen = new XWingKeyPairGenerator();
+ gen.init(new XWingKeyGenerationParameters(new SecureRandom()));
+ AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ XWingPublicKeyParameters pub = (XWingPublicKeyParameters) kp.getPublic();
+ XWingPrivateKeyParameters priv = (XWingPrivateKeyParameters) kp.getPrivate();
+ return new XWingKeyPair(pub.getEncoded(), priv.getEncoded());
+ }
+
+ public String publicKeyInPemFormat() {
+ return HybridCrypto.rawToPem(PEM_BLOCK_PUBLIC_KEY, publicKey, PUBLIC_KEY_SIZE);
+ }
+
+ public String privateKeyInPemFormat() {
+ return HybridCrypto.rawToPem(PEM_BLOCK_PRIVATE_KEY, privateKey, PRIVATE_KEY_SIZE);
+ }
+
+ public static byte[] pubKeyFromPem(String pem) {
+ return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PUBLIC_KEY, PUBLIC_KEY_SIZE);
+ }
+
+ public static byte[] privateKeyFromPem(String pem) {
+ return HybridCrypto.decodeSizedPemBlock(pem, PEM_BLOCK_PRIVATE_KEY, PRIVATE_KEY_SIZE);
+ }
+
+ public static byte[] wrapDEK(byte[] rawPub, byte[] dek) {
+ if (rawPub.length != PUBLIC_KEY_SIZE) {
+ throw new SDKException("invalid X-Wing public key size: got " + rawPub.length + " want " + PUBLIC_KEY_SIZE);
+ }
+ XWingPublicKeyParameters pub = new XWingPublicKeyParameters(rawPub);
+ SecretWithEncapsulation enc = new XWingKEMGenerator(new SecureRandom()).generateEncapsulated(pub);
+ byte[] sharedSecret = enc.getSecret();
+ byte[] ciphertext = enc.getEncapsulation();
+
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ byte[] encryptedDek = new AesGcm(wrapKey).encrypt(dek).asBytes();
+ return HybridCrypto.marshalEnvelope(ciphertext, encryptedDek);
+ }
+
+ public static byte[] unwrapDEK(byte[] rawPriv, byte[] wrappedDer) {
+ if (rawPriv.length != PRIVATE_KEY_SIZE) {
+ throw new SDKException("invalid X-Wing private key size: got " + rawPriv.length + " want " + PRIVATE_KEY_SIZE);
+ }
+ byte[][] parts = HybridCrypto.unmarshalEnvelope(wrappedDer);
+ byte[] ciphertext = parts[0];
+ byte[] encryptedDek = parts[1];
+ if (ciphertext.length != CIPHERTEXT_SIZE) {
+ throw new SDKException("invalid X-Wing ciphertext size: got " + ciphertext.length + " want " + CIPHERTEXT_SIZE);
+ }
+
+ XWingPrivateKeyParameters priv = new XWingPrivateKeyParameters(rawPriv);
+ byte[] sharedSecret = new XWingKEMExtractor(priv).extractSecret(ciphertext);
+ byte[] wrapKey = HybridCrypto.deriveWrapKey(sharedSecret);
+ return new AesGcm(wrapKey).decrypt(new AesGcm.Encrypted(encryptedDek));
+ }
+}
diff --git a/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider b/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider
new file mode 100644
index 00000000..cacd6a83
--- /dev/null
+++ b/sdk-pqc-bc/src/main/resources/META-INF/services/io.opentdf.platform.sdk.spi.KemProvider
@@ -0,0 +1 @@
+io.opentdf.platform.sdk.pqc.bc.BouncyCastleKemProvider
diff --git a/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
new file mode 100644
index 00000000..67695767
--- /dev/null
+++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/TDFHybridTest.java
@@ -0,0 +1,159 @@
+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.HybridNISTKeyPair;
+import io.opentdf.platform.sdk.pqc.bc.XWingKeyPair;
+import com.connectrpc.ResponseMessage;
+import com.connectrpc.UnaryBlockingCall;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Mirrors {@code sdk/tdf_hybrid_test.go}. Creates a TDF using each hybrid KAS key type,
+ * then asserts the resulting manifest's KeyAccess object has:
+ *
+ * - {@code keyType == "hybrid-wrapped"}
+ * - {@code ephemeralPublicKey == null} (ephemeral material lives in the wrappedKey envelope)
+ * - a {@code wrappedKey} that round-trips back to the original payload key via the matching
+ * private key.
+ *
+ */
+class TDFHybridTest {
+
+ private static KeyAccessServerRegistryServiceClient kasRegistryService;
+
+ @BeforeAll
+ static void setupMocks() {
+ kasRegistryService = mock(KeyAccessServerRegistryServiceClient.class);
+ ListKeyAccessServersResponse mockResponse = ListKeyAccessServersResponse.newBuilder()
+ .addKeyAccessServers(KeyAccessServer.newBuilder().setUri("https://kas.example.com").build())
+ .build();
+ when(kasRegistryService.listKeyAccessServersBlocking(any(ListKeyAccessServersRequest.class), any()))
+ .thenReturn(new UnaryBlockingCall<>() {
+ @Override
+ public ResponseMessage execute() {
+ return new ResponseMessage.Success<>(mockResponse,
+ Collections.emptyMap(), Collections.emptyMap());
+ }
+
+ @Override
+ public void cancel() {}
+ });
+ }
+
+ @Test
+ void createKeyAccessWithXWingKey() throws Exception {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
+ KeyType.HybridXWingKey, kp.publicKeyInPemFormat(), "xwing-kid");
+ assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
+ assertThat(ka.ephemeralPublicKey).isNull();
+ assertThat(ka.wrappedKey).isNotEmpty();
+
+ // Round-trip: unwrap with the matching private key — confirms wire format is valid.
+ byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
+ byte[] privRaw = XWingKeyPair.privateKeyFromPem(kp.privateKeyInPemFormat());
+ byte[] symKey = XWingKeyPair.unwrapDEK(privRaw, wrappedDer);
+ assertThat(symKey).hasSize(32);
+ }
+
+ @Test
+ void createKeyAccessWithP256MLKEM768Key() throws Exception {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate();
+ Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
+ KeyType.HybridSecp256r1MLKEM768Key, kp.publicKeyInPemFormat(), "p256mlkem768-kid");
+ assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
+ assertThat(ka.ephemeralPublicKey).isNull();
+ assertThat(ka.wrappedKey).isNotEmpty();
+
+ byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
+ byte[] privRaw = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(kp.privateKeyInPemFormat());
+ byte[] symKey = HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(privRaw, wrappedDer);
+ assertThat(symKey).hasSize(32);
+ }
+
+ @Test
+ void createKeyAccessWithP384MLKEM1024Key() throws Exception {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate();
+ Manifest.KeyAccess ka = createTDFAndGetFirstKeyAccess(
+ KeyType.HybridSecp384r1MLKEM1024Key, kp.publicKeyInPemFormat(), "p384mlkem1024-kid");
+ assertThat(ka.keyType).isEqualTo("hybrid-wrapped");
+ assertThat(ka.ephemeralPublicKey).isNull();
+ assertThat(ka.wrappedKey).isNotEmpty();
+
+ byte[] wrappedDer = Base64.getDecoder().decode(ka.wrappedKey);
+ byte[] privRaw = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(kp.privateKeyInPemFormat());
+ byte[] symKey = HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(privRaw, wrappedDer);
+ assertThat(symKey).hasSize(32);
+ }
+
+ /**
+ * Build a fake KAS that returns {@code (algorithm, publicKeyPem)} as its public key, then
+ * call {@code TDF.createTDF} on a 32-byte 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 hybrid 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("hybrid hello".getBytes());
+ 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/HybridCryptoTest.java b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/HybridCryptoTest.java
new file mode 100644
index 00000000..477571b9
--- /dev/null
+++ b/sdk-pqc-bc/src/test/java/io/opentdf/platform/sdk/pqc/bc/HybridCryptoTest.java
@@ -0,0 +1,168 @@
+package io.opentdf.platform.sdk.pqc.bc;
+
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+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 hybrid post-quantum key wrapping. Mirrors
+ * {@code lib/ocrypto/xwing_test.go} and {@code lib/ocrypto/hybrid_nist_test.go}.
+ *
+ * Each scheme is exercised through a full round-trip: generate keypair → PEM
+ * round-trip → wrap DEK → unwrap DEK → assert equal. The unwrap path is
+ * also used as a wire-format guard: if marshal/unmarshal drift, the round-trip
+ * fails.
+ */
+class HybridCryptoTest {
+
+ private static final byte[] DEK = "0123456789abcdef0123456789abcdef".getBytes();
+
+ @Test
+ void xwingRoundTrip() {
+ XWingKeyPair kp = XWingKeyPair.generate();
+
+ String pubPem = kp.publicKeyInPemFormat();
+ String privPem = kp.privateKeyInPemFormat();
+ assertTrue(pubPem.startsWith("-----BEGIN XWING PUBLIC KEY-----"), "public PEM header");
+ assertTrue(privPem.contains("XWING PRIVATE KEY"), "private PEM header");
+
+ byte[] rawPub = XWingKeyPair.pubKeyFromPem(pubPem);
+ byte[] rawPriv = XWingKeyPair.privateKeyFromPem(privPem);
+ assertEquals(XWingKeyPair.PUBLIC_KEY_SIZE, rawPub.length);
+ assertEquals(XWingKeyPair.PRIVATE_KEY_SIZE, rawPriv.length);
+
+ byte[] wrapped = XWingKeyPair.wrapDEK(rawPub, DEK);
+ assertNotNull(wrapped);
+ // ASN.1 SEQUENCE header byte
+ assertEquals((byte) 0x30, wrapped[0]);
+
+ byte[] unwrapped = XWingKeyPair.unwrapDEK(rawPriv, wrapped);
+ assertArrayEquals(DEK, unwrapped);
+ }
+
+ @Test
+ void p256mlkem768RoundTrip() {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P256_MLKEM768.generate();
+
+ String pubPem = kp.publicKeyInPemFormat();
+ String privPem = kp.privateKeyInPemFormat();
+ assertTrue(pubPem.contains("SECP256R1 MLKEM768 PUBLIC KEY"), "public PEM header");
+ assertTrue(privPem.contains("SECP256R1 MLKEM768 PRIVATE KEY"), "private PEM header");
+
+ byte[] rawPub = HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(pubPem);
+ byte[] rawPriv = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(privPem);
+ assertEquals(65 + 1184, rawPub.length);
+ assertEquals(32 + 64, rawPriv.length);
+
+ byte[] wrapped = HybridNISTKeyPair.P256_MLKEM768.wrapDEK(rawPub, DEK);
+ byte[] unwrapped = HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(rawPriv, wrapped);
+ assertArrayEquals(DEK, unwrapped);
+ }
+
+ @Test
+ void p384mlkem1024RoundTrip() {
+ HybridNISTKeyPair kp = HybridNISTKeyPair.P384_MLKEM1024.generate();
+
+ String pubPem = kp.publicKeyInPemFormat();
+ String privPem = kp.privateKeyInPemFormat();
+ assertTrue(pubPem.contains("SECP384R1 MLKEM1024 PUBLIC KEY"), "public PEM header");
+ assertTrue(privPem.contains("SECP384R1 MLKEM1024 PRIVATE KEY"), "private PEM header");
+
+ byte[] rawPub = HybridNISTKeyPair.P384_MLKEM1024.pubKeyFromPem(pubPem);
+ byte[] rawPriv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(privPem);
+ assertEquals(97 + 1568, rawPub.length);
+ assertEquals(48 + 64, rawPriv.length);
+
+ byte[] wrapped = HybridNISTKeyPair.P384_MLKEM1024.wrapDEK(rawPub, DEK);
+ byte[] unwrapped = HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(rawPriv, wrapped);
+ assertArrayEquals(DEK, unwrapped);
+ }
+
+ @Test
+ void wrapProducesDifferentCiphertextEachCall() {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ byte[] rawPub = XWingKeyPair.pubKeyFromPem(kp.publicKeyInPemFormat());
+ byte[] w1 = XWingKeyPair.wrapDEK(rawPub, DEK);
+ byte[] w2 = XWingKeyPair.wrapDEK(rawPub, DEK);
+ assertNotEquals(Arrays.toString(w1), Arrays.toString(w2),
+ "wrap must be randomised (fresh ephemeral + GCM IV) — two calls produced identical ciphertext");
+ }
+
+ @Test
+ void crossSchemePrivateKeyFails() {
+ HybridNISTKeyPair p256 = HybridNISTKeyPair.P256_MLKEM768.generate();
+ HybridNISTKeyPair p384 = HybridNISTKeyPair.P384_MLKEM1024.generate();
+
+ byte[] p256Pub = HybridNISTKeyPair.P256_MLKEM768.pubKeyFromPem(p256.publicKeyInPemFormat());
+ byte[] wrapped = HybridNISTKeyPair.P256_MLKEM768.wrapDEK(p256Pub, DEK);
+
+ byte[] p384Priv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(p384.privateKeyInPemFormat());
+ assertThrows(SDKException.class,
+ () -> HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(p384Priv, wrapped),
+ "P-384 private key must reject a P-256 wrapped envelope");
+ }
+
+ @Test
+ void pemBlockTypeMismatchRejected() {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ String pem = kp.publicKeyInPemFormat();
+ String mangled = pem.replace("XWING PUBLIC KEY", "WRONG PUBLIC KEY");
+ assertThrows(SDKException.class, () -> XWingKeyPair.pubKeyFromPem(mangled));
+ }
+
+ @Test
+ void pemBodySizeMismatchRejected() {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ String pem = kp.publicKeyInPemFormat();
+ // Truncate one base64 char inside the body — yields wrong byte length after decode.
+ int headerEnd = pem.indexOf('\n') + 1;
+ String truncated = pem.substring(0, headerEnd) + pem.substring(headerEnd + 4);
+ assertThrows(SDKException.class, () -> XWingKeyPair.pubKeyFromPem(truncated));
+ }
+
+ @Test
+ void dispatcherSelectsCorrectScheme() {
+ // Round-trip via the public HybridCrypto.wrapDEK dispatcher for each key type.
+ XWingKeyPair xw = XWingKeyPair.generate();
+ byte[] xwWrapped = HybridCrypto.wrapDEK(KeyType.HybridXWingKey, xw.publicKeyInPemFormat(), DEK);
+ byte[] xwPriv = XWingKeyPair.privateKeyFromPem(xw.privateKeyInPemFormat());
+ assertArrayEquals(DEK, XWingKeyPair.unwrapDEK(xwPriv, xwWrapped));
+
+ HybridNISTKeyPair p256 = HybridNISTKeyPair.P256_MLKEM768.generate();
+ byte[] p256Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp256r1MLKEM768Key,
+ p256.publicKeyInPemFormat(), DEK);
+ byte[] p256Priv = HybridNISTKeyPair.P256_MLKEM768.privateKeyFromPem(p256.privateKeyInPemFormat());
+ assertArrayEquals(DEK, HybridNISTKeyPair.P256_MLKEM768.unwrapDEK(p256Priv, p256Wrapped));
+
+ HybridNISTKeyPair p384 = HybridNISTKeyPair.P384_MLKEM1024.generate();
+ byte[] p384Wrapped = HybridCrypto.wrapDEK(KeyType.HybridSecp384r1MLKEM1024Key,
+ p384.publicKeyInPemFormat(), DEK);
+ byte[] p384Priv = HybridNISTKeyPair.P384_MLKEM1024.privateKeyFromPem(p384.privateKeyInPemFormat());
+ assertArrayEquals(DEK, HybridNISTKeyPair.P384_MLKEM1024.unwrapDEK(p384Priv, p384Wrapped));
+ }
+
+ @Test
+ void dispatcherRejectsNonHybridKeyType() {
+ assertThrows(SDKException.class,
+ () -> HybridCrypto.wrapDEK(KeyType.RSA2048Key, "not-a-real-pem", DEK));
+ }
+
+ @Test
+ void truncatedEnvelopeRejected() {
+ XWingKeyPair kp = XWingKeyPair.generate();
+ byte[] rawPub = XWingKeyPair.pubKeyFromPem(kp.publicKeyInPemFormat());
+ byte[] rawPriv = XWingKeyPair.privateKeyFromPem(kp.privateKeyInPemFormat());
+ byte[] wrapped = XWingKeyPair.wrapDEK(rawPub, DEK);
+ byte[] truncated = Arrays.copyOf(wrapped, wrapped.length - 10);
+ assertThrows(SDKException.class, () -> XWingKeyPair.unwrapDEK(rawPriv, truncated));
+ }
+}
diff --git a/sdk/pom.xml b/sdk/pom.xml
index de6b7618..b20e741e 100644
--- a/sdk/pom.xml
+++ b/sdk/pom.xml
@@ -296,6 +296,20 @@
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ attach-test-jar
+
+ test-jar
+
+
+
+
org.jetbrains.dokka
@@ -483,6 +497,9 @@
true
+
org.bouncycastle
bcpkix-jdk18on
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
index 06be4cf6..2b0e607e 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java
@@ -14,7 +14,10 @@ public enum KeyType {
RSA4096Key("rsa:4096"),
EC256Key("ec:secp256r1", SECP256R1),
EC384Key("ec:secp384r1", SECP384R1),
- EC521Key("ec:secp521r1", SECP521R1);
+ EC521Key("ec:secp521r1", SECP521R1),
+ HybridXWingKey("hpqt:xwing"),
+ HybridSecp256r1MLKEM768Key("hpqt:secp256r1-mlkem768"),
+ HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024");
private final String keyType;
private final ECCurve curve;
@@ -93,4 +96,15 @@ public static KeyType fromPublicKeyAlgorithm(KasPublicKeyAlgEnum algorithm) {
public boolean isEc() {
return this.curve != null;
}
+
+ public boolean isHybrid() {
+ switch (this) {
+ case HybridXWingKey:
+ case HybridSecp256r1MLKEM768Key:
+ case HybridSecp384r1MLKEM1024Key:
+ 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 b30460eb..05923083 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -8,6 +8,7 @@
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest;
import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersResponse;
import io.opentdf.platform.sdk.Config.KASInfo;
+import io.opentdf.platform.sdk.spi.KemProviders;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
@@ -87,6 +88,7 @@ private static byte[] tdfECKeySaltCompute() {
private static final String kSplitKeyType = "split";
private static final String kWrapped = "wrapped";
private static final String kECWrapped = "ec-wrapped";
+ private static final String kHybridWrapped = "hybrid-wrapped";
private static final String kKasProtocol = "kas";
private static final int kGcmIvSize = 12;
private static final int kAesBlockSize = 16;
@@ -226,7 +228,16 @@ private Manifest.KeyAccess createKeyAccess(Config.TDFConfig tdfConfig, Config.KA
: kasInfo.Algorithm;
var keyType = KeyType.fromString(algorithm);
- if (keyType.isEc()) {
+ if (keyType.isHybrid()) {
+ // Dispatch to whichever KemProvider claims this KeyType (typically the
+ // BouncyCastle-backed impl in sdk-pqc-bc). Keeps the core sdk jar free
+ // of BC compile-time references so the fips Maven profile stays clean.
+ byte[] wrapped = KemProviders.get(keyType).wrapDEK(keyType, kasInfo.PublicKey, symKey);
+ keyAccess.wrappedKey = Base64.getEncoder().encodeToString(wrapped);
+ keyAccess.keyType = kHybridWrapped;
+ // ephemeralPublicKey intentionally left null — the ephemeral material is
+ // carried inside the ASN.1 envelope in wrappedKey.
+ } else if (keyType.isEc()) {
var ecKeyWrappedKeyInfo = createECWrappedKey(kasInfo, symKey, keyType);
keyAccess.wrappedKey = ecKeyWrappedKeyInfo.wrappedKey;
keyAccess.ephemeralPublicKey = ecKeyWrappedKeyInfo.publicKey;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProvider.java b/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProvider.java
new file mode 100644
index 00000000..e6e65aec
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProvider.java
@@ -0,0 +1,46 @@
+package io.opentdf.platform.sdk.spi;
+
+import io.opentdf.platform.sdk.KeyType;
+
+import java.util.Set;
+
+/**
+ * Service provider interface for post-quantum key encapsulation mechanisms.
+ *
+ * Implementations live in optional sibling modules (e.g. {@code sdk-pqc-bc}
+ * for the BouncyCastle-backed providers) and are discovered at runtime via
+ * {@link java.util.ServiceLoader}. The core {@code sdk} module has no
+ * compile-time dependency on any PQC library — this keeps the core jar
+ * provider-agnostic per ADR 0001 and lets the {@code fips} Maven profile
+ * exclude PQC entirely.
+ *
+ *
Wire-format contract: {@link #wrapDEK} returns the raw bytes that go
+ * into {@code keyAccess.wrappedKey} (after base64 encoding by the caller).
+ * The exact byte layout per {@link KeyType} is fixed by the platform spec
+ * and must be byte-compatible with the Go SDK / KAS counterparts.
+ */
+public interface KemProvider {
+
+ /**
+ * @return the {@link KeyType}s this provider can wrap and unwrap.
+ * Used by {@link KemProviders} to build the dispatch table at
+ * registration time.
+ */
+ Set supportedKeyTypes();
+
+ /**
+ * Wrap a data encryption key against a recipient's public-key PEM.
+ *
+ * @param keyType hybrid or pure-PQC algorithm; must be a member of {@link #supportedKeyTypes()}
+ * @param publicKeyPEM recipient KAS public key, PEM-encoded
+ * @param dek the symmetric data encryption key being wrapped
+ * @return raw envelope bytes (caller base64-encodes for {@code keyAccess.wrappedKey})
+ */
+ byte[] wrapDEK(KeyType keyType, String publicKeyPEM, byte[] dek);
+
+ /**
+ * Inverse of {@link #wrapDEK}. Used by tests and any future client-side
+ * decap path; the production decrypt flow defers unwrap to the KAS.
+ */
+ byte[] unwrapDEK(KeyType keyType, String privateKeyPEM, byte[] wrapped);
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProviders.java b/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProviders.java
new file mode 100644
index 00000000..d098652d
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/spi/KemProviders.java
@@ -0,0 +1,87 @@
+package io.opentdf.platform.sdk.spi;
+
+import io.opentdf.platform.sdk.KeyType;
+import io.opentdf.platform.sdk.SDKException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ServiceLoader;
+import java.util.Set;
+
+/**
+ * Registry of {@link KemProvider}s discovered via {@link ServiceLoader}.
+ *
+ * On first access, scans the classpath for
+ * {@code META-INF/services/io.opentdf.platform.sdk.spi.KemProvider} entries
+ * and builds an unmodifiable {@code KeyType → KemProvider} map. If multiple
+ * providers claim the same {@link KeyType}, the first one discovered wins
+ * (deterministic per classpath order) and a warning is logged.
+ *
+ *
{@link #get(KeyType)} throws {@link SDKException} with a clear message
+ * directing the user to add the relevant provider module (typically
+ * {@code sdk-pqc-bc}) when no provider is registered for the requested
+ * {@link KeyType}. This is the FIPS-safe failure mode: no
+ * {@code NoClassDefFoundError}, no startup-time linkage to BouncyCastle.
+ */
+public final class KemProviders {
+
+ private static final Logger logger = LoggerFactory.getLogger(KemProviders.class);
+
+ private static volatile Map cache;
+
+ private KemProviders() {}
+
+ /**
+ * @return the provider registered for {@code keyType}.
+ * @throws SDKException if no provider is registered. The message tells the user how to fix it.
+ */
+ public static KemProvider get(KeyType keyType) {
+ KemProvider p = providers().get(keyType);
+ if (p == null) {
+ throw new SDKException("no KemProvider registered for " + keyType
+ + " — add sdk-pqc-bc (or another KemProvider module) to the classpath");
+ }
+ return p;
+ }
+
+ /** Non-throwing variant; returns empty when no provider matches. */
+ public static Optional find(KeyType keyType) {
+ return Optional.ofNullable(providers().get(keyType));
+ }
+
+ /** Set of {@link KeyType}s for which at least one provider is registered. Useful for diagnostics. */
+ public static Set registered() {
+ return providers().keySet();
+ }
+
+ private static Map providers() {
+ Map local = cache;
+ if (local == null) {
+ synchronized (KemProviders.class) {
+ local = cache;
+ if (local == null) {
+ local = load();
+ cache = local;
+ }
+ }
+ }
+ return local;
+ }
+
+ private static Map load() {
+ Map map = new HashMap<>();
+ for (KemProvider provider : ServiceLoader.load(KemProvider.class)) {
+ for (KeyType kt : provider.supportedKeyTypes()) {
+ KemProvider existing = map.putIfAbsent(kt, provider);
+ if (existing != null && existing != provider) {
+ logger.warn("multiple KemProviders claim {}: keeping {}, ignoring {}",
+ kt, existing.getClass().getName(), provider.getClass().getName());
+ }
+ }
+ }
+ return map;
+ }
+}