diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1466e37..713bcf2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,9 +22,14 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install package run: | - python -m pip install --upgrade pip - python -m pip install --upgrade setuptools + python -m pip install --upgrade build pip setuptools twine python -m pip install . - name: Run test run: | python tests/test_mnemonic.py + - name: Build wheel and sdist + run: | + python -m build + - name: Check long description + run: | + twine check dist/* diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db59742..5bad96f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,17 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +`0.22`_ - Unreleased +-------------------- + +.. _0.22: https://github.com/trezor/python-mnemonic/compare/v0.21...HEAD + +Added +~~~~~ + +- Command-line interface with ``create``, ``check``, and ``to-seed`` commands +- Click as a runtime dependency + `0.21`_ - 2024-01-05 -------------------- diff --git a/README.rst b/README.rst index b2f652e..2e301fd 100644 --- a/README.rst +++ b/README.rst @@ -7,28 +7,31 @@ python-mnemonic Reference implementation of BIP-0039: Mnemonic code for generating deterministic keys +Maintained by `Trezor `_. See the `GitHub repository `_ for source code and issue tracking. + Abstract -------- This BIP describes the implementation of a mnemonic code or mnemonic sentence -- a group of easy to remember words -- for the generation of deterministic wallets. -It consists of two parts: generating the mnenomic, and converting it into a +It consists of two parts: generating the mnemonic, and converting it into a binary seed. This seed can be later used to generate deterministic wallets using BIP-0032 or similar methods. BIP Paper --------- -See https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki -for full specification +See `BIP-0039`_ for the full specification. Installation ------------ To install this library and its dependencies use: - ``pip install mnemonic`` +.. code-block:: sh + + $ pip install mnemonic Usage examples -------------- @@ -75,3 +78,43 @@ Given the word list, calculate original entropy: .. code-block:: python entropy = mnemo.to_entropy(words) + +Command-line interface +---------------------- + +The ``mnemonic`` command provides CLI access to the library: + +.. code-block:: sh + + $ mnemonic create --help + $ mnemonic check --help + $ mnemonic to-seed --help + +Generate a new mnemonic phrase: + +.. code-block:: sh + + $ mnemonic create + $ mnemonic create -s 256 -l english -p "my passphrase" + $ mnemonic create -s 256 -l english + $ mnemonic create -P # prompt for passphrase (hidden input) + $ mnemonic create --hide-seed # only output mnemonic, not the seed + $ MNEMONIC_PASSPHRASE="secret" mnemonic create + +Validate a mnemonic phrase: + +.. code-block:: sh + + $ mnemonic check abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + $ echo "abandon abandon ..." | mnemonic check + +Derive seed from a mnemonic phrase: + +.. code-block:: sh + + $ mnemonic to-seed abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + $ mnemonic to-seed -p "my passphrase" word1 word2 ... + $ mnemonic to-seed -P word1 word2 ... # prompt for passphrase (hidden input) + $ MNEMONIC_PASSPHRASE="secret" mnemonic to-seed word1 word2 ... + +.. _BIP-0039: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki diff --git a/pyproject.toml b/pyproject.toml index d99cb0f..b85db29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,10 @@ include = [ [tool.poetry.dependencies] python = ">=3.8.1" +click = "^8.0" + +[tool.poetry.scripts] +mnemonic = "mnemonic.cli:cli" [tool.poetry.group.dev.dependencies] isort = "^5.13.2" diff --git a/src/mnemonic/cli.py b/src/mnemonic/cli.py new file mode 100644 index 0000000..997bdb6 --- /dev/null +++ b/src/mnemonic/cli.py @@ -0,0 +1,168 @@ +import sys + +import click + +from mnemonic import Mnemonic +from mnemonic.mnemonic import ConfigurationError + + +@click.group() +def cli() -> None: + """BIP-39 mnemonic phrase generator and validator.""" + pass + + +@cli.command() +@click.option( + "-l", + "--language", + default="english", + type=str, + help="Language for the mnemonic wordlist.", +) +@click.option( + "-s", + "--strength", + default="128", + type=click.Choice(["128", "160", "192", "224", "256"]), + help="Entropy strength in bits.", +) +@click.option( + "-p", + "--passphrase", + default="", + envvar="MNEMONIC_PASSPHRASE", + type=str, + help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.", +) +@click.option( + "-P", + "--prompt-passphrase", + is_flag=True, + default=False, + help="Prompt for passphrase with hidden input (secure).", +) +@click.option( + "--hide-seed", + is_flag=True, + default=False, + help="Do not display the derived seed.", +) +def create( + language: str, + passphrase: str, + prompt_passphrase: bool, + strength: str, + hide_seed: bool, +) -> None: + """Generate a new mnemonic phrase and its derived seed.""" + if prompt_passphrase: + if passphrase: + click.secho( + "Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.", + fg="yellow", + err=True, + ) + passphrase = click.prompt("Passphrase", default="", hide_input=True) + try: + mnemo = Mnemonic(language) + words = mnemo.generate(int(strength)) + click.echo(f"Mnemonic: {words}") + if not hide_seed: + seed = mnemo.to_seed(words, passphrase) + click.echo(f"Seed: {seed.hex()}") + except ConfigurationError as e: + raise click.ClickException(str(e)) + + +@cli.command() +@click.option( + "-l", + "--language", + default=None, + type=str, + help="Language for the mnemonic wordlist. Auto-detected if not specified.", +) +@click.argument("words", nargs=-1) +def check(language: str | None, words: tuple[str, ...]) -> None: + """Validate a mnemonic phrase's checksum. + + WORDS can be provided as arguments or piped via stdin. + """ + if words: + mnemonic = " ".join(words) + else: + mnemonic = sys.stdin.read().strip() + + if not mnemonic: + raise click.ClickException("No mnemonic provided.") + + try: + if language is None: + language = Mnemonic.detect_language(mnemonic) + mnemo = Mnemonic(language) + if mnemo.check(mnemonic): + click.secho("Valid mnemonic.", fg="green") + else: + raise click.ClickException("Invalid mnemonic checksum.") + except ConfigurationError as e: + raise click.ClickException(str(e)) + except (ValueError, LookupError) as e: + raise click.ClickException(str(e)) + + +@cli.command("to-seed") +@click.option( + "-p", + "--passphrase", + default="", + envvar="MNEMONIC_PASSPHRASE", + type=str, + help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.", +) +@click.option( + "-P", + "--prompt-passphrase", + is_flag=True, + default=False, + help="Prompt for passphrase with hidden input (secure).", +) +@click.argument("words", nargs=-1) +def to_seed(passphrase: str, prompt_passphrase: bool, words: tuple[str, ...]) -> None: + """Derive a seed from a mnemonic phrase. + + WORDS can be provided as arguments or piped via stdin. + Outputs the 64-byte seed in hexadecimal format. + """ + if words: + mnemonic = " ".join(words) + else: + mnemonic = sys.stdin.read().strip() + + if not mnemonic: + raise click.ClickException("No mnemonic provided.") + + if prompt_passphrase: + if passphrase: + click.secho( + "Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.", + fg="yellow", + err=True, + ) + passphrase = click.prompt("Passphrase", default="", hide_input=True) + + try: + language = Mnemonic.detect_language(mnemonic) + mnemo = Mnemonic(language) + if not mnemo.check(mnemonic): + raise click.ClickException("Invalid mnemonic checksum.") + seed = mnemo.to_seed(mnemonic, passphrase) + click.echo(seed.hex()) + except ConfigurationError as e: + raise click.ClickException(str(e)) + except (ValueError, LookupError) as e: + raise click.ClickException(str(e)) + + +if __name__ == "__main__": + cli() diff --git a/tests/test_mnemonic.py b/tests/test_mnemonic.py index 641cf16..48dd815 100755 --- a/tests/test_mnemonic.py +++ b/tests/test_mnemonic.py @@ -26,7 +26,10 @@ import unittest from typing import List +from click.testing import CliRunner + from mnemonic import Mnemonic +from mnemonic.cli import cli class MnemonicTest(unittest.TestCase): @@ -149,6 +152,139 @@ def test_expand(self) -> None: ) +class CLITest(unittest.TestCase): + def setUp(self) -> None: + self.runner = CliRunner() + + def test_create_generates_valid_mnemonic(self) -> None: + result = self.runner.invoke(cli, ["create"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertIn("Seed:", result.output) + # Extract mnemonic and verify it's valid + mnemonic_line = result.output.split("\n")[0] + mnemonic = mnemonic_line.replace("Mnemonic: ", "") + mnemo = Mnemonic("english") + self.assertTrue(mnemo.check(mnemonic)) + + def test_create_with_strength(self) -> None: + result = self.runner.invoke(cli, ["create", "-s", "256"]) + self.assertEqual(result.exit_code, 0) + mnemonic_line = result.output.split("\n")[0] + mnemonic = mnemonic_line.replace("Mnemonic: ", "") + # 256 bits = 24 words + self.assertEqual(len(mnemonic.split()), 24) + + def test_create_invalid_strength(self) -> None: + result = self.runner.invoke(cli, ["create", "-s", "100"]) + self.assertNotEqual(result.exit_code, 0) + + def test_create_invalid_language(self) -> None: + result = self.runner.invoke(cli, ["create", "-l", "klingon"]) + self.assertEqual(result.exit_code, 1) + self.assertIn("Error", result.output) + + def test_check_valid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_invalid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong" + result = self.runner.invoke(cli, ["check"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + self.assertIn("Invalid mnemonic checksum", result.output) + + def test_check_stdin(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check"], input=mnemonic) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_empty_input(self) -> None: + result = self.runner.invoke(cli, ["check"], input="") + self.assertEqual(result.exit_code, 1) + self.assertIn("No mnemonic provided", result.output) + + def test_to_seed_valid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_with_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed", "-p", "TREZOR"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_with_env_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke( + cli, ["to-seed"] + mnemonic.split(), env={"MNEMONIC_PASSPHRASE": "TREZOR"} + ) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_invalid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong" + result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + self.assertIn("Invalid mnemonic checksum", result.output) + + def test_to_seed_stdin(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed"], input=mnemonic) + self.assertEqual(result.exit_code, 0) + expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + self.assertEqual(result.output.strip(), expected_seed) + + def test_check_with_language_option(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check", "-l", "english"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_with_invalid_language(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check", "-l", "klingon"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + + def test_create_with_prompt_passphrase(self) -> None: + result = self.runner.invoke(cli, ["create", "-P"], input="test_passphrase\n") + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertIn("Seed:", result.output) + + def test_to_seed_with_prompt_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke( + cli, ["to-seed", "-P"] + mnemonic.split(), input="TREZOR\n" + ) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertIn(expected_seed, result.output) + + def test_prompt_passphrase_warning_when_both_set(self) -> None: + result = self.runner.invoke( + cli, + ["create", "-p", "existing", "-P"], + input="new_passphrase\n", + ) + self.assertEqual(result.exit_code, 0) + self.assertIn("Warning", result.output) + + def test_create_hide_seed(self) -> None: + result = self.runner.invoke(cli, ["create", "--hide-seed"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertNotIn("Seed:", result.output) + + def __main__() -> None: unittest.main()