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.
143 lines
4.3 KiB
Python
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")
|