Files
n_signer/tests/test_n_signer_client.sh

1119 lines
38 KiB
Bash
Executable File

#!/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 <key> <json-string>
# Returns the string value of <key> 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 <test-name> <expected-exit> [<grep-pattern>...]
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":"<hex>"}
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":"<hex>"}
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