diff --git a/nostr/key.py b/nostr/key.py index 19eadd8..48e9c58 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -1,9 +1,13 @@ +from dataclasses import dataclass import secrets import base64 +from typing import List import secp256k1 from cffi import FFI from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding +from embit import bip39 +from embit.bip32 import HDKey from hashlib import sha256 from nostr.delegation import Delegation @@ -34,15 +38,17 @@ def from_npub(cls, npub: str): return cls(bytes(raw_public_key)) + class PrivateKey: - def __init__(self, raw_secret: bytes=None) -> None: - if not raw_secret is None: + def __init__(self, raw_secret: bytes = None) -> None: + if raw_secret is not None: self.raw_secret = raw_secret else: self.raw_secret = secrets.token_bytes(32) sk = secp256k1.PrivateKey(self.raw_secret) self.public_key = PublicKey(sk.pubkey.serialize()[1:]) + @classmethod def from_nsec(cls, nsec: str): @@ -108,6 +114,47 @@ def sign_delegation(self, delegation: Delegation) -> None: def __eq__(self, other): return self.raw_secret == other.raw_secret + + +@dataclass +class Bip39PrivateKey(PrivateKey): + """ + Nostr PrivateKey that is derived from a BIP-39 mnemonic + optional BIP-39 + passphrase using the derivation path specified in NIP-06. + """ + mnemonic: List[str] = None + passphrase: str = None + + def __post_init__(self): + if self.mnemonic is None: + self.mnemonic = bip39.mnemonic_from_bytes(secrets.token_bytes(32)).split() + + if self.passphrase is None: + # Per BIP-39 spec, no passphrase is the empty string + self.passphrase = "" + + # Convert the mnemonic to the root HDKey and derive the Nostr key per NIP-06 + root = HDKey.from_seed(bip39.mnemonic_to_seed(mnemonic=" ".join(self.mnemonic), password=self.passphrase)) + nostr_root = root.derive("m/44h/1237h/0h/0/0") + + super().__init__(raw_secret=nostr_root.secret) + + + @classmethod + def with_mnemonic_length(cls, num_words: int): + """ Creates a new random BIP-39 mnemonic of the specified length to generate a new Nostr PK """ + if num_words == 24: + # default is already 24 word-mnemonic + return cls() + elif num_words == 12: + # 12-word mnemonic == 16-byte input entropy + mnemonic = bip39.mnemonic_from_bytes(secrets.token_bytes(16)).split() + return cls(mnemonic=mnemonic) + else: + raise Exception("Only mnemonics of length 12 or 24 are supported") + + + def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: if prefix is None and suffix is None: raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") @@ -122,6 +169,8 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: return sk + + ffi = FFI() @ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") def copy_x(output, x32, y32, data): diff --git a/pyproject.toml b/pyproject.toml index 417a873..2fe7b7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,11 @@ authors = [ description = "A Python library for making Nostr clients" urls = { Homepage = "https://github.com/jeffthibault/python-nostr" } readme = "README.md" -requires-python = ">3.6.0" +requires-python = ">=3.9.0" dependencies = [ "cffi>=1.15.0", "cryptography>=37.0.4", + "embit>=0.7.0", "pycparser>=2.21", "secp256k1>=0.14.0", "websocket-client>=1.3.3", diff --git a/test/test_key.py b/test/test_key.py index 70d8522..19ab63e 100644 --- a/test/test_key.py +++ b/test/test_key.py @@ -1,23 +1,121 @@ -from nostr.key import PrivateKey +import pytest +import secrets +from embit import bip39 +from nostr.event import Event +from nostr.key import Bip39PrivateKey, PrivateKey -def test_eq_true(): - """ __eq__ should return True when PrivateKeys are equal """ - pk1 = PrivateKey() - pk2 = PrivateKey(pk1.raw_secret) - assert pk1 == pk2 +class TestPrivateKey: + def test_eq_true(self): + """ __eq__ should return True when PrivateKeys are equal """ + pk1 = PrivateKey() + pk2 = PrivateKey(pk1.raw_secret) + assert pk1 == pk2 -def test_eq_false(): - """ __eq__ should return False when PrivateKeys are not equal """ - pk1 = PrivateKey() - pk2 = PrivateKey() - assert pk1.raw_secret != pk2.raw_secret - assert pk1 != pk2 + def test_eq_false(self): + """ __eq__ should return False when PrivateKeys are not equal """ + pk1 = PrivateKey() + pk2 = PrivateKey() + assert pk1.raw_secret != pk2.raw_secret + assert pk1 != pk2 -def test_from_nsec(): - """ PrivateKey.from_nsec should yield the source's raw_secret """ - pk1 = PrivateKey() - pk2 = PrivateKey.from_nsec(pk1.bech32()) - assert pk1.raw_secret == pk2.raw_secret + + def test_from_nsec(self): + """ PrivateKey.from_nsec should yield the source's raw_secret """ + pk1 = PrivateKey() + pk2 = PrivateKey.from_nsec(pk1.bech32()) + assert pk1.raw_secret == pk2.raw_secret + + + +class TestBip39PrivateKey: + def test_create_random_mnemonic(self): + """ Should create a new random BIP-39 mnemonic for each new PK """ + pk1 = Bip39PrivateKey() + pk2 = Bip39PrivateKey() + + assert pk1.mnemonic != pk2.mnemonic + assert pk1.raw_secret != pk2.raw_secret + + + def test_rejects_invalid_mnemonic(self): + """ Should reject mnemonics that fail checksum word verification """ + pk = Bip39PrivateKey() + + # Change the first word in the mnemonic that isn't "satoshi" to "satoshi" + for i in range(0, 23): + if pk.mnemonic[i] != "satoshi": + pk.mnemonic[i] = "satoshi" + break + + # Now if we try to load this modified mnemonic, it should fail validation + with pytest.raises(ValueError) as e: + Bip39PrivateKey(pk.mnemonic) + assert "Checksum verification failed" in str(e) + + + def test_24word_mnemonic_generates_pk(self): + """ Should deterministically derive the associated Nostr PK from a 24-word BIP-39 mnemonic """ + entropy = secrets.token_bytes(32) + mnemonic = bip39.mnemonic_from_bytes(entropy).split() + assert len(mnemonic) == 24 + + pk1 = Bip39PrivateKey(mnemonic) + + # The BIP-39 entropy to create the mnemonic is not the same as the final Nostr PK secret + assert entropy != pk1.raw_secret + + # Nostr key derivation is deterministic; same result each time + pk2 = Bip39PrivateKey(mnemonic) + assert pk1.raw_secret == pk2.raw_secret + + + def test_12word_mnemonic_generates_pk(self): + """ Should deterministically derive the associated Nostr PK from a 12-word BIP-39 mnemonic """ + entropy = secrets.token_bytes(16) + mnemonic = bip39.mnemonic_from_bytes(entropy).split() + assert len(mnemonic) == 12 + + pk1 = Bip39PrivateKey(mnemonic) + + # The BIP-39 entropy to create the mnemonic is not the same as the final Nostr PK secret + assert entropy != pk1.raw_secret + + # Nostr key derivation is deterministic; same result each time + pk2 = Bip39PrivateKey(mnemonic) + assert pk1.raw_secret == pk2.raw_secret + + + def test_bip39_passphrase_changes_pk(self): + """ Should generate a different Nostr PK if an optional BIP-39 passphrase is provided """ + entropy = secrets.token_bytes(32) + mnemonic = bip39.mnemonic_from_bytes(entropy).split() + pk1 = Bip39PrivateKey(mnemonic) + pk2 = Bip39PrivateKey(mnemonic, passphrase="somethingsomethingprofit!") + pk3 = Bip39PrivateKey(mnemonic, passphrase="otherpassphrase") + + assert pk1.raw_secret != pk2.raw_secret + assert pk2.raw_secret != pk3.raw_secret + + + def test_with_mnemonic_length(self): + """ Should create a new PK using a new randomly-generated mnemonic of the specified length """ + pk12 = Bip39PrivateKey.with_mnemonic_length(12) + assert len(pk12.mnemonic) == 12 + + pk24 = Bip39PrivateKey.with_mnemonic_length(24) + assert len(pk24.mnemonic) == 24 + + with pytest.raises(Exception) as e: + Bip39PrivateKey.with_mnemonic_length(99) + assert "12 or 24" in str(e) + + + def test_pk_signs_event(self): + """ Should still be able to sign Events like any other Nostr PK """ + pk = Bip39PrivateKey() + event = Event(public_key=pk.public_key.hex(), content="Hello, world!") + pk.sign_event(event) + assert event.verify()