Kez/python/kez/keys.py
Jason Tudisco b1240c13e5 Add Python implementation and cross-test interop
Add a Python port of the KEZ CLI under python/, mirroring the Rust and
Node implementations command-for-command and byte-for-byte:

- Pure-Python JCS (RFC 8785), BIP-340 Schnorr, and Bech32; cryptography
  for Ed25519 and zstandard for the compact zstd framing.
- Full CLI: identity new, claim create/dns, verify file, and
  sigchain add/revoke/show/export.

Wire Python into crosstest.sh with 35 new scenarios covering Python
against both Rust and Node, in every direction, across all wire formats,
both key types, DNS proofs, and sigchains (incl. JSONL byte parity).
All 55 scenarios pass.

Update root README and .gitignore for the new implementation.
2026-06-01 13:29:45 -06:00

143 lines
4.3 KiB
Python

"""Signing keys: nostr (secp256k1 Schnorr) and ed25519."""
from __future__ import annotations
import hashlib
import os
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, PublicFormat
from . import bech32, schnorr
from .identity import Identity
from .jcs import canonical_bytes
NOSTR_SCHNORR_ALG = "nostr-secp256k1-schnorr-sha256-jcs"
ED25519_SHA512_ALG = "ed25519-sha512-jcs"
class NostrSecret:
"""A nostr secp256k1 secret key, addressed by its npub."""
__slots__ = ("_sk", "_pub")
def __init__(self, secret_key: bytes) -> None:
if len(secret_key) != 32:
raise ValueError("nostr secret key must be 32 bytes")
self._sk = secret_key
self._pub = schnorr.pubkey_gen(secret_key)
@classmethod
def generate(cls) -> "NostrSecret":
while True:
sk = os.urandom(32)
i = int.from_bytes(sk, "big")
if 1 <= i < schnorr.n:
return cls(sk)
@classmethod
def from_nsec(cls, nsec: str) -> "NostrSecret":
raw = bech32.decode("nsec", nsec.strip())
if len(raw) != 32:
raise ValueError("invalid nsec: expected 32-byte key")
return cls(raw)
def nsec(self) -> str:
return bech32.encode("nsec", self._sk)
def npub(self) -> str:
return bech32.encode("npub", self._pub)
def identity(self) -> Identity:
return Identity.parse(f"nostr:{self.npub()}")
def alg(self) -> str:
return NOSTR_SCHNORR_ALG
def sign_payload(self, payload) -> str:
digest = hashlib.sha256(canonical_bytes(payload)).digest()
return schnorr.sign(digest, self._sk).hex()
class Ed25519Secret:
"""An ed25519 secret seed, addressed by its hex public key."""
__slots__ = ("_seed", "_key", "_pub")
def __init__(self, seed: bytes) -> None:
if len(seed) != 32:
raise ValueError("ed25519 seed must be 32 bytes")
self._seed = seed
self._key = Ed25519PrivateKey.from_private_bytes(seed)
self._pub = self._key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
@classmethod
def generate(cls) -> "Ed25519Secret":
key = Ed25519PrivateKey.generate()
seed = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
return cls(seed)
@classmethod
def from_seed_hex(cls, seed_hex: str) -> "Ed25519Secret":
seed = bytes.fromhex(seed_hex.strip())
if len(seed) != 32:
raise ValueError("invalid ed25519 seed: expected 32-byte (64 hex char) seed")
return cls(seed)
def seed_hex(self) -> str:
return self._seed.hex()
def pubkey_hex(self) -> str:
return self._pub.hex()
def identity(self) -> Identity:
return Identity.parse(f"ed25519:{self.pubkey_hex()}")
def alg(self) -> str:
return ED25519_SHA512_ALG
def sign_payload(self, payload) -> str:
return self._key.sign(canonical_bytes(payload)).hex()
def verify_signature(payload, alg: str, key: Identity, sig_hex: str) -> bool:
"""Verify a signature block against an arbitrary JCS-canonicalizable payload."""
try:
sig = bytes.fromhex(sig_hex)
except ValueError:
return False
if len(sig) != 64:
return False
if alg == NOSTR_SCHNORR_ALG:
if key.scheme != "nostr":
return False
pubkey = bech32.decode("npub", key.value)
digest = hashlib.sha256(canonical_bytes(payload)).digest()
return schnorr.verify(digest, pubkey, sig)
if alg == ED25519_SHA512_ALG:
if key.scheme != "ed25519":
return False
try:
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(key.value))
pub.verify(sig, canonical_bytes(payload))
return True
except Exception:
return False
return False
def signer_from_flags(nsec: str | None, ed25519_seed: str | None):
if nsec and ed25519_seed:
raise ValueError("pass only one of --nsec or --ed25519-seed")
if nsec:
return NostrSecret.from_nsec(nsec)
if ed25519_seed:
return Ed25519Secret.from_seed_hex(ed25519_seed)
raise ValueError("missing key: pass --nsec or --ed25519-seed")