#!/usr/bin/env python3 """Sign or verify a short statement with a Nano key. Python 3.8+, standard library only. Same primitives the forecast ladder verifies: Ed25519 with Blake2b-512 (the Nano variant), message = blake2b-256 of the canonical JSON (sorted keys, no whitespace). sign: python3 sign-statement.py --key-file match.key --statement '{"arena":"jimothyislife","match":"","payout":"nano_..."}' (a missing key file is created with a fresh random key, mode 0600; the address is derived from it) verify: python3 sign-statement.py --verify '{"statement":{...},"address":"nano_...","signature":"HEX"}' (or --verify-file signed.json); exit code 0 = valid, 1 = invalid. """ import argparse, hashlib, json, os, sys ALPHABET = "13456789abcdefghijkmnopqrstuwxyz" q = 2**255 - 19 l = 2**252 + 27742317777372353535851937790883648493 def inv(x): return pow(x, q - 2, q) d = (-121665 * inv(121666)) % q I = pow(2, (q - 1) // 4, q) def xrecover(y): xx = (y * y - 1) * inv(d * y * y + 1) x = pow(xx, (q + 3) // 8, q) if (x * x - xx) % q != 0: x = (x * I) % q if x % 2 != 0: x = q - x return x By = (4 * inv(5)) % q B = (xrecover(By), By) def add(P, Q): x1, y1 = P; x2, y2 = Q x3 = (x1 * y2 + x2 * y1) * inv(1 + d * x1 * x2 * y1 * y2) y3 = (y1 * y2 + x1 * x2) * inv(1 - d * x1 * x2 * y1 * y2) return (x3 % q, y3 % q) def mul(P, e): Q = (0, 1) for bit in bin(e)[2:]: Q = add(Q, Q) if bit == "1": Q = add(Q, P) return Q def oncurve(P): x, y = P return (-x * x + y * y - 1 - d * x * x * y * y) % q == 0 def encodepoint(P): return (P[1] | ((P[0] & 1) << 255)).to_bytes(32, "little") def decodepoint(b): n = int.from_bytes(b, "little"); y = n & ((1 << 255) - 1); x = xrecover(y) if (x & 1) != (n >> 255): x = q - x P = (x, y) if not oncurve(P): raise ValueError("point not on curve") return P def H(m): return hashlib.blake2b(m).digest() def clamp(h): a = int.from_bytes(h[:32], "little"); a &= (1 << 254) - 8; a |= 1 << 254; return a def public_key(sk): return encodepoint(mul(B, clamp(H(sk)))) def sign(msg, sk): h = H(sk); a = clamp(h); pk = encodepoint(mul(B, a)) r = int.from_bytes(H(h[32:] + msg), "little") % l R = encodepoint(mul(B, r)) k = int.from_bytes(H(R + pk + msg), "little") return R + ((r + k * a) % l).to_bytes(32, "little") def verify(msg, sig, pk): if len(sig) != 64 or len(pk) != 32: return False try: R = decodepoint(sig[:32]); A = decodepoint(pk) except ValueError: return False S = int.from_bytes(sig[32:], "little") if S >= l: return False k = int.from_bytes(H(sig[:32] + pk + msg), "little") return mul(B, S) == add(R, mul(A, k)) def b32(data, bits): n = int.from_bytes(data, "big"); out = "" for i in range(bits // 5 - 1, -1, -1): out += ALPHABET[(n >> (5 * i)) & 31] return out def address_of(pk): check = hashlib.blake2b(pk, digest_size=5).digest()[::-1] return "nano_" + b32(pk, 260) + b32(check, 40) def pk_of_address(addr): if not (addr.startswith("nano_") or addr.startswith("xrb_")): raise ValueError("bad prefix") body = addr.split("_", 1)[1] if len(body) != 60: raise ValueError("bad length") n = 0 for c in body[:52]: n = (n << 5) | ALPHABET.index(c) pk = (n & ((1 << 256) - 1)).to_bytes(32, "big") if address_of(pk) != "nano_" + body: raise ValueError("bad checksum") return pk def canonical(v): if v is None: return "null" if v is True: return "true" if v is False: return "false" if isinstance(v, (int, float)): return json.dumps(v) if isinstance(v, str): return json.dumps(v, ensure_ascii=False) if isinstance(v, list): return "[" + ",".join(canonical(x) for x in v) + "]" if isinstance(v, dict): return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(v[k]) for k in sorted(v)) + "}" raise TypeError(type(v)) def digest(statement): return hashlib.blake2b(canonical(statement).encode(), digest_size=32).digest() def main(): ap = argparse.ArgumentParser() ap.add_argument("--key-file"); ap.add_argument("--key", help="64 hex private key") ap.add_argument("--statement", help="JSON object to sign"); ap.add_argument("--statement-file") ap.add_argument("--verify", help='JSON {"statement":..,"address":..,"signature":..}'); ap.add_argument("--verify-file") a = ap.parse_args() if a.verify or a.verify_file: obj = json.loads(a.verify) if a.verify else json.load(open(a.verify_file)) try: pk = pk_of_address(obj["address"]) except Exception as e: print("invalid: address (%s)" % e); sys.exit(1) ok = verify(digest(obj["statement"]), bytes.fromhex(obj["signature"]), pk) print(("valid: " if ok else "invalid: ") + obj["address"] + " over " + canonical(obj["statement"])) sys.exit(0 if ok else 1) if a.key: sk = bytes.fromhex(a.key) elif a.key_file: if not os.path.exists(a.key_file): fd = os.open(a.key_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) os.write(fd, os.urandom(32).hex().encode() + b"\n"); os.close(fd) print("created new key in " + a.key_file, file=sys.stderr) sk = bytes.fromhex(open(a.key_file).read().strip()) else: ap.error("--key-file or --key, or --verify") st = json.loads(a.statement) if a.statement else json.load(open(a.statement_file)) if not isinstance(st, dict): ap.error("statement must be a JSON object") pk = public_key(sk); addr = address_of(pk) out = {"statement": st, "address": addr, "signature": sign(digest(st), sk).hex().upper()} assert verify(digest(st), bytes.fromhex(out["signature"]), pk) print(json.dumps(out, ensure_ascii=False)) if __name__ == "__main__": main()