diff --git a/.gitignore b/.gitignore index 2ce7afe..7ef15d3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ build dist .coverage +.vscode diff --git a/README.md b/README.md index 24ebcc9..4bb7648 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ bipsea offers four commands that work together: 1. `mnemonic` creates BIP-39 seed mnemonics in 9 languages 1. `validate` validates BIP-39 in 9 languages +1. `codex32` creates BIP-93 codex32 backups +1. `recover` validates BIP-93 strings and recovers seed 1. `xprv` derives a BIP-32 extended private key 1. `derive` applies BIP-85 to an xprv to derive child secrets @@ -122,6 +124,37 @@ bipsea mnemonic -t spa -n 12 | bipsea validate -f spa relleno peón exilio vara grave hora boda terapia dinero vulgar vulgar goloso +## `codex32` + +Suppose you want a 3-of-5 codex32 backup. + +```sh +bipsea codex32 +``` + ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm ms13casheekgpemxzshcrmqhaydlp6yhms3ws7320xyxsar9 ms13cashf8jh6sdrkpyrsp5ut94pj8ktehhw2hfvyrj48704 + +Or a 2-of-3 with identifier 'NAME'. + +```sh +bipsea codex32 -t2 -n3 -i'NAME' --pretty +``` + MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM + MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN + MS12NAMEDLL4F8JLH4E5VDVULDLFXU2JHDNLSM97XVENRXEG + + +## `bipsea recover` + +BIP-93 codex32 backups contain a 48-124 characters, and include a checksum. +`recover` checks the integrity of a codex32 string or set of shares, recovers the codex32 secret, +then echoes the result so that you can pipe it to `bipsea xprv`. + +```sh +echo "MS12NAMEDLL4F8JLH4E5VDVULDLFXU2JHDNLSM97XVENRXEG MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM" | bipsea recover +``` + MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW + + ## `bipsea xprv` ```sh @@ -129,13 +162,13 @@ bipsea mnemonic | bipsea validate | bipsea xprv ``` xprv9s21ZrQH143K41bKPQ9XHbPoqfdCDmZLBorYHay5E273HTu5yAFm27sSWRoCpisgQNH9vfrL9yVvVg5rBEbMCk2UwQ8K7qCFnZAY7aXhuqV -`bipsea xprv` converts a mnemonic into a master node (the root of your wallet +`bipsea xprv` converts a mnemonic or codex32 secret into a master node (the root of your wallet chain) that serializes as an xprv or _extended private key_. ### xprv from dice rolls (or any string) -``` +```sh bipsea validate -f free -m "123456123456123456" | bipsea xprv ``` Warning: Relative entropy of input seems low (0.42). Consider a more complex --mnemonic. @@ -174,7 +207,7 @@ Below are several applications. ### base85 passwords -``` +```sh bipsea validate -m $MNEMONIC | bipsea xprv | bipsea derive -a base85 ``` iu?42{I|2Ct{39IpEP5zBn=0 @@ -185,7 +218,7 @@ we get `-n 20` characters of a base85 password. ### mnemonic phrases -``` +```sh bipsea validate -m "$MNEMONIC" | bipsea xprv | bipsea derive -a mnemonic -t jpn -n 12 ``` ちこく へいおん ふくざつ ゆらい あたりまえ けんか らくがき ずほう みじかい たんご いそうろう えいきょう @@ -193,6 +226,30 @@ bipsea validate -m "$MNEMONIC" | bipsea xprv | bipsea derive -a mnemonic -t jpn As with all applications, you can change the child index from it's default of zero to get a fresh, repeatable secret. +### codex32 strings +``` +bipsea validate -m "$MNEMONIC" | bipsea xprv | bipsea derive -a codex32 -t 3 -id cash +``` + ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm + +As with all applications, you can change the child index from it's default of zero +to get a fresh, repeatable secret. Note this increments the `--identifier` as it +should be unique for each secret. + +### codex32 strings + +```sh +bipsea validate -m "$MNEMONIC" | bipsea xprv | bipsea derive -a codex32 -t 3 +``` + ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm + +The output will always be the first threshold t initial shares, or a codex32 secret if `-t 0` these may be passed to `codex32` to generate a backup of -n shares. + +```sh +bipsea validate -m "$MNEMONIC" | bipsea xprv | bipsea derive -a codex32 -t 3 | bipsea codex32 -n 5 +``` + ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm ms13casheekgpemxzshcrmqhaydlp6yhms3ws7320xyxsar9 ms13cashf8jh6sdrkpyrsp5ut94pj8ktehhw2hfvyrj48704 + ### DRNG, enter the matrix @@ -347,7 +404,7 @@ See [Makefile](./Makefile) for more commands. ## Is the bipsea implementation correct? -bipsea passes all BIP-32, BIP-39, and BIP-85 test vectors in all BIP-39 languages +bipsea passes all BIP-32, BIP-39, BIP-85 and BIP-93 test vectors in all BIP-39 languages plus its own unit tests. There is a single BIP-85 vector, which we believe to be incorrect in the spec, @@ -364,6 +421,8 @@ mnemonic seed words generalized BIP-32 paths 1. [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki) generalized cryptographic entropy +1. [BIP-93](https://github.com/bitcoin/bips/blob/master/bip-0094.mediawiki) +checksummed SSSS-aware BIP-32 seeds # TODO diff --git a/pyproject.toml b/pyproject.toml index 1104ff9..457d5d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = ["Aneesh Karve "] license = "Apache-2.0" homepage = "https://github.com/akarve/bipsea" repository = "https://github.com/akarve/bipsea" -keywords = ["Bitcoin", "BIP-32", "BIP-39", "BIP-85", "cryptography", "secrets", "ECDSA", "entropy"] +keywords = ["Bitcoin", "BIP-32", "BIP-39", "BIP-85", "BIP-93", "cryptography", "secrets", "ECDSA", "entropy"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -32,6 +32,7 @@ click = "~8.1.3" base58 = "~2.1.1" build = "~1.2.1" ecdsa = "~0.19.0" +codex32 = "~0.1.0" [tool.poetry.group.dev.dependencies] black = "~24.4.2" diff --git a/src/bipsea/bip85.py b/src/bipsea/bip85.py index f637fb6..ebda21c 100644 --- a/src/bipsea/bip85.py +++ b/src/bipsea/bip85.py @@ -6,6 +6,12 @@ from typing import Dict, Union import base58 +from codex32.codex32 import ( + CHARSET, + bech32_decode, + Codex32String, + entropy_to_strings, +) from .bip32 import VERSIONS, ExtendedKey from .bip32 import derive_key as derive_key_bip32 @@ -23,10 +29,13 @@ "drng": "0'", "hex": "128169'", "mnemonic": "39'", + "codex32": "93'", "wif": "2'", "xprv": "32'", } +# TODO lots to copy in from patch-1! + RANGES = { "base64": (20, 86), "base85": (10, 80), @@ -51,6 +60,11 @@ "9'": "portuguese", # not in BIP-85 but in BIP-39 test vectors } +INDEX_TO_HRP = { + "0'": "ms", + "1'": "cl", +} + assert set(INDEX_TO_LANGUAGE.values()) == set(LANGUAGES.keys()) @@ -83,6 +97,34 @@ def apply_85(derived_key: ExtendedKey, path: str) -> Dict[str, Union[bytes, str] "entropy": trimmed_entropy, "application": " ".join(words), } + elif app == APPLICATIONS["codex32"]: + header, n_bytes = indexes[:2] + hrp, data = bech32_decode(header) + if hrp not in INDEX_TO_HRP: + raise ValueError(f"Unsupported human-readable prefix: {hrp}.") + k = int(CHARSET[data[0]]) + if k == 1: + raise ValueError( + f"Threshold '{k}' is not an allowed value (2 through 9, or 0)." + ) + ident = header[1:5] + byte_length = int(n_bytes.rstrip("'")) + if not 16 <= byte_length <= 64: + raise ValueError( + f"Byte length '{byte_length}' is not an allowed value (16 through 64)." + ) + drng = DRNG(entropy) + alphabetized_charset = "sacdefghjk" # threshold above 9 is invalid + shares = [] + for share_idx in alphabetized_charset[bool(k) : k + 1]: + shares += Codex32String.from_seed( + drng.read(byte_length), ident, hrp, k, share_idx + ).s + + return { + "entropy": entropy, + "application": " ".join(shares), + } elif app == APPLICATIONS["wif"]: trimmed_entropy = entropy[: 256 // 8] prefix = b"\x80" if derived_key.get_network() == "mainnet" else b"\xef" diff --git a/src/bipsea/bipsea.py b/src/bipsea/bipsea.py index e70d7a7..2a1032f 100644 --- a/src/bipsea/bipsea.py +++ b/src/bipsea/bipsea.py @@ -5,6 +5,10 @@ import sys import click +from codex32.codex32 import ( + CHARSET, + entropy_to_codex32, +) from .bip32 import to_master_key from .bip32types import parse_ext_key, validate_prv_str @@ -21,6 +25,7 @@ APPLICATIONS, DRNG, INDEX_TO_LANGUAGE, + INDEX_TO_HRP, PURPOSE_CODES, RANGES, apply_85, @@ -137,16 +142,49 @@ def validate(from_, mnemonic): click.echo(" ".join(words)) +# TODO: paste codex32 cli here + + +@click.command( + name="recover", + help="Validate a set of codex32 strings and recover the secret or share at target share index", +) +@click.option( + "-t", "--target", help="Share index to recover at. Default is the secret `s`." +) +def recover(strings, target="s"): + click.echo(Codex32String.interpolate_at(strings, target)) + + @click.command( name="xprv", help="Derive a BIP-32 XPRV from arbitrary string. Use bipsea validate` to validate!", ) @click.option("-m", "--mnemonic", help="Mnemonic. Pipe from `bipsea validate`.") +@click.option("-c", "--codex32", help="Codex32 secret. Pipe from `bipsea recover`.") @click.option("-p", "--passphrase", default="", help="BIP-39 passphrase.") @click.option("--mainnet/--testnet", is_flag=True, default=True) -def xprv(mnemonic, passphrase, mainnet): +def xprv(mnemonic, codex32, passphrase, mainnet): if mnemonic: mnemonic = mnemonic.strip() + elif codex32: + codex32 = codex32.strip() + no_empty_param("--codex32", codex32) + if len(codex32) < 48: + raise click.BadOptionUsage( + option_name="--codex32", + message="Suspiciously short codex32 secret. Try `bipsea recover`.", + ) + if passphrase: + raise click.BadOptionUsage( + option_name="--passphrase", + message="No passphrase support for codex32 secrets. Try `bipsea recover`.", + ) + seed = Codex32String.from_string(codex32).parts().data() + prv = to_master_key(seed, mainnet=mainnet, private=True) + + click.echo(prv) + return else: mnemonic = try_for_pipe_input() no_empty_param("--mnemonic", mnemonic) @@ -206,6 +244,8 @@ def xprv(mnemonic, passphrase, mainnet): help="Output language for `--application mnemonic`.", ) def derive_cli(application, number, index, special, xprv, to): + # TODO Write the "glue" between the bip85 codex32 logic and CLI last as it's the hardest step. + # TODO Copy unfinished work from patch-1 if xprv: xprv = xprv.strip() else: @@ -241,8 +281,25 @@ def derive_cli(application, number, index, special, xprv, to): if application == "mnemonic": language = ISO_TO_LANGUAGE[to] - code_85 = next(i for i, l in INDEX_TO_LANGUAGE.items() if l == language) + code_85 = next( + i for i, l in INDEX_TO_LANGUAGE.items() if l == language + ) # noqa: E741 + path += f"/{code_85}/{number}'/{index}'" + if application == "codex32": + code_85 = next(i for i, l in INDEX_TO_HRP.items() if l == "ms") # noqa: E741 path += f"/{code_85}/{number}'/{index}'" + elif application == "codex32": + hrp, data = bech32_decode("ms1" + str(0) + "test") + code_85 = ISO_TO_HRP[hrp] + int.from_bytes( + convertbits(data, 5, 8) + ) # serialization must fit in 32-bits + # 3.2 for threshold, 20 for identifier, 1 for hrp if just 0,1 dict + 4 more for default IDs + # add the {index} to the encoded indentifier so that it increments as the index is. That gives 2^20 seeds before we reuse the original identifier + # this gives the amazing property for fingerprint IDs when you know the master fingerprint + # that you can subtract them and see what index was used to generate that codex32 backup. + # be sure it rolls over at 2^20 so that the threshold doesn't increment as well which will + # break things. + path += f"/{code_85}'/{number}'/{index}'" elif application in ("wif", "xprv"): path += f"/{index}'" elif application in ("base64", "base85", "hex"): @@ -271,6 +328,8 @@ def cli(): cli.add_command(mnemonic) cli.add_command(validate) +cli.add_command(codex32) +cli.add_command(recover) cli.add_command(xprv) cli.add_command(derive_cli) diff --git a/tests/data/bip85_vectors.py b/tests/data/bip85_vectors.py index 39d38fe..af4ac9b 100644 --- a/tests/data/bip85_vectors.py +++ b/tests/data/bip85_vectors.py @@ -47,6 +47,75 @@ }, ] +BIP_93 = [ + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/0'/0'/1'/16'/11'/25'/16'/11'", + "derived_entropy": "6250b68daf746d12a24d58b4787a714b", + "derived_codex32": "ms10tests...", + "hrp": "ms", + "threshold": 0, + "share_count": 1, + "bytes_length": 16, + "identifier": 'test', + }, + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/0'/2'/3'/16'/19'/29'/27'/25'", + "derived_entropy": "938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + "derived_codex32": "ms12namea... ms12namec... ms12named...", + "hrp": "ms", + "threshold": 2, + "share_count": 3, + "bytes_length": 16, + "identifier": 'name', + }, + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/0'/3'/2'/16'/24'/29'/16'/23'", + "derived_entropy": "938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + "derived_codex32": "ms13casha... ms13cashc...", + "hrp": "ms", + "threshold": 3, + "share_count": 2, + "bytes_length": 16, + "identifier": 'cash', + }, + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/0'/0'/1'/64'/32'/32'/32'/32'", + "derived_entropy": "938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + "derived_codex32": "ms10????s...", + "hrp": "ms", + "threshold": 0, + "share_count": 1, + "bytes_length": 64, + "identifier": '????', + }, + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/0'/3'/2'/16'/32'/32'/32'/32'", + "derived_entropy": "938033ed8b12698449d4bbca3c853c66b293ea1b1ce9d9dc", + "derived_codex32": "ms12????a... ms12????c...", + "hrp": "ms", + "threshold": 3, + "share_count": 2, + "bytes_length": 16, + "identifier": '????', + }, + { + "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", + "path": "m/83696968'/93'/1'/0'/1'/32'/24'/31'/19'/10'", + "derived_entropy": "", + "derived_codex32": "cl10cln2s...", + "hrp": "cl", + "threshold": 0, + "share_count": 1, + "bytes_length": 32, + "identifier": 'cln2', + }, +] + XPRV = [ { "master": "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb", diff --git a/tests/test_bip85.py b/tests/test_bip85.py index 0412e45..84a90df 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -7,6 +7,7 @@ from Crypto.PublicKey import RSA from data.bip85_vectors import ( BIP_39, + BIP_93, COMMON_XPRV, DICE, EXT_KEY_TO_ENTROPY, @@ -19,6 +20,7 @@ from bipsea.bip32types import parse_ext_key from bipsea.bip39 import LANGUAGES, validate_mnemonic_words +from bipsea.bip93 import ms32_decode, validate_set from bipsea.bip85 import ( APPLICATIONS, DRNG, @@ -113,6 +115,27 @@ def test_mnemonic_languages(vector, lang): words = output["application"].split(" ") assert validate_mnemonic_words(words, lang) +@pytest.mark.parametrize( + "vector", + BIP_93, + ids=[f"BIP_93-{v['identifier']}" for v in BIP_93] + ) +def test_codex32(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"] + codex32_strings = output["application"].split(" ") + for string in codex32_strings: + hrp, threshold, identifier, _, data = ms32_decode(string) + assert hrp == vector["hrp"] + assert threshold == vector["threshold"] + assert identifier == vector["identifier"] + assert len(data) == vector["bytes_length"] + assert len(codex32_strings) == vector["share_count"] + assert output["application"] == vector["derived_codex32"] + assert output["codex32"] == vector["derived_codex32"] + assert validate_set(codex32_strings, len_must_match_k=False) @pytest.mark.parametrize("vector", HEX) def test_hex(vector):