Kez/python/kez/keys.py
Jason Tudisco b0cc1a74a0 feat(python,crosstest): mirror BIP-39 mnemonic to Python + add interop scenarios
Completes the three-way BIP-39 mnemonic surface (Rust + Node landed in
0058d9b) and pins down byte-for-byte agreement with crosstest scenarios.

Python (mirrors rust/crates/kez-core/src/mnemonic.rs + nodejs's mnemonic.ts):
  • python/kez/mnemonic.py — generate_mnemonic, seed_from_mnemonic,
    mnemonic_from_seed_24, ed25519_from_mnemonic,
    generate_ed25519_with_mnemonic. Same 24-word-bijection / 12-word-
    SHA-256-domain-tagged semantics. Uses Trezor's `mnemonic` library
    (v0.21) for the BIP-39 wordlist + entropy parsing; deliberately does
    NOT use BIP-39's PBKDF2 to_seed function.
  • python/kez/keys.py — Ed25519Secret.from_mnemonic() +
    generate_with_mnemonic() classmethods; signer_from_flags widened to
    accept --mnemonic.
  • python/kez/cli.py — identity new --mnemonic-words, identity
    mnemonic [--words], identity from-mnemonic; --mnemonic flag on
    claim create/dns and sigchain add/revoke/show/export. Output format
    matches Rust + Node verbatim so the crosstest harness can grep
    Primary/Public/Secret/Mnemonic lines.
  • python/tests/test_mnemonic.py — 19 tests covering all three
    canonical vectors (exact-match Secret + Public hex), round-trip,
    determinism, whitespace tolerance, bad-checksum, bad-word-count,
    the literal domain-tag bytes, and the 12-vs-24 entropy-overlap
    non-collision case.

Note: --mnemonic is NOT added to `sigchain publish` because that
subcommand doesn't exist in the Python CLI yet (rust + node only). When
the publish surface is ported, --mnemonic should follow it the same way.

Ground truth — python/MNEMONIC-TEST-VECTORS.md:
  V1: 24-word zero-entropy phrase ("abandon… art")
      seed   = 0000…0000
      pubkey = 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
  V2: 12-word zero-entropy phrase ("abandon… about")
      seed   = 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
      pubkey = 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
  V3: 12-word "legal winner thank year wave sausage worth useful legal winner thank yellow"
      seed   = 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
      pubkey = cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03

  The domain tag for the 12-word derivation is exactly the 15 ASCII
  bytes of "kez-bip39-12-v1", documented in the spec doc.

crosstest.sh — new "BIP-39 mnemonic interop" section:
  • Vector match: each impl × each vector × Public hex == expected (9
    scenarios). Catches any silent derivation drift.
  • Cross-impl claim signing via --mnemonic: every signer ↔ verifier
    pair (rust↔node, rust↔py, node↔py), every format (json/compact/
    markdown). 6 pairings × 3 formats = 18 scenarios.
  • Bijection sanity: the 24-word phrase printed by `identity from-
    mnemonic` round-trips to itself byte-for-byte (rust + node).
  • Python-involving scenarios auto-skip if `python/.venv/bin/python
    kez_cli.py identity from-mnemonic` returns non-zero, so the harness
    stays runnable on machines where Python isn't set up.

Verified end-to-end: `bash crosstest.sh` reports
  "All 84 scenarios passed."

Test totals across implementations:
  Rust:   114 (9 mnemonic-specific in kez-core)
  Node:    99 (8 mnemonic-specific in @kez/core)
  Python:  19 (mnemonic only; was no test suite before)
  Crosstest: 84 scenarios end-to-end

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:50:34 -06:00

163 lines
4.9 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)
@classmethod
def from_mnemonic(cls, phrase: str) -> "Ed25519Secret":
# Lazy import: mnemonic.py imports Ed25519Secret at module top.
from .mnemonic import seed_from_mnemonic
return cls(seed_from_mnemonic(phrase))
@classmethod
def generate_with_mnemonic(cls, words: int = 24) -> tuple["Ed25519Secret", str]:
from .mnemonic import generate_ed25519_with_mnemonic
return generate_ed25519_with_mnemonic(words)
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,
mnemonic: str | None = None,
):
provided = [v for v in (nsec, ed25519_seed, mnemonic) if v]
if len(provided) > 1:
raise ValueError("--nsec, --ed25519-seed, and --mnemonic are mutually exclusive")
if nsec:
return NostrSecret.from_nsec(nsec)
if ed25519_seed:
return Ed25519Secret.from_seed_hex(ed25519_seed)
if mnemonic:
return Ed25519Secret.from_mnemonic(mnemonic)
raise ValueError("missing key: pass --nsec, --ed25519-seed, or --mnemonic")