|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# test-hybrid-pqc.sh — round-trip the Java SDK's hybrid post-quantum key |
| 4 | +# wrapping against a locally running OpenTDF platform. |
| 5 | +# |
| 6 | +# Per algorithm: encrypt → assert manifest → KAS rewrap → decrypt → diff. |
| 7 | +# |
| 8 | +# Prereqs: |
| 9 | +# * Local platform up at $PLATFORM_ENDPOINT with hybrid KAS keys registered |
| 10 | +# for hpqt:xwing, hpqt:secp256r1-mlkem768, hpqt:secp384r1-mlkem1024 |
| 11 | +# * java, mvn (JDK 17), unzip, jq on PATH |
| 12 | +# * grpcurl optional (used only for the pre-flight key-publication check) |
| 13 | +# |
| 14 | +# Usage: |
| 15 | +# scripts/test-hybrid-pqc.sh # full run, all 3 algorithms |
| 16 | +# scripts/test-hybrid-pqc.sh --skip-build # reuse existing jar |
| 17 | +# scripts/test-hybrid-pqc.sh --skip-kas-check # skip grpcurl pre-flight |
| 18 | +# scripts/test-hybrid-pqc.sh --algorithms HybridXWingKey # subset |
| 19 | +# PLATFORM_ENDPOINT=http://localhost:8080 scripts/test-hybrid-pqc.sh |
| 20 | +# |
| 21 | +# See scripts/README.md for a full prereq + troubleshooting guide. |
| 22 | + |
| 23 | +set -euo pipefail |
| 24 | + |
| 25 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 26 | +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" |
| 27 | +JAR="$REPO_ROOT/cmdline/target/cmdline.jar" |
| 28 | + |
| 29 | +PLATFORM_ENDPOINT="${PLATFORM_ENDPOINT:-http://localhost:8080}" |
| 30 | +KAS_URL="${KAS_URL:-$PLATFORM_ENDPOINT}" |
| 31 | +CLIENT_ID="${CLIENT_ID:-opentdf-sdk}" |
| 32 | +CLIENT_SECRET="${CLIENT_SECRET:-secret}" |
| 33 | +DATA_ATTR="${DATA_ATTR:-https://example.com/attr/attr1/value/value1}" |
| 34 | +ALGORITHMS=(HybridXWingKey HybridSecp256r1MLKEM768Key HybridSecp384r1MLKEM1024Key) |
| 35 | +SKIP_BUILD=0 |
| 36 | +SKIP_KAS_CHECK=0 |
| 37 | + |
| 38 | +while [[ $# -gt 0 ]]; do |
| 39 | + case "$1" in |
| 40 | + --skip-build) SKIP_BUILD=1; shift ;; |
| 41 | + --skip-kas-check) SKIP_KAS_CHECK=1; shift ;; |
| 42 | + --algorithms) IFS=, read -r -a ALGORITHMS <<< "$2"; shift 2 ;; |
| 43 | + --platform-endpoint) PLATFORM_ENDPOINT="$2"; shift 2 ;; |
| 44 | + --kas-url) KAS_URL="$2"; shift 2 ;; |
| 45 | + --attr) DATA_ATTR="$2"; shift 2 ;; |
| 46 | + --client-id) CLIENT_ID="$2"; shift 2 ;; |
| 47 | + --client-secret) CLIENT_SECRET="$2"; shift 2 ;; |
| 48 | + -h|--help) sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| 49 | + *) echo "unknown option: $1" >&2; exit 2 ;; |
| 50 | + esac |
| 51 | +done |
| 52 | + |
| 53 | +# Map KeyType enum name → the hpqt:* algorithm string the KAS expects. |
| 54 | +# Function form (instead of `declare -A`) so this works on macOS bash 3.2. |
| 55 | +alg_to_string() { |
| 56 | + case "$1" in |
| 57 | + HybridXWingKey) echo "hpqt:xwing" ;; |
| 58 | + HybridSecp256r1MLKEM768Key) echo "hpqt:secp256r1-mlkem768" ;; |
| 59 | + HybridSecp384r1MLKEM1024Key) echo "hpqt:secp384r1-mlkem1024" ;; |
| 60 | + *) return 1 ;; |
| 61 | + esac |
| 62 | +} |
| 63 | + |
| 64 | +WORK_DIR="$(mktemp -d -t hybrid-pqc-XXXXXX)" |
| 65 | +trap 'rm -rf "$WORK_DIR"' EXIT |
| 66 | + |
| 67 | +if [[ -t 1 ]]; then |
| 68 | + GREEN=$'\033[0;32m'; RED=$'\033[0;31m'; YELLOW=$'\033[0;33m'; RESET=$'\033[0m' |
| 69 | +else |
| 70 | + GREEN=''; RED=''; YELLOW=''; RESET='' |
| 71 | +fi |
| 72 | +pass() { echo "${GREEN}[OK]${RESET} $*"; } |
| 73 | +fail() { echo "${RED}[FAIL]${RESET} $*"; } |
| 74 | +info() { echo "${YELLOW}[..]${RESET} $*"; } |
| 75 | + |
| 76 | +require() { command -v "$1" >/dev/null 2>&1 || { fail "missing required tool: $1"; exit 2; }; } |
| 77 | +require java; require unzip; require jq |
| 78 | +[[ $SKIP_BUILD -eq 1 ]] || require mvn |
| 79 | + |
| 80 | +run_cmdline() { |
| 81 | + java -jar "$JAR" \ |
| 82 | + --client-id="$CLIENT_ID" \ |
| 83 | + --client-secret="$CLIENT_SECRET" \ |
| 84 | + --platform-endpoint="$PLATFORM_ENDPOINT" \ |
| 85 | + -h "$@" |
| 86 | +} |
| 87 | + |
| 88 | +##### 1. Build |
| 89 | +if [[ $SKIP_BUILD -eq 0 ]]; then |
| 90 | + info "Building cmdline (mvn clean install -DskipTests)" |
| 91 | + build_log="$WORK_DIR/build.log" |
| 92 | + if ! (cd "$REPO_ROOT" && mvn --batch-mode clean install -DskipTests) > "$build_log" 2>&1; then |
| 93 | + fail "Maven build failed. Tail of build log:" |
| 94 | + tail -40 "$build_log" | sed 's/^/ /' |
| 95 | + if grep -q "Buf API token" "$build_log" 2>/dev/null; then |
| 96 | + fail "Hint: run 'buf registry login' or export BUF_INPUT_HTTPS_USERNAME / BUF_INPUT_HTTPS_PASSWORD before retrying." |
| 97 | + fi |
| 98 | + exit 1 |
| 99 | + fi |
| 100 | + pass "Build complete" |
| 101 | +else |
| 102 | + info "Skipping build (--skip-build)" |
| 103 | +fi |
| 104 | +[[ -f "$JAR" ]] || { fail "jar not found at $JAR — run without --skip-build"; exit 1; } |
| 105 | + |
| 106 | +##### 2. Pre-flight: confirm KAS publishes hybrid keys |
| 107 | +if [[ $SKIP_KAS_CHECK -eq 0 ]] && command -v grpcurl >/dev/null 2>&1; then |
| 108 | + info "Pre-flight: querying KAS for hybrid public keys" |
| 109 | + host="${PLATFORM_ENDPOINT#http://}"; host="${host#https://}" |
| 110 | + for alg_name in "${ALGORITHMS[@]}"; do |
| 111 | + if ! alg=$(alg_to_string "$alg_name"); then |
| 112 | + fail "unknown algorithm: $alg_name"; exit 2 |
| 113 | + fi |
| 114 | + resp=$(grpcurl -plaintext -d "{\"algorithm\":\"$alg\"}" \ |
| 115 | + "$host" kas.AccessService/PublicKey 2>&1 || true) |
| 116 | + pem=$(jq -r '.publicKey // empty' <<<"$resp" 2>/dev/null || true) |
| 117 | + if [[ -z "$pem" ]]; then |
| 118 | + fail "$alg: KAS returned no publicKey. Response was:" |
| 119 | + echo "$resp" | head -5 | sed 's/^/ /' |
| 120 | + fail "Is the platform running with the PQC-capable KAS branch and the key registered?" |
| 121 | + exit 1 |
| 122 | + fi |
| 123 | + # Hybrid PEMs have XWING or MLKEM markers; RSA/EC PEMs don't. |
| 124 | + first_line=$(echo "$pem" | head -1) |
| 125 | + if [[ "$first_line" != *"XWING"* && "$first_line" != *"MLKEM"* && "$first_line" != *"HPQT"* && "$first_line" != *"HYBRID"* ]]; then |
| 126 | + fail "$alg: KAS returned a non-hybrid PEM (first line: $first_line)" |
| 127 | + fail "The KAS doesn't appear to have a hybrid key registered for $alg" |
| 128 | + exit 1 |
| 129 | + fi |
| 130 | + pass "$alg: KAS returns hybrid PEM ($first_line)" |
| 131 | + done |
| 132 | +else |
| 133 | + info "Skipping KAS pre-flight check" |
| 134 | +fi |
| 135 | + |
| 136 | +##### 3. Round-trip each algorithm |
| 137 | +PAYLOAD="$WORK_DIR/payload" |
| 138 | +printf 'hybrid pqc round-trip payload @ %s\n' "$(date)" > "$PAYLOAD" |
| 139 | +PAYLOAD_BYTES=$(wc -c < "$PAYLOAD" | tr -d ' ') |
| 140 | +info "Test payload: $PAYLOAD_BYTES bytes" |
| 141 | +echo " --- plaintext ---" |
| 142 | +sed 's/^/ /' < "$PAYLOAD" |
| 143 | +echo " --- end plaintext ---" |
| 144 | + |
| 145 | +failures=() |
| 146 | +for alg_name in "${ALGORITHMS[@]}"; do |
| 147 | + tdf="$WORK_DIR/test-${alg_name}.tdf" |
| 148 | + out="$WORK_DIR/out-${alg_name}" |
| 149 | + enc_log="$WORK_DIR/encrypt-${alg_name}.log" |
| 150 | + dec_log="$WORK_DIR/decrypt-${alg_name}.log" |
| 151 | + |
| 152 | + info "[$alg_name] encrypt" |
| 153 | + if ! run_cmdline encrypt \ |
| 154 | + --kas-url="$KAS_URL" \ |
| 155 | + --mime-type=text/plain \ |
| 156 | + --attr="$DATA_ATTR" \ |
| 157 | + --autoconfigure=false \ |
| 158 | + --encap-key-type="$alg_name" \ |
| 159 | + -f "$PAYLOAD" > "$tdf" 2> "$enc_log"; then |
| 160 | + fail "$alg_name: encrypt failed" |
| 161 | + sed 's/^/ /' < "$enc_log" |
| 162 | + failures+=("$alg_name (encrypt)") |
| 163 | + continue |
| 164 | + fi |
| 165 | + |
| 166 | + info "[$alg_name] verify manifest" |
| 167 | + manifest_entry=$(unzip -l "$tdf" 2>/dev/null | awk '/manifest\.json$/ {print $NF; exit}') |
| 168 | + if [[ -z "$manifest_entry" ]]; then |
| 169 | + fail "$alg_name: no manifest.json entry inside $tdf" |
| 170 | + failures+=("$alg_name (manifest entry missing)") |
| 171 | + continue |
| 172 | + fi |
| 173 | + manifest=$(unzip -p "$tdf" "$manifest_entry") |
| 174 | + # In Manifest.java, the Java field `keyType` is annotated with |
| 175 | + # @SerializedName("type"), so the JSON key is "type" (not "keyType"). |
| 176 | + keyType=$(jq -r '.encryptionInformation.keyAccess[0].type' <<<"$manifest") |
| 177 | + ephem=$(jq -r '.encryptionInformation.keyAccess[0].ephemeralPublicKey // ""' <<<"$manifest") |
| 178 | + wrapped=$(jq -r '.encryptionInformation.keyAccess[0].wrappedKey // ""' <<<"$manifest") |
| 179 | + if [[ "$keyType" != "hybrid-wrapped" ]]; then |
| 180 | + fail "$alg_name: type='$keyType' (expected 'hybrid-wrapped')" |
| 181 | + echo " keyAccess[0]:" |
| 182 | + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" 2>/dev/null | sed 's/^/ /' |
| 183 | + failures+=("$alg_name (bad type: $keyType)") |
| 184 | + continue |
| 185 | + fi |
| 186 | + if [[ -n "$ephem" ]]; then |
| 187 | + fail "$alg_name: ephemeralPublicKey unexpectedly set ('$ephem')" |
| 188 | + failures+=("$alg_name (stray ephemeralPublicKey)") |
| 189 | + continue |
| 190 | + fi |
| 191 | + if [[ -z "$wrapped" ]]; then |
| 192 | + fail "$alg_name: wrappedKey is empty" |
| 193 | + failures+=("$alg_name (empty wrappedKey)") |
| 194 | + continue |
| 195 | + fi |
| 196 | + # ASN.1 SEQUENCE always starts with 0x30 — same invariant HybridCryptoTest checks. |
| 197 | + first_byte=$(base64 -d <<<"$wrapped" 2>/dev/null | xxd -p -l 1 || true) |
| 198 | + if [[ "$first_byte" != "30" ]]; then |
| 199 | + fail "$alg_name: wrappedKey does not start with ASN.1 SEQUENCE (got 0x$first_byte)" |
| 200 | + failures+=("$alg_name (bad envelope)") |
| 201 | + continue |
| 202 | + fi |
| 203 | + pass "$alg_name: manifest OK (hybrid-wrapped, ASN.1 envelope, no ephemeralPublicKey)" |
| 204 | + echo " --- keyAccess[0] (KAO) ---" |
| 205 | + jq '.encryptionInformation.keyAccess[0]' <<<"$manifest" | sed 's/^/ /' |
| 206 | + echo " --- end keyAccess[0] ---" |
| 207 | + |
| 208 | + info "[$alg_name] decrypt (rewrap via KAS)" |
| 209 | + if ! run_cmdline decrypt -f "$tdf" > "$out" 2> "$dec_log"; then |
| 210 | + fail "$alg_name: decrypt failed" |
| 211 | + sed 's/^/ /' < "$dec_log" |
| 212 | + failures+=("$alg_name (decrypt)") |
| 213 | + continue |
| 214 | + fi |
| 215 | + if ! diff -q "$PAYLOAD" "$out" >/dev/null; then |
| 216 | + fail "$alg_name: decrypted payload differs from original" |
| 217 | + echo " --- expected (first 200 bytes) ---" |
| 218 | + head -c 200 "$PAYLOAD" | sed 's/^/ /' |
| 219 | + echo |
| 220 | + echo " --- got (first 200 bytes) ---" |
| 221 | + head -c 200 "$out" | sed 's/^/ /' |
| 222 | + echo |
| 223 | + failures+=("$alg_name (payload mismatch)") |
| 224 | + continue |
| 225 | + fi |
| 226 | + pass "$alg_name: round-trip OK" |
| 227 | + out_bytes=$(wc -c < "$out" | tr -d ' ') |
| 228 | + echo " --- decrypted ($out_bytes bytes) ---" |
| 229 | + sed 's/^/ /' < "$out" |
| 230 | + echo " --- end decrypted ---" |
| 231 | +done |
| 232 | + |
| 233 | +echo |
| 234 | +if [[ ${#failures[@]} -eq 0 ]]; then |
| 235 | + echo "${GREEN}All ${#ALGORITHMS[@]} hybrid algorithm(s) passed round-trip.${RESET}" |
| 236 | + exit 0 |
| 237 | +else |
| 238 | + echo "${RED}FAILURES (${#failures[@]}):${RESET}" |
| 239 | + printf ' - %s\n' "${failures[@]}" |
| 240 | + exit 1 |
| 241 | +fi |
0 commit comments