diff --git a/poetry.lock b/poetry.lock index 015a59d..8892491 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,17 @@ files = [ [package.extras] tests = ["PyHamcrest (>=2.0.2)", "mypy", "pytest (>=4.6)", "pytest-benchmark", "pytest-cov", "pytest-flake8"] +[[package]] +name = "bech32" +version = "1.2.0" +description = "Reference implementation for Bech32 and segwit addresses." +optional = false +python-versions = ">=3.5" +files = [ + {file = "bech32-1.2.0-py3-none-any.whl", hash = "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"}, + {file = "bech32-1.2.0.tar.gz", hash = "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899"}, +] + [[package]] name = "black" version = "24.4.2" @@ -590,4 +601,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.10, <4.0" -content-hash = "b66d659eca692165648808c2c19fcb238b8eb034f2b24d1c682cc4820bea3672" +content-hash = "eb99dbdca9d75e3392fa4448969845ed9e2f84b8954be02b78d07efde79a1798" diff --git a/pyproject.toml b/pyproject.toml index cf979fd..97703f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ click = "~8.1.3" base58 = "~2.1.1" build = "~1.2.1" ecdsa = "~0.19.0" +bech32 = "^1.2.0" [tool.poetry.group.dev.dependencies] black = "~24.4.2" diff --git a/src/bipsea/apps/__init__.py b/src/bipsea/apps/__init__.py index 5ca04f5..2f612a1 100644 --- a/src/bipsea/apps/__init__.py +++ b/src/bipsea/apps/__init__.py @@ -8,6 +8,7 @@ from bipsea.apps.dice.app import app as dice_app from bipsea.apps.hex.app import app as hex_app from bipsea.apps.mnemonic.app import app as mnemonic_app +from bipsea.apps.nostr.app import app as nostr_app from bipsea.apps.wif.app import app as wif_app from bipsea.apps.xprv.app import app as xprv_app @@ -17,6 +18,7 @@ dice_app.name: dice_app, hex_app.name: hex_app, mnemonic_app.name: mnemonic_app, + nostr_app.name: nostr_app, wif_app.name: wif_app, xprv_app.name: xprv_app, } diff --git a/src/bipsea/apps/nostr/__init__.py b/src/bipsea/apps/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bipsea/apps/nostr/app.py b/src/bipsea/apps/nostr/app.py new file mode 100644 index 0000000..83227a5 --- /dev/null +++ b/src/bipsea/apps/nostr/app.py @@ -0,0 +1,71 @@ +from typing import Any + +from bech32 import bech32_encode, convertbits + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int + + +def nsec_encode(key_bytes: bytes) -> str: + data = convertbits(key_bytes, 8, 5) + return bech32_encode("nsec", data) + + +class NostrApp: + name = "nostr" + code = "9000'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "identity", + ("--identity",), + int, + required=True, + range=(0, None), + help="Identity index (0=proof/revocation key, >=1 usable).", + ), + ] + + def path_segments(self, index: int, identity: int, **_) -> list[str]: + return [f"{identity}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return { + "identity": hardened_int(segments[0]), + "index": hardened_int(segments[1]), + } + + def apply(self, entropy: bytes, **_) -> dict[str, Any]: + key = entropy[:32] + return { + "entropy": key, + "application": nsec_encode(key), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/9000'/1'/1'", + entropy="552ad1d578fe1bc927cec9612651652b07c52dde4017911bc23bc953568075ff", + output="nsec1254dr4tclcdujf7we9sjv5t99vru2tw7gqtezx7z80y4x45qwhlsmxapst", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/9000'/1'/2'", + entropy="4fd36c0061a65db375b4350f44bb62a6d7f716ee93bd0f59887ac50b35fa8b96", + output="nsec1flfkcqrp5ewmxad5x585fwmz5mtlw9hwjw7s7kvg0tzskd063wtq34wlgr", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/9000'/2'/1'", + entropy="b2d3b48992d46f98beac0196c4e258417087e467dbec1503342785368f4402c2", + output="nsec1ktfmfzvj63he304vqxtvfcjcg9cg0er8m0kp2qe5y7zndr6yqtpq7q5y44", + ), + ] + + +app = NostrApp() diff --git a/src/bipsea/bipsea.py b/src/bipsea/bipsea.py index e70d7a7..c8b330b 100644 --- a/src/bipsea/bipsea.py +++ b/src/bipsea/bipsea.py @@ -205,7 +205,13 @@ def xprv(mnemonic, passphrase, mainnet): type=click.Choice(ENTROPY_TO_VALUES), help="Output language for `--application mnemonic`.", ) -def derive_cli(application, number, index, special, xprv, to): +@click.option( + "--identity", + type=click.IntRange(0, 2**31 - 1), + default=None, + help="Nostr identity index (0=proof/revocation key, >=1 usable). Required for --application nostr.", +) +def derive_cli(application, number, index, special, xprv, to, identity): if xprv: xprv = xprv.strip() else: @@ -253,6 +259,24 @@ def derive_cli(application, number, index, special, xprv, to): elif application == "dice": check_range(number, application) path += f"/{special}'/{number}'/{index}'" + elif application == "nostr": + if identity is None: + raise click.UsageError( + "--identity is required for --application nostr." + ) + if identity == 0: + click.secho( + "Warning: identity=0 is reserved as a proof key to link identities together.", + fg="yellow", + err=True, + ) + if index == 0: + click.secho( + f"Warning: index=0 is reserved as the proof key to link accounts for identity {identity}.", + fg="yellow", + err=True, + ) + path += f"/{identity}'/{index}'" derived = derive(master, path) if application == "drng": diff --git a/tests/test_cli.py b/tests/test_cli.py index e6767ff..7c9eb1d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ from bipsea.apps.dice.app import app as dice_app from bipsea.apps.hex.app import app as hex_app from bipsea.apps.mnemonic.app import app as mnemonic_app +from bipsea.apps.nostr.app import app as nostr_app from bipsea.apps.wif.app import app as wif_app from bipsea.bip32types import validate_prv_str from bipsea.bip39 import LANGUAGES, validate_mnemonic_words @@ -449,6 +450,60 @@ def test_commands(self, group, commands): Path(script.name).unlink() +class TestNostr: + def test_path_segments(self): + assert nostr_app.path_segments(index=2, identity=1) == ["1'", "2'"] + + @pytest.mark.parametrize("vector", nostr_app.vectors) + def test_vectors(self, runner, vector): + segments = vector.path.split("/") + identity = int(segments[3].rstrip("'")) + index = int(segments[4].rstrip("'")) + result = runner.invoke( + cli, + [ + "derive", + "-a", "nostr", + "-x", vector.master, + "--identity", identity, + "--index", index, + ], + ) + assert result.exit_code == 0 + assert result.output.strip() == vector.output + + def test_missing_identity(self, runner): + result = runner.invoke( + cli, ["derive", "-a", "nostr", "-x", nostr_app.vectors[0].master] + ) + assert result.exit_code != 0 + assert "identity" in result.output + + def test_identity_zero_warning(self, runner): + result = runner.invoke( + cli, + [ + "derive", "-a", "nostr", + "-x", nostr_app.vectors[0].master, + "--identity", 0, + "--index", 1, + ], + ) + assert "Warning" in result.output + + def test_index_zero_warning(self, runner): + result = runner.invoke( + cli, + [ + "derive", "-a", "nostr", + "-x", nostr_app.vectors[0].master, + "--identity", 1, + "--index", 0, + ], + ) + assert "Warning" in result.output + + class TestCliAdapter: def test_required_param(self): param = Param("length", ("-n", "--length"), int, required=True, help="Length")