Updates
This commit is contained in:
109
tools/gen_auth.py
Normal file
109
tools/gen_auth.py
Normal 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
124
tools/gen_auth.sh
Executable 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
2
tools/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
ecdsa==0.18.0
|
||||
requests==2.32.3
|
204
tools/rpc_smoke_test.py
Normal file
204
tools/rpc_smoke_test.py
Normal 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())
|
Reference in New Issue
Block a user