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>
163 lines
4.9 KiB
Python
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")
|