"""Dependency-free Nostr signing for Buzz WebSocket authentication.""" from __future__ import annotations import hashlib import json import secrets import time from typing import Any, Optional FIELD_ORDER = 2**256 - 2**32 - 977 CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 GENERATOR = ( 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, ) BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" Point = Optional[tuple[int, int]] def _bech32_polymod(values: list[int]) -> int: generators = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) checksum = 1 for value in values: top = checksum >> 25 checksum = ((checksum & 0x1FFFFFF) << 5) ^ value for index, generator in enumerate(generators): if (top >> index) & 1: checksum ^= generator return checksum def _bech32_hrp_expand(hrp: str) -> list[int]: return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp] def _decode_nsec(value: str) -> bytes: if value.lower() != value and value.upper() != value: raise ValueError("nsec cannot mix upper- and lowercase") normalized = value.lower() separator = normalized.rfind("1") if separator < 1 or separator + 7 > len(normalized): raise ValueError("invalid nsec encoding") hrp = normalized[:separator] if hrp != "nsec": raise ValueError("private key must use the nsec prefix") try: data = [BECH32_CHARSET.index(char) for char in normalized[separator + 1 :]] except ValueError as exc: raise ValueError("invalid character in nsec") from exc if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1: raise ValueError("invalid nsec checksum") accumulator = 0 bits = 0 decoded = bytearray() for value5 in data[:-6]: accumulator = (accumulator << 5) | value5 bits += 5 while bits >= 8: bits -= 8 decoded.append((accumulator >> bits) & 0xFF) if bits and (accumulator & ((1 << bits) - 1)): raise ValueError("non-zero nsec padding") if len(decoded) != 32: raise ValueError("nsec must encode exactly 32 bytes") return bytes(decoded) def decode_private_key(value: str) -> int: raw = value.strip() if raw.lower().startswith("nsec1"): key_bytes = _decode_nsec(raw) else: try: key_bytes = bytes.fromhex(raw) except ValueError as exc: raise ValueError("private key must be 64 hex characters or nsec") from exc if len(key_bytes) != 32: raise ValueError("private key must be 32 bytes") key = int.from_bytes(key_bytes, "big") if not 1 <= key < CURVE_ORDER: raise ValueError("private key is outside the secp256k1 range") return key def _point_add(left: Point, right: Point) -> Point: if left is None: return right if right is None: return left x1, y1 = left x2, y2 = right if x1 == x2: if (y1 + y2) % FIELD_ORDER == 0: return None slope = (3 * x1 * x1) * pow(2 * y1, FIELD_ORDER - 2, FIELD_ORDER) else: slope = (y2 - y1) * pow(x2 - x1, FIELD_ORDER - 2, FIELD_ORDER) slope %= FIELD_ORDER x3 = (slope * slope - x1 - x2) % FIELD_ORDER y3 = (slope * (x1 - x3) - y1) % FIELD_ORDER return x3, y3 def _point_multiply(scalar: int, point: Point = GENERATOR) -> Point: result: Point = None addend = point while scalar: if scalar & 1: result = _point_add(result, addend) addend = _point_add(addend, addend) scalar >>= 1 return result def _tagged_hash(tag: str, payload: bytes) -> bytes: tag_hash = hashlib.sha256(tag.encode()).digest() return hashlib.sha256(tag_hash + tag_hash + payload).digest() def public_key_hex(private_key: str) -> str: point = _point_multiply(decode_private_key(private_key)) if point is None: # pragma: no cover - range validation makes this unreachable raise ValueError("invalid private key") return point[0].to_bytes(32, "big").hex() def schnorr_sign( message: bytes, private_key: str, *, auxiliary_randomness: Optional[bytes] = None, ) -> bytes: if len(message) != 32: raise ValueError("BIP-340 signs a 32-byte message") secret = decode_private_key(private_key) public_point = _point_multiply(secret) if public_point is None: # pragma: no cover raise ValueError("invalid private key") public_x = public_point[0].to_bytes(32, "big") adjusted_secret = secret if public_point[1] % 2 == 0 else CURVE_ORDER - secret aux = ( auxiliary_randomness if auxiliary_randomness is not None else secrets.token_bytes(32) ) if len(aux) != 32: raise ValueError("auxiliary randomness must be 32 bytes") masked = bytes( left ^ right for left, right in zip( adjusted_secret.to_bytes(32, "big"), _tagged_hash("BIP0340/aux", aux), ) ) nonce = ( int.from_bytes( _tagged_hash("BIP0340/nonce", masked + public_x + message), "big" ) % CURVE_ORDER ) if nonce == 0: raise RuntimeError("BIP-340 produced a zero nonce") nonce_point = _point_multiply(nonce) if nonce_point is None: # pragma: no cover raise RuntimeError("BIP-340 produced an invalid nonce point") adjusted_nonce = nonce if nonce_point[1] % 2 == 0 else CURVE_ORDER - nonce nonce_x = nonce_point[0].to_bytes(32, "big") challenge = ( int.from_bytes( _tagged_hash("BIP0340/challenge", nonce_x + public_x + message), "big" ) % CURVE_ORDER ) signature_scalar = (adjusted_nonce + challenge * adjusted_secret) % CURVE_ORDER return nonce_x + signature_scalar.to_bytes(32, "big") def build_auth_event( *, private_key: str, challenge: str, relay_url: str, auth_tag_json: str = "", created_at: Optional[int] = None, auxiliary_randomness: Optional[bytes] = None, ) -> dict[str, Any]: tags: list[list[str]] = [ ["relay", relay_url], ["challenge", challenge], ] if auth_tag_json.strip(): try: auth_tag = json.loads(auth_tag_json) except json.JSONDecodeError as exc: raise ValueError("BUZZ_AUTH_TAG is not valid JSON") from exc if ( not isinstance(auth_tag, list) or len(auth_tag) != 4 or auth_tag[0] != "auth" or not all(isinstance(part, str) for part in auth_tag) ): raise ValueError("BUZZ_AUTH_TAG must be a four-string auth tag") tags.append(auth_tag) pubkey = public_key_hex(private_key) timestamp = int(time.time()) if created_at is None else int(created_at) serialized = json.dumps( [0, pubkey, timestamp, 22242, tags, ""], separators=(",", ":"), ensure_ascii=False, ).encode() event_id = hashlib.sha256(serialized).digest() return { "id": event_id.hex(), "pubkey": pubkey, "created_at": timestamp, "kind": 22242, "tags": tags, "content": "", "sig": schnorr_sign( event_id, private_key, auxiliary_randomness=auxiliary_randomness, ).hex(), }