"""Minimal Nostr client: NIP-01 kind-1 notes (build, sign, verify, publish). Implemented in-repo (DECISIONS D4) so every byte of the signing path is auditable: event id = sha256 of the canonical NIP-01 serialization; signature = BIP-340 Schnorr over the id. Keys are 32-byte hex. Relays are plain env config. Publishing is fire-and-wait: open a websocket per relay, send the EVENT, wait for the OK frame (or timeout), and return the per-relay results. """ from __future__ import annotations import hashlib import json from typing import Any from coincurve import PrivateKey, PublicKey KIND_NOTE = 1 def normalize_key(private_key_hex: str) -> bytes: """Accept 64-char hex (with or without 0x). Anything else is a config error.""" key = private_key_hex.strip().lower().removeprefix("0x") if len(key) != 64 or not all(c in "0123456789abcdef" for c in key): raise ValueError("CAYCE_OPS_NOSTR_PRIVATE_KEY must be 64-char hex (32 bytes)") return bytes.fromhex(key) def pubkey_from_secret(secret: bytes) -> str: return PublicKey.from_secret(secret).format().hex()[2:] def build_event( secret: bytes, content: str, tags: list[list[str]] | None = None, created_at: int | None = None, kind: int = KIND_NOTE, ) -> dict[str, Any]: """Build and sign a NIP-01 event. Deterministic given the same inputs.""" created_at = int(created_at if created_at is not None else __import__("time").time()) pubkey = pubkey_from_secret(secret) serialized = json.dumps( [0, pubkey, created_at, kind, tags or [], content], separators=(",", ":"), # canonical NIP-01 form ensure_ascii=False, ) event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest() # zero aux randomness -> fully deterministic signatures (BIP-340 permits it; # verification is unaffected either way) signature = ( PrivateKey(secret) .sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32) .hex() ) return { "pubkey": pubkey, "created_at": created_at, "kind": kind, "tags": tags or [], "content": content, "id": event_id, "sig": signature, } def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool: """BIP-340 reference verification (pure Python, public domain spec code). Signing runs in coincurve (libsecp256k1); verification is pure Python so the auditor can read every step without CFFI indirection. """ P = 2**256 - 2**32 - 977 N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 def tag_hash(tag: str, msg: bytes) -> bytes: t = hashlib.sha256(tag.encode()).digest() return hashlib.sha256(t + t + msg).digest() def lift_x(b: int): if b >= P: return None y_sq = (pow(b, 3, P) + 7) % P y = pow(y_sq, (P + 1) // 4, P) if pow(y, 2, P) != y_sq: return None return (b, y if y % 2 == 0 else P - y) def point_add(p1, p2): if p1 is None: return p2 if p2 is None: return p1 if p1[0] == p2[0] and p1[1] != p2[1]: return None if p1 == p2: lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P else: lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P x3 = (lam * lam - p1[0] - p2[0]) % P return (x3, (lam * (p1[0] - x3) - p1[1]) % P) def point_mul(p, scalar): acc = None for i in range(256): if (scalar >> i) & 1: acc = point_add(acc, p) p = point_add(p, p) return acc if len(sig32) != 64 or len(msg32) != 32 or len(xonly_pub32) != 32: return False r = int.from_bytes(sig32[:32], "big") s = int.from_bytes(sig32[32:], "big") if not (0 <= r < P and 0 <= s < N): return False pub_point = lift_x(int.from_bytes(xonly_pub32, "big")) if pub_point is None: return False e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N generator = ( 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, # Gx 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, # Gy ) r_point = point_add( point_mul(generator, s), point_mul(pub_point, (N - e) % N), ) if r_point is None or r_point[1] % 2 != 0: return False return r_point[0] == r def verify_event(event: dict[str, Any]) -> bool: """Recompute the id and verify the Schnorr signature against the pubkey.""" serialized = json.dumps( [0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]], separators=(",", ":"), ensure_ascii=False, ) if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]: return False # 33-byte compressed point (02-prefixed x-only) compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format() return _schnorr_verify( bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:] ) def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str: """Publish one event to one relay; return the relay's OK message.""" from websockets.sync.client import connect with connect(relay_url, close_timeout=timeout) as ws: ws.send(json.dumps(["EVENT", event])) reply = json.loads(ws.recv(timeout=timeout)) if not (isinstance(reply, list) and len(reply) >= 3 and reply[0] == "OK" and reply[2] is True): raise RuntimeError(f"relay {relay_url} rejected event: {str(reply)[:200]}") return str(reply[1]) def publish( relays: list[str], event: dict[str, Any], timeout: float = 10.0 ) -> list[dict[str, str]]: """Publish to every relay; report per-relay outcome (never raises unless all fail).""" results = [] for relay in relays: try: relay_publish(relay, event, timeout) results.append({"relay": relay, "ok": "true"}) except Exception as exc: # noqa: BLE001 — per-relay isolation results.append({"relay": relay, "ok": "false", "error": str(exc)[:200]}) if relays and all(r["ok"] == "false" for r in results): raise RuntimeError(f"all relays failed: {results}") return results