#!/bin/bash # # test_n_signer_client.sh — Integration test suite for n_signer_client CLI. # # Spawns a dedicated nsigner server with a known test mnemonic, runs the # full verb surface through build/n_signer_client, and tears down. # # Usage: # make test-n-signer-client # # or directly: # bash tests/test_n_signer_client.sh # # Prerequisites: # - make dev clients (or at least build/nsigner and build/nsigner_client) # - jq (optional, falls back to python3/grep) # set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_DIR="$PROJECT_DIR/build" CLIENT="$BUILD_DIR/nsigner_client" SERVER="$BUILD_DIR/nsigner" # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" SOCKET_NAME="nsigner_test_client_$$" SERVER_PID="" PASS_COUNT=0 FAIL_COUNT=0 SKIP_COUNT=0 # Test event JSON (kind 1, deterministic created_at) EVENT_JSON='{"kind":1,"content":"hello from test","tags":[],"created_at":1700000000}' # A known secp256k1 public key for NIP-04/44 tests (64 hex chars). # We'll derive this from the signer at runtime, but we also need a peer key. # Use a well-known test vector pubkey (any valid 64-hex secp256k1 pubkey). # This is the pubkey for "abandon..." mnemonic, role=main path=m/44'/1237'/0'/0/0, # but we'll discover it dynamically. For peer operations we can use the same # pubkey (encrypt to self). PEER_PUBKEY="" # filled at runtime # --------------------------------------------------------------------------- # Tool detection # --------------------------------------------------------------------------- HAS_JQ=0 HAS_PYTHON=0 if command -v jq &>/dev/null; then HAS_JQ=1 elif command -v python3 &>/dev/null; then HAS_PYTHON=1 fi json_get() { # Usage: json_get # Returns the string value of from the JSON. local key="$1" local json="$2" if [ "$HAS_JQ" -eq 1 ]; then echo "$json" | jq -r ".$key // empty" elif [ "$HAS_PYTHON" -eq 1 ]; then python3 -c "import sys,json; d=json.loads('$json'); print(d.get('$key',''))" else # Fallback: grep for "key":"value" pattern echo "$json" | grep -o "\"$key\":\"[^\"]*\"" | sed "s/\"$key\":\"//;s/\"//" | head -1 fi } json_has_key() { local key="$1" local json="$2" if [ "$HAS_JQ" -eq 1 ]; then echo "$json" | jq -e ". | has(\"$key\")" &>/dev/null elif [ "$HAS_PYTHON" -eq 1 ]; then python3 -c "import sys,json; d=json.loads('$json'); sys.exit(0 if '$key' in d else 1)" else echo "$json" | grep -q "\"$key\"" fi } # --------------------------------------------------------------------------- # Test harness # --------------------------------------------------------------------------- print_result() { local name="$1" local status="$2" local detail="${3:-}" if [ "$status" = "PASS" ]; then echo " PASS $name" elif [ "$status" = "SKIP" ]; then echo " SKIP $name${detail:+: $detail}" else echo " FAIL $name${detail:+: $detail}" fi } pass() { local name="$1" PASS_COUNT=$((PASS_COUNT + 1)) print_result "$name" "PASS" } fail() { local name="$1" local detail="${2:-}" FAIL_COUNT=$((FAIL_COUNT + 1)) print_result "$name" "FAIL" "$detail" } skip() { local name="$1" local reason="${2:-}" SKIP_COUNT=$((SKIP_COUNT + 1)) print_result "$name" "SKIP" "$reason" } # Run a command, check exit code, and optionally grep stdout. # Usage: check_test [...] check_test() { local name="$1" local expected_exit="$2" shift 2 local patterns=("$@") # Build the command from remaining args (everything after patterns) # We need to be careful: the caller passes the command as the last arguments # But we already consumed name and expected_exit. The remaining args are # patterns + command. We need to separate them. # Actually, let's use a different approach: capture patterns and command separately. # We'll use a sentinel approach: patterns end before '--' # But that's awkward. Let's just use a simpler helper. # For now, we'll use a simpler inline approach in each test. : } # --------------------------------------------------------------------------- # Server management # --------------------------------------------------------------------------- start_server() { echo "Starting nsigner server (socket: @$SOCKET_NAME)..." # Build the server if not present if [ ! -x "$SERVER" ]; then echo "Building nsigner server..." (cd "$PROJECT_DIR" && make dev) || { echo "ERROR: failed to build nsigner server" exit 1 } fi # Build the client if not present if [ ! -x "$CLIENT" ]; then echo "Building n_signer_client..." (cd "$PROJECT_DIR" && make clients) || { echo "ERROR: failed to build n_signer_client" exit 1 } fi # Start the server with --mnemonic-stdin and --allow-all # Set test env vars so non-interactive prompts auto-allow # Note: export is needed so the backgrounded server process inherits it export NSIGNER_TEST_NONINTERACTIVE_PROMPT=allow echo "$MNEMONIC" | "$SERVER" \ --socket-name "$SOCKET_NAME" \ --allow-all \ --listen unix \ --mnemonic-stdin & SERVER_PID=$! # Wait for the server to be ready by polling /proc/net/unix local max_attempts=50 local attempt=0 while [ $attempt -lt $max_attempts ]; do if grep -q "$SOCKET_NAME" /proc/net/unix 2>/dev/null; then echo "Server ready (PID $SERVER_PID, socket @$SOCKET_NAME)" return 0 fi sleep 0.1 attempt=$((attempt + 1)) done # Fallback: try connecting with the client if $CLIENT --socket-name "$SOCKET_NAME" --timeout 2000 get-info &>/dev/null; then echo "Server ready (PID $SERVER_PID, socket @$SOCKET_NAME)" return 0 fi echo "ERROR: server did not become ready within ${max_attempts} attempts" kill "$SERVER_PID" 2>/dev/null SERVER_PID="" return 1 } stop_server() { if [ -n "$SERVER_PID" ]; then echo "Stopping server (PID $SERVER_PID)..." kill "$SERVER_PID" 2>/dev/null wait "$SERVER_PID" 2>/dev/null || true SERVER_PID="" fi } # Second server with --register-role (template roles). Uses a separate socket. REGROLE_SOCKET_NAME="" REGROLE_SERVER_PID="" start_server_regrole() { REGROLE_SOCKET_NAME="nsigner_test_regrole_$$" echo "Starting nsigner server with --register-role (socket: @$REGROLE_SOCKET_NAME)..." export NSIGNER_TEST_NONINTERACTIVE_PROMPT=allow echo "$MNEMONIC" | "$SERVER" \ --socket-name "$REGROLE_SOCKET_NAME" \ --allow-all \ --listen unix \ --mnemonic-stdin \ --register-role "nostr_range:secp256k1:m/44'/1237'/*'/0/0" \ --register-role "ssh_range:ed25519:m/44'/102001'/*'/0'/0'" \ --register-role "ml_dsa_range:ml-dsa-65:m/44'/102003'/*'/0'/0'" \ --register-role "slh_dsa_range:slh-dsa-128s:m/44'/102004'/*'/0'/0'" \ >/dev/null 2>&1 & REGROLE_SERVER_PID=$! local max_attempts=50 local attempt=0 while [ $attempt -lt $max_attempts ]; do if grep -q "$REGROLE_SOCKET_NAME" /proc/net/unix 2>/dev/null; then echo "Regrole server ready (PID $REGROLE_SERVER_PID, socket @$REGROLE_SOCKET_NAME)" return 0 fi sleep 0.1 attempt=$((attempt + 1)) done echo "ERROR: regrole server did not become ready" kill "$REGROLE_SERVER_PID" 2>/dev/null REGROLE_SERVER_PID="" return 1 } stop_server_regrole() { if [ -n "$REGROLE_SERVER_PID" ]; then echo "Stopping regrole server (PID $REGROLE_SERVER_PID)..." kill "$REGROLE_SERVER_PID" 2>/dev/null wait "$REGROLE_SERVER_PID" 2>/dev/null || true REGROLE_SERVER_PID="" fi } cleanup() { stop_server stop_server_regrole } # --------------------------------------------------------------------------- # Test functions # --------------------------------------------------------------------------- test_get_info() { echo "" echo "=== Basic connectivity ===" # get-info local name="get-info returns server metadata" local output output=$($CLIENT --socket-name "$SOCKET_NAME" get-info 2>/dev/null) || { fail "$name" "exit code $?" return } # The server returns the result as a JSON string with escaped quotes. # Grep for field names without surrounding quotes to handle both cases. if printf '%s\n' "$output" | grep -q 'name' && \ printf '%s\n' "$output" | grep -q 'verbs' && \ printf '%s\n' "$output" | grep -q 'algorithms'; then pass "$name" else fail "$name" "missing expected fields: $output" fi } test_get_public_key_nostr() { local name="get-public-key (nostr, default role) returns 64 hex chars" local output output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } # Should be 64 hex characters if printf '%s\n' "$output" | grep -qE '^[0-9a-f]{64}$'; then pass "$name" PEER_PUBKEY="$output" else fail "$name" "expected 64 hex chars, got: $output" fi name="get-public-key --role main --path \"m/44'/1237'/0'/0/0\" returns 64 hex chars" output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -qE '^[0-9a-f]{64}$'; then pass "$name" else fail "$name" "expected 64 hex chars, got: $output" fi name="get-public-key --role main --path \"m/44'/1237'/0'/0/0\" --format structured returns JSON" output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" --format structured get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm"' && \ printf '%s\n' "$output" | grep -q '"public_key"'; then pass "$name" else fail "$name" "expected structured JSON, got: $output" fi } test_sign_event() { echo "" echo "=== Sign event ===" local name="sign-event from stdin (pipe)" local output output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" sign-event 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"id"' && \ printf '%s\n' "$output" | grep -q '"pubkey"' && \ printf '%s\n' "$output" | grep -q '"sig"'; then pass "$name" else fail "$name" "expected signed event JSON, got: $output" return fi # Verify pubkey matches get-public-key output local signed_pubkey signed_pubkey=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('pubkey',''))" 2>/dev/null) local expected_pubkey expected_pubkey=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) if [ "$signed_pubkey" = "$expected_pubkey" ]; then pass "sign-event pubkey matches get-public-key" else fail "sign-event pubkey matches get-public-key" "expected $expected_pubkey, got $signed_pubkey" fi # Verify sig is 128 hex chars (schnorr signature) local sig sig=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('sig',''))" 2>/dev/null) if echo "$sig" | grep -qE '^[0-9a-f]{128}$'; then pass "sign-event sig is 128 hex chars" else fail "sign-event sig is 128 hex chars" "got length ${#sig}: $sig" fi # sign-event from argv name="sign-event from argv" output=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" sign-event "$EVENT_JSON" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"id"' && \ printf '%s\n' "$output" | grep -q '"pubkey"' && \ printf '%s\n' "$output" | grep -q '"sig"'; then pass "$name" else fail "$name" "expected signed event JSON, got: $output" fi } test_mine_event() { echo "" echo "=== Mine event ===" local name="mine-event with difficulty 4" local output # Use a short timeout to avoid hanging output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" --difficulty 4 mine-event 2>/dev/null) || { fail "$name" "exit code $?" return } # The mine-event result wraps the signed event in an "event" field as a JSON string. # Check for the wrapper fields and also verify the inner event has id/pubkey/sig. if printf '%s\n' "$output" | grep -q '"event"' && \ printf '%s\n' "$output" | grep -q '"achieved_difficulty"' && \ printf '%s\n' "$output" | grep -q '"target_reached"'; then pass "$name" else fail "$name" "expected mined event JSON with event/achieved_difficulty/target_reached, got: $output" fi } test_nip04_roundtrip() { echo "" echo "=== NIP-04 encrypt/decrypt round-trip ===" local plaintext="hello_nip04_test" local name="nip04-encrypt returns ciphertext" local ciphertext ciphertext=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip04-encrypt "$PEER_PUBKEY" "$plaintext" 2>/dev/null) || { fail "$name" "exit code $?" return } if [ -n "$ciphertext" ]; then pass "$name" else fail "$name" "empty ciphertext" return fi name="nip04-decrypt recovers plaintext" local decrypted decrypted=$(echo "$ciphertext" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip04-decrypt "$PEER_PUBKEY" 2>/dev/null) || { fail "$name" "exit code $?" return } if [ "$decrypted" = "$plaintext" ]; then pass "$name" else fail "$name" "expected '$plaintext', got '$decrypted'" fi } test_nip44_roundtrip() { echo "" echo "=== NIP-44 encrypt/decrypt round-trip ===" local plaintext="hello_nip44_test" local name="nip44-encrypt returns ciphertext" local ciphertext ciphertext=$($CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip44-encrypt "$PEER_PUBKEY" "$plaintext" 2>/dev/null) || { fail "$name" "exit code $?" return } if [ -n "$ciphertext" ]; then pass "$name" else fail "$name" "empty ciphertext" return fi name="nip44-decrypt recovers plaintext" local decrypted decrypted=$(echo "$ciphertext" | $CLIENT --socket-name "$SOCKET_NAME" --role main --path "m/44'/1237'/0'/0/0" nip44-decrypt "$PEER_PUBKEY" 2>/dev/null) || { fail "$name" "exit code $?" return } if [ "$decrypted" = "$plaintext" ]; then pass "$name" else fail "$name" "expected '$plaintext', got '$decrypted'" fi } test_algorithm_verbs() { echo "" echo "=== Algorithm-based verbs ===" local name="get-public-key --algorithm secp256k1 --index 0" local output output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm secp256k1 --index 0 get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm":"secp256k1"' && \ printf '%s\n' "$output" | grep -q '"public_key"'; then pass "$name" else fail "$name" "got: $output" fi name="get-public-key --algorithm ed25519 --index 0" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm":"ed25519"'; then pass "$name" else fail "$name" "got: $output" fi name="sign --algorithm ed25519 --index 0 68656c6c6f" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 sign "68656c6c6f" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"signature"'; then pass "$name" else fail "$name" "got: $output" return fi # Extract the signature for verify test local ed_sig ed_sig=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null) name="verify --algorithm ed25519 --index 0 68656c6c6f (valid)" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "$ed_sig" 2>/dev/null) || { local rc=$? if [ $rc -eq 1 ]; then fail "$name" "signature reported as invalid" else fail "$name" "exit code $rc" fi return } if printf '%s\n' "$output" | grep -q "valid"; then pass "$name" else fail "$name" "expected 'valid', got: $output" fi name="verify --algorithm ed25519 --index 0 68656c6c6f (invalid sig)" local wrong_sig="abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01" set +e output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "$wrong_sig" 2>/dev/null) local rc=$? set -e if [ $rc -eq 1 ] && printf '%s\n' "$output" | grep -q "invalid"; then pass "$name" else fail "$name" "expected exit 1 + 'invalid', got exit $rc: $output" fi name="derive --algorithm secp256k1 --index 0 'test-data'" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm secp256k1 --index 0 derive "test-data" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"digest"'; then local digest digest=$(printf '%s\n' "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('digest',''))" 2>/dev/null) if printf '%s\n' "$digest" | grep -qE '^[0-9a-f]{64}$'; then pass "$name" else fail "$name" "digest not 64 hex chars: $digest" fi else fail "$name" "no digest field: $output" fi name="derive-shared-secret --algorithm x25519 --index 0" # Use the nostr pubkey as the peer (it's a valid secp256k1 point, which x25519 can work with) output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm x25519 --index 0 derive-shared-secret "$PEER_PUBKEY" 2>/dev/null) || { fail "$name" "exit code $?" return } # The server returns structured JSON for derive-shared-secret if printf '%s\n' "$output" | grep -q '"shared_secret"' && \ printf '%s\n' "$output" | grep -q '"algorithm"'; then local ss ss=$(printf '%s\n' "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null) if printf '%s\n' "$ss" | grep -qE '^[0-9a-f]{64}$'; then pass "$name" else fail "$name" "shared_secret not 64 hex chars: $ss" fi else fail "$name" "expected structured JSON with shared_secret, got: $output" fi } test_ml_kem_roundtrip() { echo "" echo "=== ML-KEM-768 encapsulate/decapsulate round-trip ===" local name="get-public-key --algorithm ml-kem-768 --index 0" local output output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 --index 0 get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm":"ml-kem-768"' && \ printf '%s\n' "$output" | grep -q '"public_key"'; then pass "$name" else fail "$name" "got: $output" return fi # Extract the ML-KEM public key local mlkem_pubkey mlkem_pubkey=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('public_key',''))" 2>/dev/null) if [ -z "$mlkem_pubkey" ]; then fail "extract ml-kem-768 pubkey" "empty" return fi name="encapsulate --algorithm ml-kem-768 with peer pubkey" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 encapsulate "$mlkem_pubkey" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"ciphertext"' && \ printf '%s\n' "$output" | grep -q '"shared_secret"'; then pass "$name" else fail "$name" "got: $output" return fi # Extract ciphertext and shared_secret from encapsulate local ct enc_ss ct=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('ciphertext',''))" 2>/dev/null) enc_ss=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null) name="decapsulate --algorithm ml-kem-768 --index 0 with ciphertext" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-kem-768 --index 0 decapsulate "$ct" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"shared_secret"'; then pass "$name" else fail "$name" "got: $output" return fi # Verify shared secrets match local dec_ss dec_ss=$(echo "$output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('shared_secret',''))" 2>/dev/null) if [ "$enc_ss" = "$dec_ss" ]; then pass "ML-KEM-768 encapsulate/decapsulate shared secrets match" else fail "ML-KEM-768 encapsulate/decapsulate shared secrets match" "enc=$enc_ss dec=$dec_ss" fi } # --------------------------------------------------------------------------- # Template-role tests (require the regrole server with --register-role) # --------------------------------------------------------------------------- test_template_role_distinct_pubkeys() { echo "" echo "=== Template-role: distinct pubkeys per account index ===" # This test catches the caching bug where a template role returned the # same pubkey for all concrete paths after the first derivation. local name="nostr_range index 0 returns 64 hex" local pk0 pk0=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$pk0" | grep -qE '^[0-9a-f]{64}$'; then pass "$name" else fail "$name" "got: $pk0" return fi local pk1 pk5 pk1=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" get-public-key 2>/dev/null) pk5=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/5'/0/0" get-public-key 2>/dev/null) if [ "$pk0" != "$pk1" ]; then pass "nostr_range index 0 != index 1 (distinct keys)" else fail "nostr_range index 0 != index 1 (distinct keys)" "both: $pk0" fi if [ "$pk0" != "$pk5" ] && [ "$pk1" != "$pk5" ]; then pass "nostr_range index 5 distinct from 0 and 1" else fail "nostr_range index 5 distinct from 0 and 1" "pk0=$pk0 pk1=$pk1 pk5=$pk5" fi # Re-request index 0 to confirm it's stable (not affected by caching) local pk0_again pk0_again=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) if [ "$pk0" = "$pk0_again" ]; then pass "nostr_range index 0 stable on re-request" else fail "nostr_range index 0 stable on re-request" "first=$pk0 second=$pk0_again" fi } test_template_role_path_rejection() { echo "" echo "=== Template-role: path validation rejection ===" # Hardened tail should be rejected (role template has unhardened 0/0) local name="reject hardened tail (0'/0') with path_not_allowed" local output rc set +e output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0'/0'" get-public-key 2>&1) rc=$? set -e if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'path_not_allowed'; then pass "$name" else fail "$name" "rc=$rc output=$output" fi # Wrong change segment should be rejected name="reject wrong change segment (1) with path_not_allowed" set +e output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/1/0" get-public-key 2>&1) rc=$? set -e if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'path_not_allowed'; then pass "$name" else fail "$name" "rc=$rc output=$output" fi # Unknown role should be rejected name="reject unknown role with unknown_role" set +e output=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role bogus --path "m/44'/1237'/0'/0/0" get-public-key 2>&1) rc=$? set -e if [ $rc -ne 0 ] && printf '%s\n' "$output" | grep -q 'unknown_role'; then pass "$name" else fail "$name" "rc=$rc output=$output" fi } test_template_role_sign_event() { echo "" echo "=== Template-role: sign-event with distinct indices ===" local pk0 signed_pubkey pk0=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" get-public-key 2>/dev/null) local name="sign-event via nostr_range index 0" local output output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/0'/0/0" sign-event 2>/dev/null) || { fail "$name" "exit code $?" return } signed_pubkey=$(json_get "pubkey" "$output") if [ "$signed_pubkey" = "$pk0" ]; then pass "$name pubkey matches get-public-key" else fail "$name pubkey matches get-public-key" "expected $pk0, got $signed_pubkey" fi # Sign with index 1 and confirm different pubkey local pk1 signed_pubkey1 pk1=$($CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" get-public-key 2>/dev/null) output=$(echo "$EVENT_JSON" | $CLIENT --socket-name "$REGROLE_SOCKET_NAME" --timeout 8000 --role nostr_range --path "m/44'/1237'/1'/0/0" sign-event 2>/dev/null) || { fail "sign-event via nostr_range index 1" "exit code $?" return } signed_pubkey1=$(json_get "pubkey" "$output") if [ "$signed_pubkey1" = "$pk1" ] && [ "$signed_pubkey1" != "$signed_pubkey" ]; then pass "sign-event nostr_range index 1 has correct and distinct pubkey" else fail "sign-event nostr_range index 1 has correct and distinct pubkey" "pk1=$pk1 signed=$signed_pubkey1 prev=$signed_pubkey" fi } test_ml_dsa_65_roundtrip() { echo "" echo "=== ML-DSA-65 sign/verify round-trip ===" local name="get-public-key --algorithm ml-dsa-65 --index 0" local output output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm":"ml-dsa-65"' && \ printf '%s\n' "$output" | grep -q '"public_key"'; then pass "$name" else fail "$name" "got: $output" return fi name="sign --algorithm ml-dsa-65 --index 0 68656c6c6f" local sig_raw sig sig_raw=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 sign "68656c6c6f" 2>/dev/null) || { fail "$name" "exit code $?" return } # Sign output is JSON: {"algorithm":"...","key_id":"...","signature":""} sig=$(echo "$sig_raw" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null) if [ -z "$sig" ]; then # Fallback: maybe raw hex sig="$sig_raw" fi # ML-DSA-65 signatures are 3309 bytes = 6618 hex chars if printf '%s\n' "$sig" | grep -qE '^[0-9a-f]{6618}$'; then pass "$name (sig is 6618 hex chars)" else fail "$name (sig is 6618 hex chars)" "got length ${#sig}" return fi name="verify --algorithm ml-dsa-65 --index 0 68656c6c6f (valid)" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 verify "68656c6c6f" "$sig" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -qi 'valid\|true\|verified'; then pass "$name" else fail "$name" "got: $output" fi name="verify --algorithm ml-dsa-65 --index 0 68656c6c6f (invalid sig)" local wrong_sig wrong_sig=$(printf '%06618s' 0 | tr ' ' '0') set +e output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm ml-dsa-65 --index 0 verify "68656c6c6f" "$wrong_sig" 2>/dev/null) local rc=$? set -e if [ $rc -ne 0 ] || printf '%s\n' "$output" | grep -qi 'invalid\|false\|not'; then pass "$name" else fail "$name" "rc=$rc output=$output" fi } test_slh_dsa_128s_roundtrip() { echo "" echo "=== SLH-DSA-128s sign/verify round-trip ===" local name="get-public-key --algorithm slh-dsa-128s --index 0" local output output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 get-public-key 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q '"algorithm":"slh-dsa-128s"' && \ printf '%s\n' "$output" | grep -q '"public_key"'; then pass "$name" else fail "$name" "got: $output" return fi name="sign --algorithm slh-dsa-128s --index 0 68656c6c6f" local sig_raw sig sig_raw=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 sign "68656c6c6f" 2>/dev/null) || { fail "$name" "exit code $?" return } # Sign output is JSON: {"algorithm":"...","key_id":"...","signature":""} sig=$(echo "$sig_raw" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('signature',''))" 2>/dev/null) if [ -z "$sig" ]; then sig="$sig_raw" fi # SLH-DSA-128s signatures are 7856 bytes = 15712 hex chars if printf '%s\n' "$sig" | grep -qE '^[0-9a-f]{15712}$'; then pass "$name (sig is 15712 hex chars)" else fail "$name (sig is 15712 hex chars)" "got length ${#sig}" return fi name="verify --algorithm slh-dsa-128s --index 0 68656c6c6f (valid)" output=$($CLIENT --socket-name "$SOCKET_NAME" --algorithm slh-dsa-128s --index 0 verify "68656c6c6f" "$sig" 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -qi 'valid\|true\|verified'; then pass "$name" else fail "$name" "got: $output" fi } test_otp_encrypt_decrypt() { echo "" echo "=== OTP encrypt/decrypt ===" local name="encrypt --algorithm otp (base64 plaintext)" local plaintext_b64="SGVsbG8gT1RQIQ==" # "Hello OTP!" in base64 local rc=0 local stdout_file local stderr_file stdout_file=$(mktemp /tmp/otp_stdout_XXXXXX) stderr_file=$(mktemp /tmp/otp_stderr_XXXXXX) # Run the command, capturing stdout and stderr separately set +e "$CLIENT" --socket-name "$SOCKET_NAME" --algorithm otp encrypt "$plaintext_b64" >"$stdout_file" 2>"$stderr_file" rc=$? set -e if [ $rc -ne 0 ]; then local stderr_text stderr_text=$(cat "$stderr_file") rm -f "$stdout_file" "$stderr_file" # Check if the error is about missing OTP pad if printf '%s\n' "$stderr_text" | grep -qi "otp_pad\|pad_not_bound\|no pad\|not available\|not supported\|not configured"; then skip "$name" "OTP pad not available on server" return fi fail "$name" "exit code $rc stderr: $stderr_text" return fi local ciphertext ciphertext=$(cat "$stdout_file") rm -f "$stdout_file" "$stderr_file" if [ -n "$ciphertext" ]; then pass "$name" else fail "$name" "empty output" return fi name="decrypt --algorithm otp" stdout_file=$(mktemp /tmp/otp_stdout_XXXXXX) stderr_file=$(mktemp /tmp/otp_stderr_XXXXXX) set +e "$CLIENT" --socket-name "$SOCKET_NAME" --algorithm otp decrypt "$ciphertext" >"$stdout_file" 2>"$stderr_file" rc=$? set -e local decrypted decrypted=$(cat "$stdout_file") rm -f "$stdout_file" "$stderr_file" if [ $rc -eq 0 ] && [ -n "$decrypted" ]; then pass "$name" else fail "$name" "exit code $rc output: $decrypted" fi } test_call_verb() { echo "" echo "=== Generic call verb ===" local name="call get_info via stdin" local output output=$(echo '[]' | $CLIENT --socket-name "$SOCKET_NAME" call get_info 2>/dev/null) || { fail "$name" "exit code $?" return } if printf '%s\n' "$output" | grep -q 'name' && \ printf '%s\n' "$output" | grep -q 'verbs'; then pass "$name" else fail "$name" "got: $output" fi } test_error_cases() { echo "" echo "=== Error cases ===" local name="No socket found (bogus socket name)" local rc=0 local stderr_file stderr_file=$(mktemp /tmp/err_stderr_XXXXXX) set +e $CLIENT --socket-name "nonexistent_socket_$$" --timeout 1000 get-info 2>"$stderr_file" >/dev/null rc=$? set -e local stderr_text stderr_text=$(cat "$stderr_file") rm -f "$stderr_file" if [ $rc -ne 0 ] && [ -n "$stderr_text" ]; then pass "$name" else fail "$name" "expected non-zero exit + stderr, got exit $rc stderr: $stderr_text" fi name="--index 5 without --algorithm" stderr_file=$(mktemp /tmp/err_stderr_XXXXXX) set +e $CLIENT --socket-name "$SOCKET_NAME" --index 5 get-public-key 2>"$stderr_file" >/dev/null rc=$? set -e stderr_text=$(cat "$stderr_file") rm -f "$stderr_file" if [ $rc -ne 0 ] && printf '%s\n' "$stderr_text" | grep -qi "index.*only valid\|--index"; then pass "$name" else fail "$name" "expected error about --index, got exit $rc: $stderr_text" fi name="Unknown verb" stderr_file=$(mktemp /tmp/err_stderr_XXXXXX) set +e $CLIENT --socket-name "$SOCKET_NAME" nonexistent-verb 2>"$stderr_file" >/dev/null rc=$? set -e stderr_text=$(cat "$stderr_file") rm -f "$stderr_file" if [ $rc -ne 0 ] && printf '%s\n' "$stderr_text" | grep -qi "unknown verb"; then pass "$name" else fail "$name" "expected unknown verb error, got exit $rc: $stderr_text" fi name="verify with malformed signature (exit 2)" stderr_file=$(mktemp /tmp/err_stderr_XXXXXX) set +e $CLIENT --socket-name "$SOCKET_NAME" --algorithm ed25519 --index 0 verify "68656c6c6f" "nothex" 2>"$stderr_file" rc=$? set -e stderr_text=$(cat "$stderr_file") rm -f "$stderr_file" # Should be exit 2 (error), not exit 1 (invalid) if [ $rc -eq 2 ]; then pass "$name" else fail "$name" "expected exit 2 (error), got exit $rc: $stderr_text" fi } test_auto_discovery() { echo "" echo "=== Auto-discovery ===" local name="Auto-discover socket (only test signer running)" # This is best-effort: if only our test signer is running, it should work. # If other signers are running, skip. local rc=0 local stdout_file local stderr_file stdout_file=$(mktemp /tmp/auto_stdout_XXXXXX) stderr_file=$(mktemp /tmp/auto_stderr_XXXXXX) set +e $CLIENT get-info >"$stdout_file" 2>"$stderr_file" rc=$? set -e local output output=$(cat "$stdout_file") local stderr_text stderr_text=$(cat "$stderr_file") rm -f "$stdout_file" "$stderr_file" if [ $rc -eq 0 ]; then if printf '%s\n' "$output" | grep -q 'name'; then pass "$name" else fail "$name" "got output but missing 'name' field: $output" fi else if printf '%s\n' "$stderr_text" | grep -qi "multiple"; then skip "$name" "multiple signer sockets found" else skip "$name" "auto-discovery failed: $stderr_text" fi fi } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- trap cleanup EXIT INT TERM echo "============================================" echo " n_signer_client Integration Test Suite" echo "============================================" echo "" # Start the server start_server || { echo "FATAL: could not start nsigner server" exit 1 } # Start a second server with --register-role (template roles) start_server_regrole || { echo "FATAL: could not start nsigner regrole server" exit 1 } # Run tests test_get_info test_get_public_key_nostr test_sign_event test_mine_event test_nip04_roundtrip test_nip44_roundtrip test_algorithm_verbs test_ml_kem_roundtrip test_ml_dsa_65_roundtrip test_slh_dsa_128s_roundtrip test_otp_encrypt_decrypt test_call_verb test_error_cases test_auto_discovery # Template-role tests (require regrole server) test_template_role_distinct_pubkeys test_template_role_path_rejection test_template_role_sign_event # Summary echo "" echo "============================================" echo " Results" echo "============================================" echo " PASS: $PASS_COUNT" echo " FAIL: $FAIL_COUNT" echo " SKIP: $SKIP_COUNT" echo " TOTAL: $((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))" echo "============================================" if [ "$FAIL_COUNT" -gt 0 ]; then exit 1 fi exit 0