diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 508acc63..caa3201d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: API & CLI Py 3.8 - 3.12 (coverage) +name: API & CLI Py 3.10 - 3.12 (coverage) on: [pull_request] @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - name: checkout repo uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index e4d712e9..84e7afc5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean install test +.PHONY: all build check check-version-sync clean download-lists install install-ci install-dist install-go lint publish push test test-all test-dist test-integration all:: install build @@ -9,11 +9,12 @@ test-all:: poetry run pytest -n auto -m "" test-dist:: clean build install-dist test-integration + # after run `make install-ci` to restore dev deps test-integration:: poetry run pytest "tests/test_cli.py::TestIntegration" -m "" -n auto -push:: lint test-all git-off-main git-no-unsaved +push:: git-off-main git-no-unsaved lint test-all @branch=$$(git symbolic-ref --short HEAD); \ git push origin $$branch @@ -28,7 +29,7 @@ clean:: rm -rf build dist *.egg-info .pytest_cache pip uninstall -y bipsea -publish:: download-lists git-no-unsaved git-on-main test-dist install-ci test +publish:: download-lists git-no-unsaved git-on-main check-version-sync test-dist install-ci test poetry publish install:: install-ci install-go @@ -44,18 +45,21 @@ install-go:: install-dist:: poetry install --without dev -check:: +check:: check-version-sync poetry run black . --check poetry run isort . --check - poetry run flake8 . --ignore=E501,W503 + poetry run flake8 . --ignore=E501,W503,E704 bash -n scripts/*.sh bash -n tests/*.sh +check-version-sync:: + @poetry run python -c "import pathlib,re,sys; p=pathlib.Path('pyproject.toml').read_text(); u=pathlib.Path('src/bipsea/util.py').read_text(); pv=re.search(r'(?m)^version\\s*=\\s*\"([^\"]+)\"', p).group(1); uv=re.search(r'(?m)^__version__\\s*=\\s*\"([^\"]+)\"', u).group(1); sys.exit(0) if pv==uv else sys.exit(f'Version mismatch: pyproject.toml={pv} util.py={uv}')" + lint:: isort . black . actionlint - flake8 . --ignore=E501,W503 + flake8 . --ignore=E501,W503,E704 checkmake Makefile git-off-main:: @@ -73,7 +77,7 @@ git-on-main:: fi git-no-unsaved:: - @if ! git diff --quiet; then \ + @if ! git diff --quiet || ! git diff --cached --quiet; then \ echo "There are unsaved changes in the git repository."; \ exit 1; \ fi diff --git a/README.md b/README.md index 24ebcc97..69fd8a34 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,8 @@ make test ``` See [Makefile](./Makefile) for more commands. +To add a new BIP-85 application, see the +[app developer guide](./src/bipsea/apps/README.md). ## Is the bipsea implementation correct? diff --git a/poetry.lock b/poetry.lock index c7bf27f8..015a59d8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -293,9 +293,6 @@ files = [ {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, ] -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] @@ -592,5 +589,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" -python-versions = ">=3.8.1, <4.0" -content-hash = "efca24e2ccc4d92a447126ce480230beac9d854b32a04d908cc51fcb42aec3c6" +python-versions = ">=3.10, <4.0" +content-hash = "b66d659eca692165648808c2c19fcb238b8eb034f2b24d1c682cc4820bea3672" diff --git a/pyproject.toml b/pyproject.toml index 1104ff9d..cf979fd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "bipsea" -version = "3.0.0" +version = "3.2.0" description = "Composable Python CLI for Bitcoin mnemonics and BIP-85 secrets." readme = "README.md" authors = ["Aneesh Karve "] @@ -27,7 +27,7 @@ classifiers = [ ] [tool.poetry.dependencies] -python = ">=3.8.1, <4.0" +python = ">=3.10, <4.0" click = "~8.1.3" base58 = "~2.1.1" build = "~1.2.1" @@ -37,7 +37,7 @@ ecdsa = "~0.19.0" black = "~24.4.2" flake8 = "~7.0.0" isort = "~5.13.2" -importlib-resources = { version = "^6.4.0", python = "<3.9" } +importlib-resources = "^6.4.0" pycryptodome = "~3.20.0" pytest = "~8.2.1" pytest-xdist = "~3.6.1" diff --git a/src/bipsea/app_protocol.py b/src/bipsea/app_protocol.py new file mode 100644 index 00000000..9cec883e --- /dev/null +++ b/src/bipsea/app_protocol.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Protocol, Tuple, runtime_checkable + + +@dataclass(frozen=True) +class Param: + """App parameter definition. Pure data, no framework coupling.""" + + name: str + flags: tuple[str, ...] + type: type + required: bool = False + default: Any = None + range: Optional[Tuple[Optional[int], Optional[int]]] = None + choices: Optional[list[str]] = None + help: str = "" + role: Optional[str] = None + + +@dataclass(frozen=True) +class TestVector: + master: str + path: str + entropy: str + output: str + + +@runtime_checkable +class BIP85App(Protocol): + name: str + code: str + + @property + def params(self) -> list[Param]: ... + + def path_segments(self, index: int, **kwargs) -> list[str]: ... + + def parse_path(self, segments: list[str]) -> dict[str, Any]: ... + + def apply(self, entropy: bytes, **kwargs) -> dict[str, Any]: ... + + @property + def vectors(self) -> list[TestVector]: ... diff --git a/src/bipsea/apps/README.md b/src/bipsea/apps/README.md new file mode 100644 index 00000000..fa5fd12d --- /dev/null +++ b/src/bipsea/apps/README.md @@ -0,0 +1,90 @@ +# Adding a BIP-85 app + +Each app lives in its own directory under `src/bipsea/apps/` and implements the +`BIP85App` protocol defined in [`app_protocol.py`](../app_protocol.py). + + +## Protocol + +Implement [`BIP85App`](../app_protocol.py). `apply()` must return +`{"entropy": bytes, "application": str}`. + + +## Param + +Params declare the app's CLI options as pure data. The CLI adapter converts +them to Click options automatically. + +```python +Param( + name="length", # kwarg name passed to path_segments() and apply() + flags=("-n", "--length"), + type=int, + required=True, + range=(20, 86), # becomes click.IntRange + help="Password length in characters", + role="number", # "number" = the primary numeric param +) +``` + +- `choices` becomes `click.Choice` +- `range` becomes `click.IntRange` +- `role="number"` identifies the param used for output length (used by range validation) + + +## Steps + +1. Create `src/bipsea/apps/yourapp/app.py` +2. Implement the protocol (see `base64/app.py` for a minimal example) +3. Export a module-level `app` instance +4. Register in `src/bipsea/apps/__init__.py` +5. Add test vectors from the BIP-85 spec +6. Run `make test lint` + + +## Example + +```python +from typing import Any + +from bipsea.app_protocol import Param, TestVector + + +class MyApp: + name = "myapp" + code = "12345'" + + @property + def params(self) -> list[Param]: + return [ + Param("length", ("-n", "--length"), int, required=True, + range=(1, 64), help="Output length", role="number"), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + from bipsea.apps.shared import hardened_int + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + return { + "entropy": entropy, + "application": entropy.hex()[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv...", + path="m/83696968'/12345'/32'/0'", + entropy="abcd...", + output="abcd...", + ), + ] + + +app = MyApp() +``` diff --git a/src/bipsea/apps/__init__.py b/src/bipsea/apps/__init__.py new file mode 100644 index 00000000..5ca04f59 --- /dev/null +++ b/src/bipsea/apps/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Dict + +from bipsea.app_protocol import BIP85App +from bipsea.apps.base64.app import app as base64_app +from bipsea.apps.base85.app import app as base85_app +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.wif.app import app as wif_app +from bipsea.apps.xprv.app import app as xprv_app + +APPS: Dict[str, BIP85App] = { + base64_app.name: base64_app, + base85_app.name: base85_app, + dice_app.name: dice_app, + hex_app.name: hex_app, + mnemonic_app.name: mnemonic_app, + wif_app.name: wif_app, + xprv_app.name: xprv_app, +} diff --git a/src/bipsea/apps/base64/app.py b/src/bipsea/apps/base64/app.py new file mode 100644 index 00000000..47939cc3 --- /dev/null +++ b/src/bipsea/apps/base64/app.py @@ -0,0 +1,52 @@ +import base64 as b64 +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int + + +class Base64App: + name = "base64" + code = "707764'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + ("-n", "--length"), + int, + required=True, + range=(20, 86), + help="Password length in characters", + role="number", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (20 <= length <= 86): + raise ValueError(f"Expected length in [20, 86], got {length}") + return { + "entropy": entropy, + "application": b64.b64encode(entropy).decode("utf-8")[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/707764'/21'/0'", + entropy="74a2e87a9ba0cdd549bdd2f9ea880d554c6c355b08ed25088cfa88f3f1c4f74632b652fd4a8f5fda43074c6f6964a3753b08bb5210c8f5e75c07a4c2a20bf6e9", + output="dKLoepugzdVJvdL56ogNV", + ), + ] + + +app = Base64App() diff --git a/src/bipsea/apps/base85/app.py b/src/bipsea/apps/base85/app.py new file mode 100644 index 00000000..50d36d08 --- /dev/null +++ b/src/bipsea/apps/base85/app.py @@ -0,0 +1,52 @@ +import base64 as b64 +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int + + +class Base85App: + name = "base85" + code = "707785'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + ("-n", "--length"), + int, + required=True, + range=(10, 80), + help="Password length in characters", + role="number", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (10 <= length <= 80): + raise ValueError(f"Expected length in [10, 80], got {length}") + return { + "entropy": entropy, + "application": b64.b85encode(entropy).decode("utf-8")[:length], + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/707785'/12'/0'", + entropy="f7cfe56f63dca2490f65fcbf9ee63dcd85d18f751b6b5e1c1b8733af6459c904a75e82b4a22efff9b9e69de2144b293aa8714319a054b6cb55826a8e51425209", + output="_s`{TW89)i4`", + ), + ] + + +app = Base85App() diff --git a/src/bipsea/apps/dice/app.py b/src/bipsea/apps/dice/app.py new file mode 100644 index 00000000..932fffb0 --- /dev/null +++ b/src/bipsea/apps/dice/app.py @@ -0,0 +1,77 @@ +import math +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int +from bipsea.drng import DRNG + + +class DiceApp: + name = "dice" + code = "89101'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "rolls", + ("-n", "--rolls"), + int, + required=True, + range=(1, 10_000), + help="Number of rolls", + role="number", + ), + Param( + "sides", + ("-s", "--sides"), + int, + default=6, + range=(2, None), + help="Number of sides on die", + ), + ] + + def path_segments(self, index: int, rolls: int, sides: int = 6, **_) -> list[str]: + return [f"{sides}'", f"{rolls}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return { + "sides": hardened_int(segments[0]), + "rolls": hardened_int(segments[1]), + } + + def apply(self, entropy: bytes, rolls: int, sides: int = 6, **_) -> dict[str, Any]: + return { + "entropy": entropy, + "application": self._do_rolls(entropy, sides, rolls), + } + + def _do_rolls(self, entropy: bytes, sides: int, rolls: int) -> str: + max_width = len(str(sides - 1)) + history = [] + bits_per_roll = math.ceil(math.log(sides, 2)) + bytes_per_roll = math.ceil(bits_per_roll / 8) + drng = DRNG(entropy) + while len(history) < rolls: + trial_int = int.from_bytes(drng.read(bytes_per_roll), "big") + available_bits = 8 * bytes_per_roll + excess_bits = available_bits - bits_per_roll + trial_int >>= excess_bits + if trial_int < sides: + history.append(f"{trial_int:0{max_width}d}") + return ",".join(history) + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/89101'/6'/10'/0'", + entropy="5e41f8f5d5d9ac09a20b8a5797a3172b28c806aead00d27e36609e2dd116a59176a738804236586f668da8a51b90c708a4226d7f92259c69f64c51124b6f6cd2", + output="1,0,0,2,0,1,5,5,2,4", + ), + ] + + +app = DiceApp() diff --git a/src/bipsea/apps/drng/app.py b/src/bipsea/apps/drng/app.py new file mode 100644 index 00000000..cd89eacc --- /dev/null +++ b/src/bipsea/apps/drng/app.py @@ -0,0 +1,50 @@ +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.drng import DRNG +from bipsea.util import to_hex_string + + +class DrngApp: + name = "drng" + code = "0'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + ("-n", "--length"), + int, + required=True, + help="Number of bytes to generate", + role="number", + ), + ] + + def path_segments(self, index: int, **_) -> list[str]: + return ["0'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + drng = DRNG(entropy) + return { + "entropy": entropy, + "application": to_hex_string(drng.read(length)), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/0'/0'/0'", + entropy="efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7", + output="b78b1ee6b345eae6836c2d53d33c64cdaf9a696487be81b03e822dc84b3f1cd883d7559e53d175f243e4c349e822a957bbff9224bc5dde9492ef54e8a439f6bc8c7355b87a925a37ee405a7502991111", + ), + ] + + +app = DrngApp() diff --git a/src/bipsea/apps/hex/app.py b/src/bipsea/apps/hex/app.py new file mode 100644 index 00000000..eaa30e10 --- /dev/null +++ b/src/bipsea/apps/hex/app.py @@ -0,0 +1,52 @@ +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int +from bipsea.util import to_hex_string + + +class HexApp: + name = "hex" + code = "128169'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "length", + ("-n", "--length"), + int, + required=True, + range=(16, 64), + help="Output length in bytes", + role="number", + ), + ] + + def path_segments(self, index: int, length: int, **_) -> list[str]: + return [f"{length}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {"length": hardened_int(segments[0])} + + def apply(self, entropy: bytes, length: int, **_) -> dict[str, Any]: + if not (16 <= length <= 64): + raise ValueError(f"Expected length in [16, 64], got {length}") + return { + "entropy": entropy, + "application": to_hex_string(entropy[:length]), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/128169'/64'/0'", + entropy="492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c", + output="492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e71aa2d645442f878555d078fd1f1f67e368976f04137b1f7a0d19232136ca50c44614af72b5582a5c", + ), + ] + + +app = HexApp() diff --git a/src/bipsea/apps/mnemonic/app.py b/src/bipsea/apps/mnemonic/app.py new file mode 100644 index 00000000..2237ffa8 --- /dev/null +++ b/src/bipsea/apps/mnemonic/app.py @@ -0,0 +1,106 @@ +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.apps.shared import hardened_int +from bipsea.bip39 import ( + LANGUAGES, + N_WORDS_META, + entropy_to_words, + validate_mnemonic_words, +) + +INDEX_TO_LANGUAGE = { + "0'": "english", + "1'": "japanese", + "2'": "korean", + "3'": "spanish", + "4'": "chinese_simplified", + "5'": "chinese_traditional", + "6'": "french", + "7'": "italian", + "8'": "czech", + "9'": "portuguese", # not in BIP-85 but in BIP-39 test vectors +} + +LANGUAGE_TO_INDEX = {v: k for k, v in INDEX_TO_LANGUAGE.items()} + +assert set(INDEX_TO_LANGUAGE.values()) == set(LANGUAGES.keys()) + + +class MnemonicApp: + name = "mnemonic" + code = "39'" + + @property + def params(self) -> list[Param]: + return [ + Param( + "n_words", + ("-n", "--words"), + int, + required=True, + choices=[str(n) for n in N_WORDS_META.keys()], + help="Number of mnemonic words", + role="number", + ), + Param( + "language", + ("-t", "--language"), + str, + default="english", + choices=list(LANGUAGES.keys()), + help="Output language", + ), + ] + + def path_segments( + self, index: int, n_words: int, language: str = "english", **_ + ) -> list[str]: + lang_code = LANGUAGE_TO_INDEX[language] + return [lang_code, f"{n_words}'", f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return { + "language": INDEX_TO_LANGUAGE[segments[0]], + "n_words": hardened_int(segments[1]), + } + + def apply( + self, entropy: bytes, n_words: int, language: str = "english", **_ + ) -> dict[str, Any]: + if n_words not in N_WORDS_META: + raise ValueError(f"Unsupported number of words: {n_words}") + n_bytes = N_WORDS_META[n_words]["entropy_bits"] // 8 + trimmed = entropy[:n_bytes] + words = entropy_to_words(n_words, trimmed, language) + assert validate_mnemonic_words(words, language) + return { + "entropy": trimmed, + "application": " ".join(words), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/12'/0'", + entropy="6250b68daf746d12a24d58b4787a714b", + output="girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/18'/0'", + entropy="938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + output="near account window bike charge season chef number sketch tomorrow excuse sniff circle vital hockey outdoor supply token", + ), + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/39'/0'/24'/0'", + entropy="ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f", + output="puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano", + ), + ] + + +app = MnemonicApp() diff --git a/src/bipsea/apps/shared.py b/src/bipsea/apps/shared.py new file mode 100644 index 00000000..e7bd26a8 --- /dev/null +++ b/src/bipsea/apps/shared.py @@ -0,0 +1,4 @@ +def hardened_int(segment: str) -> int: + if not segment.endswith("'"): + raise ValueError(f"Expected hardened segment, got {segment}") + return int(segment[:-1]) diff --git a/src/bipsea/apps/wif/app.py b/src/bipsea/apps/wif/app.py new file mode 100644 index 00000000..ff52c033 --- /dev/null +++ b/src/bipsea/apps/wif/app.py @@ -0,0 +1,44 @@ +from typing import Any + +import base58 + +from bipsea.app_protocol import Param, TestVector + + +class WifApp: + name = "wif" + code = "2'" + + @property + def params(self) -> list[Param]: + return [] + + def path_segments(self, index: int, **_) -> list[str]: + return [f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, network: str = "mainnet", **_) -> dict[str, Any]: + trimmed = entropy[:32] + prefix = b"\x80" if network == "mainnet" else b"\xef" + suffix = b"\x01" # use with compressed public keys because BIP-32 + extended = prefix + trimmed + suffix + return { + "entropy": trimmed, + "application": base58.b58encode_check(extended).decode("utf-8"), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/2'/0'", + entropy="7040bb53104f27367f317558e78a994ada7296c6fde36a364e5baf206e502bb1", + output="Kzyv4uF39d4Jrw2W7UryTHwZr1zQVNk4dAFyqE6BuMrMh1Za7uhp", + ), + ] + + +app = WifApp() diff --git a/src/bipsea/apps/xprv/app.py b/src/bipsea/apps/xprv/app.py new file mode 100644 index 00000000..0ec26659 --- /dev/null +++ b/src/bipsea/apps/xprv/app.py @@ -0,0 +1,47 @@ +from typing import Any + +from bipsea.app_protocol import Param, TestVector +from bipsea.bip32 import VERSIONS, ExtendedKey + + +class XprvApp: + name = "xprv" + code = "32'" + + @property + def params(self) -> list[Param]: + return [] + + def path_segments(self, index: int, **_) -> list[str]: + return [f"{index}'"] + + def parse_path(self, segments: list[str]) -> dict[str, Any]: + return {} + + def apply(self, entropy: bytes, **_) -> dict[str, Any]: + derived_key = ExtendedKey( + version=VERSIONS["mainnet"]["private"], + depth=bytes(1), + finger=bytes(4), + child_number=bytes(4), + chain_code=entropy[:32], + data=bytes(1) + entropy[32:], + ) + return { + "entropy": entropy[32:], + "application": str(derived_key), + } + + @property + def vectors(self) -> list[TestVector]: + return [ + TestVector( + master="xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + path="m/83696968'/32'/0'", + entropy="ead0b33988a616cf6a497f1c169d9e92562604e38305ccd3fc96f2252c177682", + output="xprv9s21ZrQH143K2srSbCSg4m4kLvPMzcWydgmKEnMmoZUurYuBuYG46c6P71UGXMzmriLzCCBvKQWBUv3vPB3m1SATMhp3uEjXHJ42jFg7myX", + ), + ] + + +app = XprvApp() diff --git a/src/bipsea/bip39.py b/src/bipsea/bip39.py index e031b9be..f6830341 100644 --- a/src/bipsea/bip39.py +++ b/src/bipsea/bip39.py @@ -5,12 +5,7 @@ import secrets import warnings from hashlib import pbkdf2_hmac - -try: # pragma: no cover - from importlib.resources import files -except ImportError: # pragma: no cover - from importlib_resources import files # for Python 3.8 - +from importlib.resources import files from typing import List from unicodedata import normalize diff --git a/src/bipsea/bip85.py b/src/bipsea/bip85.py index f637fb61..aef73f6b 100644 --- a/src/bipsea/bip85.py +++ b/src/bipsea/bip85.py @@ -1,57 +1,49 @@ -import base64 -import hashlib import logging import math import re from typing import Dict, Union -import base58 - -from .bip32 import VERSIONS, ExtendedKey +from .apps import APPS +from .apps.mnemonic.app import INDEX_TO_LANGUAGE +from .bip32 import ExtendedKey from .bip32 import derive_key as derive_key_bip32 from .bip32 import hmac_sha512 -from .bip39 import LANGUAGES, N_WORDS_META, entropy_to_words, validate_mnemonic_words -from .util import LOGGER_NAME, to_hex_string +from .constants import HMAC_KEY, PURPOSE_CODES +from .drng import DRNG +from .util import LOGGER_NAME logger = logging.getLogger(LOGGER_NAME) +__all__ = [ + "APPLICATIONS", + "RANGES", + "PURPOSE_CODES", + "HMAC_KEY", + "INDEX_TO_LANGUAGE", + "DRNG", + "apply_85", + "to_entropy", + "derive", + "split_and_validate", + "do_rolls", +] + + +APPLICATIONS = {app.name: app.code for app in APPS.values()} +APPLICATIONS["drng"] = "0'" + + +def _number_range(app): + """Extract range from the param with role='number', if any.""" + for p in app.params: + if p.role == "number" and p.range is not None: + return p.range + return None + + +RANGES = {name: rng for name, app in APPS.items() if (rng := _number_range(app))} -APPLICATIONS = { - "base64": "707764'", - "base85": "707785'", - "dice": "89101'", - "drng": "0'", - "hex": "128169'", - "mnemonic": "39'", - "wif": "2'", - "xprv": "32'", -} - -RANGES = { - "base64": (20, 86), - "base85": (10, 80), - "hex": (16, 64), - "dice": (1, 10_000), -} - -PURPOSE_CODES = {"BIP-85": "83696968'"} - -HMAC_KEY = b"bip-entropy-from-k" - -INDEX_TO_LANGUAGE = { - "0'": "english", - "1'": "japanese", - "2'": "korean", - "3'": "spanish", - "4'": "chinese_simplified", - "5'": "chinese_traditional", - "6'": "french", - "7'": "italian", - "8'": "czech", - "9'": "portuguese", # not in BIP-85 but in BIP-39 test vectors -} - -assert set(INDEX_TO_LANGUAGE.values()) == set(LANGUAGES.keys()) +CODE_TO_APP = {app.code: app for app in APPS.values()} def apply_85(derived_key: ExtendedKey, path: str) -> Dict[str, Union[bytes, str]]: @@ -64,81 +56,20 @@ def apply_85(derived_key: ExtendedKey, path: str) -> Dict[str, Union[bytes, str] raise ValueError( f"Paths should have 4+ segments, all hardened children: {path}" ) - app, *indexes = segments[2:] + app_code = segments[2] + app_segments = segments[3:] + if app_code not in CODE_TO_APP: + raise NotImplementedError(f"Unsupported BIP-85 application {app_code}") + + app = CODE_TO_APP[app_code] entropy = to_entropy(derived_key.data[1:]) + kwargs = app.parse_path(app_segments) - if app == APPLICATIONS["mnemonic"]: - language_index, n_words = indexes[:2] - n_words = int(n_words.rstrip("'")) - if n_words not in N_WORDS_META.keys(): - raise ValueError(f"Unsupported number of words: {n_words}.") - language = INDEX_TO_LANGUAGE[language_index] - n_bytes = N_WORDS_META[n_words]["entropy_bits"] // 8 - trimmed_entropy = entropy[:n_bytes] - words = entropy_to_words(n_words, trimmed_entropy, language) - assert validate_mnemonic_words(words, language) - - return { - "entropy": trimmed_entropy, - "application": " ".join(words), - } - elif app == APPLICATIONS["wif"]: - trimmed_entropy = entropy[: 256 // 8] - prefix = b"\x80" if derived_key.get_network() == "mainnet" else b"\xef" - suffix = b"\x01" # use with compressed public keys because BIP-32 - extended = prefix + trimmed_entropy + suffix - - return { - "entropy": trimmed_entropy, - "application": base58.b58encode_check(extended).decode("utf-8"), - } - elif app == APPLICATIONS["xprv"]: - derived_key = ExtendedKey( - version=VERSIONS["mainnet"]["private"], - depth=bytes(1), - finger=bytes(4), - child_number=bytes(4), - chain_code=entropy[:32], - data=bytes(1) + entropy[32:], - ) + if app.name == "wif": + kwargs["network"] = derived_key.get_network() - return { - "entropy": entropy[32:], - "application": str(derived_key), - } - elif app == APPLICATIONS["hex"]: - num_bytes = int(indexes[0].rstrip("'")) - if not (16 <= num_bytes <= 64): - raise ValueError(f"Expected num_bytes in [16, 64], got {num_bytes}") - - return {"entropy": entropy, "application": to_hex_string(entropy[:num_bytes])} - elif app == APPLICATIONS["base64"]: - pwd_len = int(indexes[0].rstrip("'")) - if not (20 <= pwd_len <= 86): - raise ValueError(f"Expected pwd_len in [20, 86], got {pwd_len}") - - return { - "entropy": entropy, - "application": base64.b64encode(entropy).decode("utf-8")[:pwd_len], - } - elif app == APPLICATIONS["base85"]: - pwd_len = int(indexes[0].rstrip("'")) - if not (10 <= pwd_len <= 80): - raise ValueError("Expected pwd_len in [10, 80], got {pwd_len}") - - return { - "entropy": entropy, - "application": base64.b85encode(entropy).decode("utf-8")[:pwd_len], - } - elif app == APPLICATIONS["dice"]: - sides, rolls, index = (int(s.rstrip("'")) for s in indexes[:3]) - return { - "entropy": entropy, - "application": do_rolls(entropy, sides, rolls, index), - } - else: - raise NotImplementedError(f"Unsupported BIP-85 application {app}") + return app.apply(entropy, **kwargs) def to_entropy(data: bytes) -> bytes: @@ -152,20 +83,6 @@ def derive(master: ExtendedKey, path: str, private: bool = True) -> ExtendedKey: return derive_key_bip32(master, split_and_validate(path), private) -class DRNG: - def __init__(self, seed: bytes): - if len(seed) != 64: - raise ValueError("Seed must be exactly 64 bytes long") - self.shake = hashlib.shake_256(seed) - self.cursor = 0 - - def read(self, n: int) -> bytes: - start = self.cursor - self.cursor = stop = start + n - - return self.shake.digest(stop)[start:stop] - - def split_and_validate(path: str): segments = path.split("/") if segments[0] != "m": diff --git a/src/bipsea/cli_adapter.py b/src/bipsea/cli_adapter.py new file mode 100644 index 00000000..62efac02 --- /dev/null +++ b/src/bipsea/cli_adapter.py @@ -0,0 +1,31 @@ +"""Adapter functions to convert protocol types to Click constructs.""" + +from typing import Any + +import click + +from .app_protocol import Param + + +def param_to_click_option(param: Param) -> tuple[tuple[str, ...], dict[str, Any]]: + """Convert a Param to click.option arguments. + + Returns: + (flags, kwargs) tuple for use with click.option(*flags, **kwargs) + """ + kwargs: dict[str, Any] = {"help": param.help} + + if param.required: + kwargs["required"] = True + if param.default is not None: + kwargs["default"] = param.default + + # Type resolution: choices > range > raw type + if param.choices is not None: + kwargs["type"] = click.Choice(param.choices) + elif param.range is not None: + kwargs["type"] = click.IntRange(*param.range) + else: + kwargs["type"] = param.type + + return param.flags, kwargs diff --git a/src/bipsea/constants.py b/src/bipsea/constants.py new file mode 100644 index 00000000..402cbf79 --- /dev/null +++ b/src/bipsea/constants.py @@ -0,0 +1,3 @@ +PURPOSE_CODES = {"BIP-85": "83696968'"} + +HMAC_KEY = b"bip-entropy-from-k" diff --git a/src/bipsea/drng.py b/src/bipsea/drng.py new file mode 100644 index 00000000..3b7bb686 --- /dev/null +++ b/src/bipsea/drng.py @@ -0,0 +1,14 @@ +import hashlib + + +class DRNG: + def __init__(self, seed: bytes): + if len(seed) != 64: + raise ValueError("Seed must be exactly 64 bytes long") + self.shake = hashlib.shake_256(seed) + self.cursor = 0 + + def read(self, n: int) -> bytes: + start = self.cursor + self.cursor = stop = start + n + return self.shake.digest(stop)[start:stop] diff --git a/src/bipsea/registry.py b/src/bipsea/registry.py new file mode 100644 index 00000000..0f97b4b4 --- /dev/null +++ b/src/bipsea/registry.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Dict + +from .app_protocol import BIP85App +from .apps import APPS + + +def get_apps() -> Dict[str, BIP85App]: + return APPS + + +def get_app(name: str) -> BIP85App: + if name not in APPS: + raise ValueError(f"Unknown app: {name}") + return APPS[name] diff --git a/src/bipsea/util.py b/src/bipsea/util.py index 337111c6..d682299e 100644 --- a/src/bipsea/util.py +++ b/src/bipsea/util.py @@ -13,7 +13,7 @@ MIN_REL_ENTROPY = 0.50 # somewhat magic heuristic __app_name__ = "bipsea" -__version__ = "2.0.1" +__version__ = "3.2.0" LOGGER_NAME = __app_name__ diff --git a/tests/test_bip85.py b/tests/test_bip85.py index 0412e455..fece2ad4 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -5,17 +5,7 @@ import base58 import pytest from Crypto.PublicKey import RSA -from data.bip85_vectors import ( - BIP_39, - COMMON_XPRV, - DICE, - EXT_KEY_TO_ENTROPY, - HEX, - PWD_BASE64, - PWD_BASE85, - WIF, - XPRV, -) +from data.bip85_vectors import COMMON_XPRV, EXT_KEY_TO_ENTROPY, PWD_BASE64 from bipsea.bip32types import parse_ext_key from bipsea.bip39 import LANGUAGES, validate_mnemonic_words @@ -28,6 +18,7 @@ split_and_validate, to_entropy, ) +from bipsea.registry import APPS from bipsea.util import LOGGER_NAME, to_hex_string logger = logging.getLogger(LOGGER_NAME) @@ -72,41 +63,26 @@ def test_pwd_base64_entropy(vector): assert vector["derived_entropy"] == to_hex_string(output["entropy"]) -@pytest.mark.parametrize("vector", PWD_BASE85) -def test_pwd_base85(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_pwd"] == output["application"] - assert vector["derived_entropy"] == to_hex_string(output["entropy"]) +def _all_app_vectors(): + for app in APPS.values(): + for v in app.vectors: + yield pytest.param(app, v, id=f"{app.name}-{v.path}") -@pytest.mark.parametrize( - "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}" for v in BIP_39], -) -def test_mnemonic(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] +@pytest.mark.parametrize("app,vector", list(_all_app_vectors())) +def test_app_vectors(app, vector): + master = parse_ext_key(vector.master) + path = vector.path output = apply_85(derive(master, path), path) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - words = output["application"].split(" ") - assert len(words) == vector["mnemonic_length"] - assert output["application"] == vector["derived_mnemonic"] - assert validate_mnemonic_words(words, "english") + assert to_hex_string(output["entropy"]) == vector.entropy + assert output["application"] == vector.output @pytest.mark.filterwarnings("ignore:.*184 bits") @pytest.mark.parametrize("lang", LANGUAGES, ids=[lang for lang in LANGUAGES]) -@pytest.mark.parametrize( - "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}" for v in BIP_39], -) -def test_mnemonic_languages(vector, lang): - n_words = vector["mnemonic_length"] - master = parse_ext_key(vector["master"]) +@pytest.mark.parametrize("n_words", (12, 18, 24)) +def test_mnemonic_languages(n_words, lang): + master = parse_ext_key(COMMON_XPRV) code = next(k for k, v in INDEX_TO_LANGUAGE.items() if v == lang) path = f"m/83696968'/39'/{code}/{n_words}'" output = apply_85(derive(master, path), path) @@ -114,14 +90,6 @@ def test_mnemonic_languages(vector, lang): assert validate_mnemonic_words(words, lang) -@pytest.mark.parametrize("vector", HEX) -def test_hex(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_entropy"] == output["application"] - - @pytest.mark.slow @pytest.mark.parametrize("key_bits", [1024]) def test_rsa(key_bits): @@ -141,24 +109,6 @@ def test_rsa(key_bits): assert data[0] != data[1] -@pytest.mark.parametrize("vector", WIF) -def test_wif(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - assert output["application"] == vector["derived_wif"] - - -@pytest.mark.parametrize("vector", XPRV) -def test_xprv(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - assert vector["derived_key"] == output["application"] - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - - def test_private_key_to_wif(): """follow the procedure from https://en.bitcoin.it/wiki/Wallet_import_format""" @@ -175,19 +125,6 @@ def test_private_key_to_wif(): assert wif == base58.b58encode_check(extended) -@pytest.mark.parametrize("vector", DICE) -def test_dice(vector): - master = parse_ext_key(vector["master"]) - path = vector["path"] - output = apply_85(derive(master, path), path) - rolls = output["application"] - assert rolls == vector["derived_rolls"] - rolls_int = [int(r) for r in rolls.split(",")] - assert len(rolls_int) == 10 - assert all(0 <= r < 10 for r in rolls_int) - assert to_hex_string(output["entropy"]) == vector["derived_entropy"] - - @pytest.mark.parametrize( "path, works", [ diff --git a/tests/test_cli.py b/tests/test_cli.py index f71d4376..e6767ffc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,21 +7,22 @@ from pathlib import Path import pytest +from click import Choice, IntRange from click.testing import CliRunner from data.bip39_vectors import VECTORS -from data.bip85_vectors import ( - BIP_39, - COMMON_XPRV, - DICE, - HEX, - PWD_BASE64, - PWD_BASE85, - WIF, -) - +from data.bip85_vectors import COMMON_XPRV + +from bipsea.app_protocol import Param +from bipsea.apps.base64.app import app as base64_app +from bipsea.apps.base85.app import app as base85_app +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.wif.app import app as wif_app from bipsea.bip32types import validate_prv_str from bipsea.bip39 import LANGUAGES, validate_mnemonic_words from bipsea.bipsea import ISO_TO_LANGUAGE, N_WORDS_ALLOWED, cli, try_for_pipe_input +from bipsea.cli_adapter import param_to_click_option from bipsea.util import ASCII_INPUTS, LOGGER_NAME logger = logging.getLogger(LOGGER_NAME) @@ -218,18 +219,19 @@ def test_bad_n(self, runner, app, n): @pytest.mark.parametrize( "vector", - BIP_39, - ids=[f"BIP_39-{v['mnemonic_length']}-words" for v in BIP_39], + mnemonic_app.vectors, + ids=[f"mnemonic-{v.path}" for v in mnemonic_app.vectors], ) def test_mnemonic(self, runner, vector): - xprv = vector["master"] - n_words = vector["mnemonic_length"] + xprv = vector.master + segments = vector.path.split("/") + n_words = int(segments[4].rstrip("'")) result = runner.invoke( cli, ["derive", "-a", "mnemonic", "--xprv", xprv, "-n", n_words] ) assert result.exit_code == 0 words = result.output.strip() - assert words == vector["derived_mnemonic"] + assert words == vector.output @pytest.mark.parametrize("iso", [v["code"] for v in LANGUAGES.values()]) def test_mnemonic_languages(self, runner, iso): @@ -241,42 +243,55 @@ def test_mnemonic_languages(self, runner, iso): words = result.output.strip() assert validate_mnemonic_words(words.split(" "), ISO_TO_LANGUAGE[iso]) - @pytest.mark.parametrize("vector", DICE) + @pytest.mark.parametrize("vector", dice_app.vectors) def test_dice(self, runner, vector): - xprv = vector["master"] + xprv = vector.master + segments = vector.path.split("/") + sides = int(segments[3].rstrip("'")) + rolls = int(segments[4].rstrip("'")) result = runner.invoke( - cli, ["derive", "-a", "dice", "-x", xprv, "-n", 10, "-s", 6] + cli, ["derive", "-a", "dice", "-x", xprv, "-n", rolls, "-s", sides] ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_rolls"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", HEX) + @pytest.mark.parametrize("vector", hex_app.vectors) def test_hex(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "hex", "-x", xprv, "-n", 64]) + xprv = vector.master + segments = vector.path.split("/") + n_bytes = int(segments[3].rstrip("'")) + result = runner.invoke(cli, ["derive", "-a", "hex", "-x", xprv, "-n", n_bytes]) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_entropy"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", WIF) + @pytest.mark.parametrize("vector", wif_app.vectors) def test_wif(self, runner, vector): - xprv = vector["master"] + xprv = vector.master result = runner.invoke(cli, ["derive", "-a", "wif", "-x", xprv]) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_wif"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", PWD_BASE64) + @pytest.mark.parametrize("vector", base64_app.vectors) def test_base64(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "base64", "-x", xprv, "-n", 21]) + xprv = vector.master + segments = vector.path.split("/") + length = int(segments[3].rstrip("'")) + result = runner.invoke( + cli, ["derive", "-a", "base64", "-x", xprv, "-n", length] + ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_pwd"] + assert result.output.strip() == vector.output - @pytest.mark.parametrize("vector", PWD_BASE85) + @pytest.mark.parametrize("vector", base85_app.vectors) def test_base85(self, runner, vector): - xprv = vector["master"] - result = runner.invoke(cli, ["derive", "-a", "base85", "-x", xprv, "-n", 12]) + xprv = vector.master + segments = vector.path.split("/") + length = int(segments[3].rstrip("'")) + result = runner.invoke( + cli, ["derive", "-a", "base85", "-x", xprv, "-n", length] + ) assert result.exit_code == 0 - assert result.output.strip() == vector["derived_pwd"] + assert result.output.strip() == vector.output @pytest.mark.parametrize("app", ("wif", "xprv")) def test_num_not_allowed(self, runner, app): @@ -432,3 +447,37 @@ def test_commands(self, group, commands): ) finally: Path(script.name).unlink() + + +class TestCliAdapter: + def test_required_param(self): + param = Param("length", ("-n", "--length"), int, required=True, help="Length") + flags, kwargs = param_to_click_option(param) + assert flags == ("-n", "--length") + assert kwargs == {"help": "Length", "required": True, "type": int} + + def test_param_with_default(self): + param = Param("sides", ("-s", "--sides"), int, default=6, help="Die sides") + flags, kwargs = param_to_click_option(param) + assert kwargs["default"] == 6 + assert "required" not in kwargs + + def test_param_with_range(self): + param = Param("bytes", ("-b",), int, range=(16, 64), help="Bytes") + _, kwargs = param_to_click_option(param) + assert isinstance(kwargs["type"], IntRange) + assert kwargs["type"].min == 16 + assert kwargs["type"].max == 64 + + def test_param_with_choices(self): + param = Param("fmt", ("-f",), str, choices=["json", "csv"], help="Format") + _, kwargs = param_to_click_option(param) + assert isinstance(kwargs["type"], Choice) + assert list(kwargs["type"].choices) == ["json", "csv"] + + def test_real_app_params(self): + for app in (base64_app, dice_app, mnemonic_app): + for param in app.params: + flags, kwargs = param_to_click_option(param) + assert flags == param.flags + assert "type" in kwargs