Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] Bip39PrivateKey class #48

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions nostr/key.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
132 changes: 115 additions & 17 deletions test/test_key.py
Original file line number Diff line number Diff line change
@@ -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()