Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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]

Expand All @@ -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
Expand Down
18 changes: 11 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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::
Expand All @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
7 changes: 2 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bonded_metals_0u@icloud.com>"]
Expand All @@ -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"
Expand All @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions src/bipsea/app_protocol.py
Original file line number Diff line number Diff line change
@@ -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]: ...
90 changes: 90 additions & 0 deletions src/bipsea/apps/README.md
Original file line number Diff line number Diff line change
@@ -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()
```
22 changes: 22 additions & 0 deletions src/bipsea/apps/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
}
52 changes: 52 additions & 0 deletions src/bipsea/apps/base64/app.py
Original file line number Diff line number Diff line change
@@ -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()
52 changes: 52 additions & 0 deletions src/bipsea/apps/base85/app.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading