#!/usr/bin/env python3 import hashlib import json import struct import sys import time import serial from coincurve import PrivateKey, PublicKeyXOnly def read_exact(ser: serial.Serial, n: int, timeout_s: float = 8.0) -> bytes: end = time.time() + timeout_s out = bytearray() while len(out) < n and time.time() < end: chunk = ser.read(n - len(out)) if chunk: out.extend(chunk) continue time.sleep(0.005) return bytes(out) def recv_frame(ser: serial.Serial, timeout_s: float = 10.0) -> bytes: end = time.time() + timeout_s window = bytearray() while time.time() < end: b = ser.read(1) if not b: time.sleep(0.005) continue window.extend(b) if len(window) > 4: window.pop(0) if len(window) < 4: continue (n,) = struct.unpack(">I", bytes(window)) if n == 0 or n > 1_000_000: continue body = read_exact(ser, n, timeout_s=max(0.2, end - time.time())) if len(body) != n: return b"" if body[:1] not in (b"{", b"["): continue try: json.loads(body.decode("utf-8", errors="strict")) except Exception: # Probably matched a false header inside stream noise; keep scanning. continue return body return b"" def nostr_serialize(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> bytes: arr = [0, pubkey, created_at, kind, tags, content] return json.dumps(arr, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def event_id_hex(event_obj: dict) -> str: ser = nostr_serialize( event_obj["pubkey"], int(event_obj["created_at"]), int(event_obj["kind"]), event_obj["tags"], event_obj["content"], ) return hashlib.sha256(ser).hexdigest() def verify_event(event_obj: dict) -> bool: ev_id = event_id_hex(event_obj) if ev_id != event_obj.get("id"): return False pk = PublicKeyXOnly(bytes.fromhex(event_obj["pubkey"])) sig = bytes.fromhex(event_obj["sig"]) return pk.verify(sig, bytes.fromhex(ev_id)) def build_auth_envelope(method: str, params, caller_priv: bytes, label: str = "py-cli") -> dict: body_json = json.dumps(params, separators=(",", ":")).encode("utf-8") body_hash = hashlib.sha256(body_json).hexdigest() sk = PrivateKey(caller_priv) caller_pub_x = sk.public_key.format(compressed=True)[1:].hex() created_at = int(time.time()) tags = [ ["nsigner_rpc", "1"], ["nsigner_method", method], ["nsigner_body_hash", body_hash], ] serialized = json.dumps( [0, caller_pub_x, created_at, 27235, tags, label], separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") event_id = hashlib.sha256(serialized).hexdigest() sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32).hex() return { "id": event_id, "pubkey": caller_pub_x, "created_at": created_at, "kind": 27235, "tags": tags, "content": label, "sig": sig, } def main() -> int: args = [a for a in sys.argv[1:]] no_auth = False if "--no-auth" in args: no_auth = True args.remove("--no-auth") use_alt_caller = "--alt-caller" in args if use_alt_caller: args.remove("--alt-caller") port = args[0] if args else "/dev/ttyACM0" caller_priv = bytes(range(1, 33)) if not use_alt_caller else bytes(range(33, 65)) unsigned_event = { "kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from feather phase4", } params = [unsigned_event] req = { "jsonrpc": "2.0", "id": "2", "method": "nostr_sign_event", "params": params, } if not no_auth: req["auth"] = build_auth_envelope("nostr_sign_event", params, caller_priv) body = json.dumps(req, separators=(",", ":")).encode("utf-8") frame = struct.pack(">I", len(body)) + body ser = serial.Serial() ser.port = port ser.baudrate = 115200 ser.timeout = 0.2 ser.rtscts = False ser.dsrdtr = False # Set modem-control lines before open to avoid auto-reset pulses. ser.dtr = False ser.rts = False with ser: # Let board fully settle after USB open/reconnect. time.sleep(3.0) ser.reset_input_buffer() ser.write(frame) ser.flush() resp_raw = recv_frame(ser, timeout_s=45.0) if not resp_raw: print("No framed response received") return 1 try: resp = json.loads(resp_raw.decode("utf-8", errors="strict")) except Exception: print("Invalid JSON response bytes:", resp_raw) return 1 if "error" in resp: print("RPC error:", json.dumps(resp["error"], separators=(",", ":"))) return 1 result = resp.get("result") if not isinstance(result, dict): print("Invalid RPC result") return 1 ok = verify_event(result) print(json.dumps(result, separators=(",", ":"))) if not ok: print("signature invalid") return 1 print("✓ signature valid") return 0 if __name__ == "__main__": raise SystemExit(main())