This commit is contained in:
Maxime Van Hees
2025-08-14 14:14:34 +02:00
parent 04a1af2423
commit 0ebda7c1aa
59 changed files with 6950 additions and 354 deletions

109
tools/gen_auth.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Generate secp256k1 keypair and sign a nonce in the exact format the server expects.
Install dependencies once:
python3 -m pip install -r tools/requirements.txt
Usage examples:
# Generate a new keypair and sign a nonce (prints PRIVATE_HEX, PUBLIC_HEX, SIGNATURE_HEX)
python tools/gen_auth.py --nonce "PASTE_NONCE_FROM_fetch_nonce"
# Sign with an existing private key (64 hex chars)
python tools/gen_auth.py --nonce "PASTE_NONCE" --priv "YOUR_PRIVATE_KEY_HEX"
# Output JSON instead of key=value lines
python tools/gen_auth.py --nonce "PASTE_NONCE" --json
Notes:
- Public key is compressed (33 bytes) hex, starting with 02/03 (66 hex chars total).
- Signature is compact ECDSA (r||s) 64 bytes (128 hex chars).
- The nonce should be the exact ASCII string returned by fetch_nonce().
- The message signed is sha256(nonce_ascii) to match client/server behavior:
- [rust.AuthHelper::sign_message()](interfaces/openrpc/client/src/auth.rs:55)
- [rust.AuthManager::verify_signature()](interfaces/openrpc/server/src/auth.rs:85)
"""
import argparse
import hashlib
import json
import sys
from typing import Dict, Tuple, Optional
try:
from ecdsa import SigningKey, VerifyingKey, SECP256k1, util
except Exception as e:
print("Missing dependency 'ecdsa'. Install with:", file=sys.stderr)
print(" python3 -m pip install -r tools/requirements.txt", file=sys.stderr)
raise
def sha256_ascii(s: str) -> bytes:
return hashlib.sha256(s.encode()).digest()
def to_compact_signature_hex(sk: SigningKey, nonce_ascii: str) -> str:
digest = sha256_ascii(nonce_ascii)
sig = sk.sign_digest(digest, sigencode=util.sigencode_string) # 64 bytes r||s
return sig.hex()
def compressed_pubkey_hex(vk: VerifyingKey) -> str:
# Prefer compressed output if library supports it directly (ecdsa>=0.18)
try:
return vk.to_string("compressed").hex()
except TypeError:
# Manual compression (02/03 + X)
p = vk.pubkey.point
x = p.x()
y = p.y()
prefix = b"\x02" if (y % 2 == 0) else b"\x03"
return (prefix + x.to_bytes(32, "big")).hex()
def generate_or_load_sk(priv_hex: Optional[str]) -> Tuple[SigningKey, bool]:
if priv_hex:
if len(priv_hex) != 64:
raise ValueError("Provided --priv must be 64 hex chars (32 bytes).")
return SigningKey.from_string(bytes.fromhex(priv_hex), curve=SECP256k1), False
return SigningKey.generate(curve=SECP256k1), True
def run(nonce: str, priv_hex: Optional[str], as_json: bool) -> int:
sk, generated = generate_or_load_sk(priv_hex)
vk = sk.get_verifying_key()
out: Dict[str, str] = {
"PUBLIC_HEX": compressed_pubkey_hex(vk),
"NONCE": nonce,
"SIGNATURE_HEX": to_compact_signature_hex(sk, nonce),
}
# Always print the private key for convenience (either generated or provided)
out["PRIVATE_HEX"] = sk.to_string().hex()
if as_json:
print(json.dumps(out, separators=(",", ":")))
else:
# key=value form for easy copy/paste
print(f"PRIVATE_HEX={out['PRIVATE_HEX']}")
print(f"PUBLIC_HEX={out['PUBLIC_HEX']}")
print(f"NONCE={out['NONCE']}")
print(f"SIGNATURE_HEX={out['SIGNATURE_HEX']}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Generate secp256k1 auth material and signature for a nonce.")
parser.add_argument("--nonce", required=True, help="Nonce string returned by fetch_nonce (paste as-is)")
parser.add_argument("--priv", help="Existing private key hex (64 hex chars). If omitted, a new keypair is generated.")
parser.add_argument("--json", action="store_true", help="Output JSON instead of key=value lines.")
args = parser.parse_args()
try:
return run(args.nonce, args.priv, args.json)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

124
tools/gen_auth.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
gen_auth.sh --nonce "<nonce_string>" [--priv <private_key_hex>] [--json]
Options:
--nonce The nonce string returned by fetch_nonce (paste as-is).
--priv Optional private key hex (64 hex chars). If omitted, a new key is generated.
--json Output JSON instead of plain KEY=VALUE lines.
Outputs:
PRIVATE_HEX Private key hex (only when generated, or echoed back if provided)
PUBLIC_HEX Compressed secp256k1 public key hex (33 bytes, 66 hex chars)
NONCE The nonce string you passed in
SIGNATURE_HEX Compact ECDSA signature hex (64 bytes, 128 hex chars)
Notes:
- The signature is produced by signing sha256(nonce_ascii) and encoded as compact r||s (64 bytes),
which matches the server/client behavior ([interfaces/openrpc/client/src/auth.rs](interfaces/openrpc/client/src/auth.rs:55), [interfaces/openrpc/server/src/auth.rs](interfaces/openrpc/server/src/auth.rs:85)).
USAGE
}
NONCE=""
PRIV_HEX=""
OUT_JSON=0
while [[ $# -gt 0 ]]; do
case "$1" in
--nonce)
NONCE="${2:-}"; shift 2 ;;
--priv)
PRIV_HEX="${2:-}"; shift 2 ;;
--json)
OUT_JSON=1; shift ;;
-h|--help)
usage; exit 0 ;;
*)
echo "Unknown arg: $1" >&2; usage; exit 1 ;;
esac
done
if [[ -z "$NONCE" ]]; then
echo "Error: --nonce is required" >&2
usage
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 not found. Install Python 3 (e.g., sudo pacman -S python) and retry." >&2
exit 1
fi
# Ensure 'ecdsa' module is available; install to user site if missing.
if ! python3 - <<'PY' >/dev/null 2>&1
import importlib; importlib.import_module("ecdsa")
PY
then
echo "Installing Python 'ecdsa' package in user site..." >&2
if ! python3 -m pip install --user --quiet ecdsa; then
echo "Error: failed to install 'ecdsa'. Install manually: python3 -m pip install --user ecdsa" >&2
exit 1
fi
fi
# Now run Python to generate/derive keys and sign the nonce (ASCII) with compact ECDSA.
python3 - "$NONCE" "$PRIV_HEX" "$OUT_JSON" <<'PY'
import sys, json, hashlib
from ecdsa import SigningKey, VerifyingKey, SECP256k1, util
NONCE = sys.argv[1]
PRIV_HEX = sys.argv[2]
OUT_JSON = int(sys.argv[3]) == 1
def to_compact_signature(sk: SigningKey, msg_ascii: str) -> bytes:
digest = hashlib.sha256(msg_ascii.encode()).digest()
return sk.sign_digest(digest, sigencode=util.sigencode_string) # 64 bytes r||s
def compressed_pubkey(vk: VerifyingKey) -> bytes:
try:
return vk.to_string("compressed")
except TypeError:
p = vk.pubkey.point
x = p.x()
y = vk.pubkey.point.y()
prefix = b'\x02' if (y % 2 == 0) else b'\x03'
return prefix + x.to_bytes(32, "big")
generated = False
if PRIV_HEX:
if len(PRIV_HEX) != 64:
print("ERROR: Provided --priv must be 64 hex chars", file=sys.stderr)
sys.exit(1)
sk = SigningKey.from_string(bytes.fromhex(PRIV_HEX), curve=SECP256k1)
else:
sk = SigningKey.generate(curve=SECP256k1)
generated = True
vk = sk.get_verifying_key()
pub_hex = compressed_pubkey(vk).hex()
sig_hex = to_compact_signature(sk, NONCE).hex()
priv_hex = sk.to_string().hex()
out = {
"PUBLIC_HEX": pub_hex,
"NONCE": NONCE,
"SIGNATURE_HEX": sig_hex,
}
if generated or PRIV_HEX:
out["PRIVATE_HEX"] = priv_hex
if OUT_JSON:
print(json.dumps(out, separators=(",", ":")))
else:
if "PRIVATE_HEX" in out:
print(f"PRIVATE_HEX={out['PRIVATE_HEX']}")
print(f"PUBLIC_HEX={out['PUBLIC_HEX']}")
print(f"NONCE={out['NONCE']}")
print(f"SIGNATURE_HEX={out['SIGNATURE_HEX']}")
PY
# End

2
tools/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
ecdsa==0.18.0
requests==2.32.3

204
tools/rpc_smoke_test.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Non-destructive JSON-RPC smoke tests against the OpenRPC server.
Installs:
python3 -m pip install -r tools/requirements.txt
Usage:
# Default URL http://127.0.0.1:9944
python tools/rpc_smoke_test.py
# Specify a different URL
python tools/rpc_smoke_test.py --url http://127.0.0.1:9944
# Provide a specific pubkey for fetch_nonce (compressed 33-byte hex)
python tools/rpc_smoke_test.py --pubkey 02deadbeef...
# Lookup details for first N jobs returned by list_jobs
python tools/rpc_smoke_test.py --limit 5
What it tests (non-destructive):
- fetch_nonce(pubkey) -> returns a nonce string from the server auth manager
- whoami() -> returns a JSON string with basic server info
- list_jobs() -> returns job IDs only (no mutation)
- get_job_status(id) -> reads status (for up to --limit items)
- get_job_output(id) -> reads output (for up to --limit items)
- get_job_logs(id) -> reads logs (for up to --limit items)
Notes:
- If you don't pass --pubkey, this script will generate a random secp256k1 keypair
and derive a compressed public key (no persistence, just for testing fetch_nonce).
"""
import argparse
import json
import os
import random
import sys
import time
from typing import Any, Dict, List, Optional
try:
import requests
except Exception:
print("Missing dependency 'requests'. Install with:\n python3 -m pip install -r tools/requirements.txt", file=sys.stderr)
raise
try:
from ecdsa import SigningKey, SECP256k1
except Exception:
# ecdsa is optional here; only used to generate a test pubkey if --pubkey is absent
SigningKey = None # type: ignore
def ensure_http_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
# Accept ws:// scheme too; convert to http for JSON-RPC over HTTP
if url.startswith("ws://"):
return "http://" + url[len("ws://") :]
if url.startswith("wss://"):
return "https://" + url[len("wss://") :]
# Default to http if no scheme
return "http://" + url
class JsonRpcClient:
def __init__(self, url: str):
self.url = ensure_http_url(url)
self._id = int(time.time() * 1000)
def call(self, method: str, params: Any) -> Any:
self._id += 1
payload = {
"jsonrpc": "2.0",
"id": self._id,
"method": method,
"params": params,
}
resp = requests.post(self.url, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data and data["error"] is not None:
raise RuntimeError(f"RPC error for {method}: {data['error']}")
return data.get("result")
def random_compressed_pubkey_hex() -> str:
"""
Generate a random secp256k1 keypair and return compressed public key hex.
Requires 'ecdsa'. If unavailable, raise an informative error.
"""
if SigningKey is None:
raise RuntimeError(
"ecdsa not installed; either install with:\n"
" python3 -m pip install -r tools/requirements.txt\n"
"or pass --pubkey explicitly."
)
sk = SigningKey.generate(curve=SECP256k1)
vk = sk.get_verifying_key()
try:
comp = vk.to_string("compressed")
except TypeError:
# Manual compression
p = vk.pubkey.point
x = p.x()
y = p.y()
prefix = b"\x02" if (y % 2 == 0) else b"\x03"
comp = prefix + x.to_bytes(32, "big")
return comp.hex()
def main() -> int:
parser = argparse.ArgumentParser(description="Non-destructive RPC smoke tests")
parser.add_argument("--url", default=os.environ.get("RPC_URL", "http://127.0.0.1:9944"),
help="RPC server URL (http[s]://host:port or ws[s]://host:port)")
parser.add_argument("--pubkey", help="Compressed secp256k1 public key hex (33 bytes, 66 hex chars)")
parser.add_argument("--limit", type=int, default=3, help="Number of job IDs to detail from list_jobs()")
args = parser.parse_args()
client = JsonRpcClient(args.url)
print(f"[rpc] URL: {client.url}")
# 1) fetch_nonce
pubkey = args.pubkey or random_compressed_pubkey_hex()
print(f"[rpc] fetch_nonce(pubkey={pubkey[:10]}...):", end=" ")
try:
nonce = client.call("fetch_nonce", [pubkey])
print("OK")
print(f" nonce: {nonce}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 2) whoami
print("[rpc] whoami():", end=" ")
try:
who = client.call("whoami", [])
print("OK")
print(f" whoami: {who}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 3) list_jobs
print("[rpc] list_jobs():", end=" ")
try:
job_ids: List[str] = client.call("list_jobs", [])
print("OK")
print(f" total: {len(job_ids)}")
for i, jid in enumerate(job_ids[: max(0, args.limit)]):
print(f" [{i}] {jid}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 4) For a few jobs, query status/output/logs
detail_count = 0
for jid in job_ids[: max(0, args.limit)] if 'job_ids' in locals() else []:
print(f"[rpc] get_job_status({jid}):", end=" ")
try:
st = client.call("get_job_status", [jid])
print("OK")
print(f" status: {st}")
except Exception as e:
print(f"ERROR: {e}")
print(f"[rpc] get_job_output({jid}):", end=" ")
try:
out = client.call("get_job_output", [jid])
print("OK")
snippet = (out if isinstance(out, str) else json.dumps(out))[:120]
print(f" output: {snippet}{'...' if len(snippet)==120 else ''}")
except Exception as e:
print(f"ERROR: {e}")
print(f"[rpc] get_job_logs({jid}):", end=" ")
try:
logs_obj = client.call("get_job_logs", [jid]) # { logs: String | null }
print("OK")
logs = logs_obj.get("logs") if isinstance(logs_obj, dict) else None
if logs is None:
print(" logs: (no logs)")
else:
snippet = logs[:120]
print(f" logs: {snippet}{'...' if len(snippet)==120 else ''}")
except Exception as e:
print(f"ERROR: {e}")
detail_count += 1
print("\nSmoke tests complete.")
print("Summary:")
print(f" whoami tested")
print(f" fetch_nonce tested (pubkey provided/generated)")
print(f" list_jobs tested (count printed)")
print(f" detailed queries for up to {detail_count} job(s) (status/output/logs)")
return 0
if __name__ == "__main__":
sys.exit(main())