diff --git a/docs/developer/api.md b/docs/developer/api.md index 4e6cd06..51c9fb5 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -7,7 +7,7 @@ The package uses one narrow dependency direction: ```text text -> format/header/checksum -> optional profile module -> immutable artifact |-> BIP93 sharing - |-> ms/cl generation + |-> ms generation |-> bounded correction `-> MasterSeed wallet adapter @@ -44,8 +44,8 @@ generic parse-length failure. | CLI competitor scheduling and pruning | `_competitors.py` | `test_competitors.py` | | incremental alignment syndromes | `_alignment.py` | `test_alignment.py` | | stdlib-only BIP32 root validation and serialization | `_bip32.py`, `profiles/ms32.py` | BIP32 and wallet vectors | -| fixed wallet derivation and descriptors | `wallet.py` | `test_wallet.py` | -| Core target selection and subprocess state | `_bitcoin_core.py` | Core adapter and regtest | +| root xprv handoff | `wallet.py` | `test_wallet.py` | +| Core target selection, descriptor requests, and subprocess state | `_bitcoin_core.py` | `test_bitcoin_core.py`, regtest | | bounded stdin, fixed-prefix TTY entry, and whole-card confirmation | `_cli_input.py` | `test_cli.py` | | command grammar, dispatch, and presentation | `_cli_parser.py`, `cli.py` | `test_cli.py` | @@ -67,13 +67,12 @@ generic parse-length failure. `.text` attribute. - Sharing interpolates payload and checksum together, explicitly constructs the target header, and reparses the result. -- `generation.py` is the only entropy owner and generates only `ms` and `cl`. +- `generation.py` is the only entropy owner; it generates and shares only `ms`. - Correction never edits the HRP or separator and reparses every candidate. - `_bip32.py` stops at HMAC-SHA512 root derivation, scalar validity, and root xprv/tprv Base58Check serialization. It performs no child derivation or - secp256k1 point arithmetic. `wallet.py` accepts only `MasterSeed`; EC-dependent - public derivation is supplied through an explicit wallet integration and the - CLI uses Bitcoin Core for that boundary. + secp256k1 point arithmetic. `wallet.py` accepts only `MasterSeed`; Bitcoin + Core performs all EC-dependent derivation. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. @@ -100,7 +99,7 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 25-name package `__all__`; direct use of private helpers is +surface is the 22-name package `__all__`; direct use of private helpers is unsupported but remains in the review scope. ### Size budget @@ -117,13 +116,13 @@ base artifact types rather than falling back to a registered application. | Capability | opaque HRP | `ms` | `cl` | `bip39_12w/24w` | |---|---:|---:|---:|---:| -| parse S/share | yes | yes | yes | yes | +| parse S/share | yes | yes | unshared S only | yes | | semantic S bytes | no | 16, 20, 24, 28, 32, or 64 | exactly 32 | no | -| recovery and API share derivation | yes | yes | yes | yes | -| `codex32` recovery/share/correction | yes | yes | yes | yes | -| unshared generation / shared ceremony API | no | six supported sizes | exactly 32 bytes | no | +| recovery and API share derivation | yes | yes | no | yes | +| `codex32` recovery/share/correction | yes | yes | correction only | yes | +| unshared generation / shared ceremony API | no | six supported sizes | no | no | | fresh generation CLI (`ms32`) | no | six supported sizes | no | no | -| existing-S splitting | no | yes | yes | no | +| existing-S splitting | no | yes | no | no | | wallet API | no | S only | no | no | Registered application semantics remain: @@ -136,32 +135,30 @@ Registered application semantics remain: `ms` payloads encode exactly 16, 20, 24, 28, 32, or 64 seed bytes and may have any legal parsed trailing bits. Parsing, generation, ceremonies, raw-seed -import, and CLI `--bytes` enforce the same six sizes. `cl` has 52 payload symbols; parsed -discarded bits remain application data. BIP39 profiles have exactly 27/53 -payload symbols; S requires zero outer padding and a valid embedded SHA-256 -checksum. Ordinary BIP39 shares are random masks and receive structural -validation only. +import, and CLI `--bytes` enforce the same six sizes. `cl` has 52 payload +symbols and threshold 0; parsed discarded bits remain application data. BIP39 +profiles have exactly 27/53 payload symbols; S requires zero outer padding and +a valid embedded SHA-256 checksum. Ordinary BIP39 shares are random masks and +receive structural validation only. -CL generation is explicit and uses a random identifier unless one is supplied. Current Core Lightning defaults to mnemonic recovery, but its recovery command -retains an import path for codex32 HSM secrets. Generated CL S strings use the -zero-padding convention emitted by CLN; parsed nonzero discarded bits remain -valid and are preserved when re-sharing. +retains an import path for unshared codex32 HSM secrets. It rejects shares and +any S with a nonzero threshold, so the `cl` profile rejects them too and there +is no CL recovery, share derivation, or splitting. -The generic `codex32` façade supports CL and BIP39 inspection, correction, -recovery, and share derivation. The `ms32` façade accepts only `ms` artifacts. +The generic `codex32` façade supports CL inspection and correction, and BIP39 +inspection, correction, recovery, and share derivation. The `ms32` façade +accepts only `ms` artifacts. ## Secret generation -`generation.py` is the only module that draws entropy. It generates BIP93 -master seeds and Core Lightning HSM secrets, and splits either validated S type. -Core Lightning now defaults to mnemonic recovery, but retains a codex32 HSM -secret import path for recovery on an unused node. +`generation.py` is the only module that draws entropy. It generates and +splits BIP93 master seeds only. Fresh unshared seeds default to 16 bytes and use the first 20 bits of their BIP32 fingerprint as public identifier metadata. Fresh shared sets use four -independent random u5 identifier symbols. Raw bytes, re-shared secrets, and CL -generation also use an independent random identifier unless one is supplied. +independent random u5 identifier symbols. Raw bytes and re-shared secrets also +use an independent random identifier unless one is supplied. Random re-sharing never repeats the source set header; an explicitly repeated source header is rejected. @@ -169,15 +166,18 @@ The Python API and CLI accept the six PR #2258 `ms` sizes: 16, 20, 24, 28, 32, and 64 bytes. Other byte lengths are rejected at every public construction boundary; there is no legacy decoder. -One-shot functions create only unshared secrets: +The one-shot function creates only unshared secrets: ```python -generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None) -generate_core_lightning_secret(secret_bytes=None, *, identifier=None) +generate_master_seed(seed_bytes=None, *, byte_length=None, identifier=None, fingerprint=None) ``` -Shared creation uses `CreationCeremony.master_seed(...)`, -`CreationCeremony.core_lightning(...)`, or +A fresh seed with no `identifier` needs `fingerprint`: a callable that takes +the seed bytes and returns their four-byte BIP32 master fingerprint. This +package has no secp256k1 arithmetic, so the CLI asks Bitcoin Core. Without +either argument, fresh generation raises `ValueError`. + +Shared creation uses `CreationCeremony.master_seed(...)` or `CreationCeremony.from_secret(...)`. Exactly one of `share_count` and `indices` is required. `next_share()` returns one pending share, `confirm(text)` must accept its independently re-entered string, and `finish()` returns the secret @@ -185,8 +185,7 @@ only after every requested share is confirmed. There is no public one-shot sharing or `split_secret` function. Fresh and existing Bitcoin CLI creation requires interactive input and output, preflights local Bitcoin Core before entropy or recovery input, and initializes a user-selected wallet after every -share is confirmed. CLI creation does not accept Core Lightning profiles; CL -generation and sharing remain API-only. +share is confirmed. Without `--existing`, omitting the Bitcoin header creates an unshared master seed. With `--existing` and no sharing threshold, a supplied codex32 secret is emitted and confirmed unchanged, and the original validated artifact initializes @@ -254,10 +253,10 @@ string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. For a fresh set, the final direct share is rejection-sampled until the recovered -S has the private CRC padding convention (`ms`) or zero discarded bits (CL). -The already confirmed `k-1` masks remain fixed. Neither padding rule is BIP93 -validity: parsed S strings may use any application-valid discarded bits, which -re-sharing preserves exactly. CRC never applies to shares. +S has the private CRC padding convention. The already confirmed `k-1` masks +remain fixed. That padding rule is not BIP93 validity: parsed S strings may use +any application-valid discarded bits, which re-sharing preserves exactly. CRC +never applies to shares. Explicit output indices preserve caller order. A share count uses `SystemRandom.sample` over the 31 ordinary indices and preserves sample order. @@ -316,7 +315,7 @@ applies registered S semantics when the HRP has a profile. `tests/test_sharing.py` proves that recovery and derivation still work after checksum creation is disabled. Official BIP93 vectors anchor the GF(32) -arithmetic. CL and BIP39 use compact frozen string/result fixtures rather than +arithmetic. BIP39 uses compact frozen string/result fixtures rather than a duplicate test implementation of interpolation or checksumming. ### BIP39 migration profiles @@ -580,27 +579,17 @@ Public wallet operations accept only a validated `MasterSeed`. `wallet.py` is stateless and never accepts shares, Core Lightning secrets, BIP39 migration artifacts, or raw bytes. -The public adapter has two functions: - -- `master_xprv(secret, testnet=False)` returns the BIP32 root extended private - key. -- `core_descriptors(...)` returns fixed BIP44, BIP49, BIP84, and BIP86 Bitcoin - Core `importdescriptors` records. Private records use stdlib-only root xprv - serialization; public records require an explicit wallet integration and the - Core wallet whose imported root key will perform hardened derivation. +The public adapter has one function: `master_xprv(secret, testnet=False)` +returns the BIP32 root extended private key, using stdlib-only serialization. +It grants authority over every key derived from the seed, and the CLI warns +before printing it. No installed Python dependency performs secp256k1 operations. The private Bitcoin Core adapter gives Core the root xprv over stdin and asks Core to -create the four standard account-0 descriptor types. Public descriptor -derivation remains available through the explicit integration API. - -Public descriptors contain account xpubs. Private descriptors intentionally -follow Bitcoin Core's root-key form: they contain the root xprv followed by the -complete derivation path. They therefore grant authority over the entire root, -not only the selected account. The CLI warns before printing them. +create the four standard account-0 descriptor types. Core's own wallet +commands provide public descriptors and watch-only exports. -Account, private/public mode, network serialization, and timestamp are explicit -API inputs. The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the +The `ms32 wallet` CLI takes `--account 0` and `--timestamp`; the selected Bitcoin Core chain is authoritative and there is no wallet `--testnet` flag. `ms32 xprv --testnet` remains explicit because it directly selects xprv versus tprv serialization. The timestamp defaults to `0` so @@ -647,7 +636,7 @@ the BIP32 fingerprint. The Core calls are fixed: `getnetworkinfo`, `getblockchaininfo`, `listwallets`, `getwalletinfo`, `listdescriptors`, `getdescriptorinfo`, `deriveaddresses`, -`validateaddress`, `gethdkeys`, `derivehdkey`, `addhdkey`, +`validateaddress`, `addhdkey`, `createwalletdescriptor`, `importdescriptors`, and `walletlock`. Bitcoin Core alone creates wallets, selects encryption, handles passphrases, stores keys, and provides normal wallet behavior. @@ -688,11 +677,10 @@ These choices are not presented as BIP93 requirements. | `ms` accepts only six seed sizes | follows the frozen PR #2258 profile with no legacy decoder | | random electronic output indices | reduces canonical index disclosure; explicit indices preserve requested order | | generation-only CRC padding | small recovery hint; not validity or share semantics | -| fingerprint identifier only for fresh k=0 | shared sets, raw seeds, re-sharing, and CL generation use random IDs unless explicitly overridden | +| fingerprint identifier only for fresh k=0 | shared sets, raw seeds, and re-sharing use random IDs unless explicitly overridden | | BIP39 profiles have no construction or wallet CLI | migration artifacts may still be checked, corrected, recovered, and re-shared generically | | reject existing derivation targets | enforces BIP93's fresh-index wording | | bounded structural correction is deliberately finite | exact capture safety, complete global rank layers, the 48-character ten-second target, and the package audit budget exclude a general recovery engine; longer valid strings keep the same bounded classes | -| private descriptors contain root xprv | matches Bitcoin Core behavior and carries an explicit authority warning | | no caller-supplied partial-basis completion | unauthenticated points can create incompatible same-header polynomials | Unknown-HRP application interpretation, GUI, direct sockets, a general RPC @@ -712,9 +700,8 @@ The four-character identifier is public metadata, not authentication. offline 20-bit predicate against candidate seeds. - A fresh shared set uses four independent random u5 symbols and leaks no seed-derived fingerprint bits. -- Raw seed bytes, re-sharing, and CL generation use an independent random - identifier unless the caller supplies all four symbols. A random identifier - does not make a weak supplied seed safe. +- Raw seed bytes and re-sharing use an independent random identifier unless the + caller supplies all four symbols. - Random re-sharing rejects the source set header and draws another identifier. An explicitly repeated source header remains an error. diff --git a/docs/security/model.md b/docs/security/model.md index 2d3e18b..6eabc34 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -120,9 +120,10 @@ full-payload OS-CSPRNG request. The current share string must be re-entered exactly, ignoring case and whitespace, before the next request. Confirmation text is never reparsed as the source secret and contributes no entropy. Existing complete Bitcoin master seeds are confirmed unchanged and initialize the wallet -without new entropy. The `ms32` façade rejects CL; CL generation remains -Python-API-only. Generic sharing, recovery, inspection, and correction are -available through `codex32`. +without new entropy. The `ms32` façade rejects CL. The generic API parses, +inspects, and corrects unshared CL secrets only: Core Lightning rejects shares +and nonzero thresholds, so CL shares are rejected and CL secrets are never +generated, derived, or re-shared. Creation retries show only entered text in contiguous regions: bold red means review the card, with reverse video added for the active region. Original card formatting is display-only; editable prefills retain entered case and spacing. Complete matching canonical groups freeze; local alignment preserves entered group ownership before edit minimization and proceeds without crossing frozen boundaries (see the API alignment rules). @@ -176,10 +177,11 @@ those explanations; unproven scoring work is retained. Repeated masks and cancelled operations are removed only with a coverage proof. Pruning does not reallocate the ledger's mass to additional hypotheses. -`codex32` checks, corrects, recovers, and derives shares for compatible CL, -BIP39, and opaque-HRP sets. `ms32` applies an `ms` input filter and alone owns -Bitcoin creation, xprv, and wallet commands. Both façades offer worksheet-residue -correction; only `ms32 correct` offers `--bytes`. +`codex32` checks and corrects unshared CL secrets, and checks, corrects, +recovers, and derives shares for compatible BIP39 and opaque-HRP sets. `ms32` +applies an `ms` input filter and alone owns Bitcoin creation, xprv, and wallet +commands. Both façades offer worksheet-residue correction; only `ms32 correct` +offers `--bytes`. ### Low-discrimination correction disclosure diff --git a/docs/user/guide.md b/docs/user/guide.md index 6004418..b875ab0 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -298,9 +298,11 @@ enter the damaged string with trailing `?` characters when asked; do not place a secret or share in the command arguments. The strong warning and literal `YES` gate apply before the completed suggestion is shown. -The generic `codex32` façade can check, correct, recover, and derive shares for -compatible Core Lightning, BIP39 worksheet, and opaque-HRP artifacts. It does -not generate BIP39 words or Core Lightning secrets. Registered profiles retain +The generic `codex32` façade can check and correct unshared Core Lightning HSM +secrets, and check, correct, recover, and derive shares for compatible BIP39 +worksheet and opaque-HRP artifacts. Core Lightning HSM secrets cannot be split, +because Core Lightning accepts only threshold 0. The façade does not generate +BIP39 words or Core Lightning secrets. Registered profiles retain their application validation; unknown HRPs remain opaque codex32 symbols. Keep the matching application worksheet and original wallet instructions with the inheritance plan. Published BIP-93 still defines the `ms` application; the diff --git a/src/codex32/__init__.py b/src/codex32/__init__.py index 78eda94..56ceb77 100644 --- a/src/codex32/__init__.py +++ b/src/codex32/__init__.py @@ -20,14 +20,13 @@ from .generation import ( ConfirmationResult, CreationCeremony, - generate_core_lightning_secret, generate_master_seed, ) from .profiles import Profile from .profiles.bip39 import Bip39Secret from .profiles.cl32 import CoreLightningSecret from .profiles.ms32 import MasterSeed -from .wallet import core_descriptors, master_xprv +from .wallet import master_xprv __all__ = [ "Bip39Secret", @@ -45,11 +44,9 @@ "Secret", "Share", "WorksheetCorrection", - "core_descriptors", "correct", "correct_worksheet_residue", "derive_share", - "generate_core_lightning_secret", "generate_master_seed", "master_xprv", "parse_codex32", diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 695cb56..606594f 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -2,7 +2,6 @@ import hashlib import json -import re import shutil import string import subprocess @@ -15,7 +14,6 @@ from codex32.bech32 import _u5_to_chars, convertbits from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _descriptor_records class BitcoinCoreError(Exception): @@ -34,9 +32,7 @@ class FingerprintMismatch(BitcoinCoreError): ("regtest", "regtest"), ) -_ORIGIN = re.compile(r"\[(?P[0-9a-f]{8})(?P(?:/[0-9]+[h']?)*)\]") _PRIVATE_MARKERS = ("xprv", "tprv") -_PURPOSES = (44, 49, 84, 86) _OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m") @@ -218,79 +214,6 @@ def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None "Bitcoin Core was not changed." ) - def _root_xpub(self, wallet: str) -> str: - result = self._rpc("gethdkeys", wallet=wallet) - if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): - raise BitcoinCoreError("Bitcoin Core did not return the expected wallet HD key.") - xpub = result[0].get("xpub") - prefix = "xpub" if self.chain == "main" else "tpub" - if ( - result[0].get("has_private") is not True - or not isinstance(xpub, str) - or not xpub.startswith(prefix) - ): - raise BitcoinCoreError("Bitcoin Core did not return the expected private wallet HD key.") - return xpub - - def _derived_key(self, wallet: str, root_xpub: str, path: str) -> tuple[bytes, str]: - result = self._rpc( - "-named", - "derivehdkey", - f"path=m{path}", - f"hdkey={root_xpub}", - wallet=wallet, - ) - origin = result.get("origin") if isinstance(result, dict) else None - xpub = result.get("xpub") if isinstance(result, dict) else None - match = _ORIGIN.fullmatch(origin) if isinstance(origin, str) else None - prefix = "xpub" if self.chain == "main" else "tpub" - if match is None or not isinstance(xpub, str) or not xpub.startswith(prefix): - raise BitcoinCoreError("Bitcoin Core did not return the expected derived HD key.") - normalized_path = match.group("path").replace("'", "h") - if normalized_path != path: - raise BitcoinCoreError("Bitcoin Core returned an unexpected derivation path.") - return bytes.fromhex(match.group("fingerprint")), f"{origin}{xpub}/<0;1>/*" - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | Literal["now"] = 0, - ) -> tuple[dict[str, object], ...]: - """Ask Core to derive account xpubs, then normalize their public descriptors.""" - if not isinstance(secret, MasterSeed): - raise TypeError("wallet operations accept only MasterSeed") - root_xpub = self._root_xpub(wallet) - keys: list[str] = [] - expected_fingerprint: bytes | None = None - for purpose in _PURPOSES: - path = f"/{purpose}h/{int(self.chain != 'main')}h/{account}h" - fingerprint, key = self._derived_key(wallet, root_xpub, path) - if expected_fingerprint is None: - expected_fingerprint = fingerprint - elif fingerprint != expected_fingerprint: - raise BitcoinCoreError("Bitcoin Core returned inconsistent master fingerprints.") - keys.append(key) - records = _descriptor_records(tuple(keys), timestamp) # type: ignore[arg-type] - for record in records: - detail = self._rpc("getdescriptorinfo", stdin=str(record["desc"]) + "\n") - expansion = detail.get("multipath_expansion") if isinstance(detail, dict) else None - if ( - not isinstance(detail, dict) - or detail.get("hasprivatekeys") is not False - or not isinstance(expansion, list) - or len(expansion) != 2 - or not all( - isinstance(descriptor, str) - and not any(marker in descriptor for marker in _PRIVATE_MARKERS) - for descriptor in expansion - ) - ): - raise BitcoinCoreError("Bitcoin Core did not validate the expected public descriptor.") - return records - def _target(self, name: str) -> tuple[bool, bool] | None: listing, info = self._rpc("listdescriptors", wallet=name), self._rpc("getwalletinfo", wallet=name) if not isinstance(info, dict) or not isinstance(listing, dict): diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 59befc7..7704f01 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -1,3 +1,28 @@ +# Portions of this file are derived from work by: +# Copyright (c) 2017, 2020 Pieter Wuille +# Source: https://github.com/sipa/bech32/blob/master/ref/python/segwit_addr.py +# +# Additional code and modifications: +# Copyright (c) 2026 Ben Westgate +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + """Bech32 character, container, and bit-conversion helpers.""" from codex32.checksums import _Checksum diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 9e3757c..238f7dd 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -170,7 +170,7 @@ class Secret(_Artifact): def _validate_payload(profile: Profile, header: Header, payload: tuple[int, ...]) -> None: rules = _profile_rules(profile) rules.validate_payload_length(len(payload)) - rules.validate_payload(payload, header.index) + rules.validate_payload(payload, header) def _artifact( diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 19262d1..6061d1e 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -1,4 +1,33 @@ -"""Immutable checksum specifications used by codex32 and descriptors.""" +# The polymod arithmetic in this file is derived from work by: +# Copyright (c) 2017, 2020 Pieter Wuille, MIT License +# Source: https://github.com/sipa/bech32/blob/master/ref/python/segwit_addr.py +# codex32 constants in this file are derived from work by: +# Author: Leon Olsson Curr and Pearlwort Sneed +# License: BSD-3-Clause +# Source: https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki +# +# Additional code: +# Copyright (c) 2026 Ben Westgate , MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Immutable checksum specifications used by codex32.""" from dataclasses import dataclass @@ -16,7 +45,6 @@ 0x0C577EAECCF1990D13C, 0x1887F74F8DC71B10651, ) -_DESCSUM_GEN = (0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD) @dataclass(frozen=True, slots=True) @@ -52,9 +80,6 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]: _CODEX32 = _Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) _CODEX32_LONG = _Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) -# Descriptor checksum remains an independently specified, non-codex32 helper. -DESCSUM = _Checksum("Descriptor", _DESCSUM_GEN, 8, 1) - _CRC = ( None, # ``_Checksum`` consumes input bits most-significant bit first. With its diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 6e81e43..1ffd1f1 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -130,7 +130,7 @@ def _summary(artifact: Artifact, *, valid: bool = False) -> list[str]: else: name = _profile_rules(artifact.profile).label if isinstance(artifact, Share): - name = name.replace("master seed", "master-seed").replace("HSM secret", "HSM-secret") + name = name.replace("master seed", "master-seed") heading = f"{name} share {header.index.upper()}" else: heading = f"{'Unshared' if header.threshold == 0 else 'Shared'} {name}" diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d19..7ad8c2e 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -1,4 +1,4 @@ -"""Electronic generation for ``ms`` and Core Lightning share sets.""" +"""Electronic master-seed generation and sharing.""" from __future__ import annotations @@ -29,14 +29,6 @@ InvalidThreshold, ) from codex32.profiles import Profile -from codex32.profiles.cl32 import PAYLOAD_LENGTH as CL_PAYLOAD_LENGTH -from codex32.profiles.cl32 import ( - CoreLightningSecret, - _secret_from_bytes, -) -from codex32.profiles.cl32 import ( - _has_generation_padding as _cl_padding, -) from codex32.profiles.ms32 import ( DEFAULT_SEED_BYTES, SEED_BYTE_LENGTHS, @@ -124,9 +116,9 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) -def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share: +def _random_share(threshold: int, identifier: str, index: str, length: int) -> Share: symbols = tuple(value & 31 for value in secrets.token_bytes(length)) - artifact = _from_parts(profile, Header(threshold, identifier, index), symbols) + artifact = _from_parts(Profile.MS, Header(threshold, identifier, index), symbols) assert isinstance(artifact, Share) return artifact @@ -175,14 +167,6 @@ def generate_master_seed( return MasterSeed.from_seed(fresh, identifier=_fingerprint_identifier(fingerprint(fresh))) -def generate_core_lightning_secret( - secret_bytes: bytes | None = None, *, identifier: str | None = None -) -> CoreLightningSecret: - """Generate or encode one unshared Core Lightning HSM secret.""" - identifier = _random_identifier() if identifier is None else _identifier(identifier) - return _secret_from_bytes(secrets.token_bytes(32) if secret_bytes is None else secret_bytes, identifier) - - class CreationCeremony: """Generate and confirm one shared-backup card at a time.""" @@ -193,8 +177,7 @@ class CreationCeremony: _payload_length: int _pending: Share | None _position: int - _profile: Profile - _secret: MasterSeed | CoreLightningSecret | None + _secret: MasterSeed | None _set_identifier: str _threshold: int @@ -207,24 +190,23 @@ def __reduce_ex__(self, _protocol: SupportsIndex) -> NoReturn: @classmethod def _start( cls, - profile: Profile, payload_length: int, threshold: int, share_count: int | None, indices: Sequence[str] | str | None, identifier: str, - secret: MasterSeed | CoreLightningSecret | None, + secret: MasterSeed | None, ) -> CreationCeremony: self = object.__new__(cls) - self._profile, self._payload_length = profile, payload_length + self._payload_length = payload_length self._threshold, self._set_identifier = threshold, identifier self._indices = _selection(threshold, share_count, indices) self._position, self._pending, self._finished = 0, None, False if secret is None: self._secret, self._basis, self._direct_count = None, [], threshold else: - reheadered = _from_parts(profile, Header(threshold, identifier, "s"), secret.payload_symbols) - assert isinstance(reheadered, (MasterSeed, CoreLightningSecret)) + reheadered = _from_parts(Profile.MS, Header(threshold, identifier, "s"), secret.payload_symbols) + assert isinstance(reheadered, MasterSeed) self._secret, self._basis, self._direct_count = ( reheadered, [reheadered], @@ -247,7 +229,6 @@ def master_seed( _supplied, byte_length = _seed_input(None, byte_length) identifier = _random_identifier() if identifier is None else _identifier(identifier) return cls._start( - Profile.MS, _payload_length(byte_length), threshold, share_count, @@ -256,41 +237,19 @@ def master_seed( None, ) - @classmethod - def core_lightning( - cls, - *, - threshold: int, - share_count: int | None = None, - indices: Sequence[str] | str | None = None, - identifier: str | None = None, - ) -> CreationCeremony: - """Start a ceremony for a fresh shared Core Lightning secret.""" - threshold = _threshold(threshold, allow_zero=False) - identifier = _random_identifier() if identifier is None else _identifier(identifier) - return cls._start( - Profile.CL, - CL_PAYLOAD_LENGTH, - threshold, - share_count, - indices, - identifier, - None, - ) - @classmethod def from_secret( cls, - secret: MasterSeed | CoreLightningSecret, + secret: MasterSeed, *, threshold: int, share_count: int | None = None, indices: Sequence[str] | str | None = None, identifier: str | None = None, ) -> CreationCeremony: - """Start a ceremony that shares an existing validated secret.""" - if not isinstance(secret, (MasterSeed, CoreLightningSecret)): - raise TypeError("from_secret accepts only MasterSeed or CoreLightningSecret") + """Start a ceremony that shares an existing validated master seed.""" + if not isinstance(secret, MasterSeed): + raise TypeError("from_secret accepts only MasterSeed") threshold = _threshold(threshold, allow_zero=False) random_identifier = identifier is None identifier = _random_identifier() if random_identifier else _identifier(identifier) @@ -301,9 +260,7 @@ def from_secret( if not random_identifier: raise HeaderCollision("new share set must use a different set header") identifier = _random_identifier() - assert secret.profile is not None return cls._start( - secret.profile, len(secret.payload_symbols), threshold, share_count, @@ -312,9 +269,7 @@ def from_secret( secret, ) - def _candidate_is_accepted(self, secret: MasterSeed | CoreLightningSecret) -> bool: - if isinstance(secret, CoreLightningSecret): - return _cl_padding(secret) + def _candidate_is_accepted(self, secret: MasterSeed) -> bool: return _valid_root(secret.seed_bytes) and _ms_padding(secret) def next_share(self) -> Share: @@ -329,7 +284,6 @@ def next_share(self) -> Share: if self._position < self._direct_count: while True: pending = _random_share( - self._profile, self._threshold, self._set_identifier, index, @@ -338,7 +292,7 @@ def next_share(self) -> Share: if self._position + 1 < self._direct_count or self._secret is not None: break candidate = recover_secret((*cast(list[Share], self._basis), pending)) - assert isinstance(candidate, (MasterSeed, CoreLightningSecret)) + assert isinstance(candidate, MasterSeed) if self._candidate_is_accepted(candidate): self._secret = candidate break @@ -370,14 +324,14 @@ def confirm(self, text: str) -> ConfirmationResult: self._pending = None return ConfirmationResult(True) - def finish(self) -> MasterSeed | CoreLightningSecret: + def finish(self) -> MasterSeed: """Return the secret after every requested card is confirmed.""" if self._finished: raise CeremonyStateError("this creation ceremony is finished") if self._pending is not None or self._position != len(self._indices): raise CeremonyStateError("confirm every card before finishing the ceremony") secret = self._secret - assert isinstance(secret, (MasterSeed, CoreLightningSecret)) + assert isinstance(secret, MasterSeed) self._finished = True self._basis.clear() self._indices = () diff --git a/src/codex32/profiles/bip39.py b/src/codex32/profiles/bip39.py index 5ebc5ed..d01d760 100644 --- a/src/codex32/profiles/bip39.py +++ b/src/codex32/profiles/bip39.py @@ -3,7 +3,7 @@ import hashlib from dataclasses import dataclass -from codex32.bip93 import Secret +from codex32.bip93 import Header, Secret from codex32.errors import ( InvalidBip39Checksum, InvalidLength, @@ -46,8 +46,8 @@ def validate_payload_length(self, payload_length: int) -> None: f"{self.text_length}-character codex32 string." ) - def validate_payload(self, symbols: tuple[int, ...], index: str) -> None: - if index != "s": + def validate_payload(self, symbols: tuple[int, ...], header: Header) -> None: + if header.index != "s": return value = 0 for symbol in symbols: diff --git a/src/codex32/profiles/cl32.py b/src/codex32/profiles/cl32.py index ca5b757..51cc8c3 100644 --- a/src/codex32/profiles/cl32.py +++ b/src/codex32/profiles/cl32.py @@ -3,15 +3,11 @@ from __future__ import annotations from codex32.bech32 import convertbits -from codex32.bip93 import Header, Secret, _from_parts -from codex32.errors import InvalidLength +from codex32.bip93 import Header, Secret +from codex32.errors import InvalidLength, InvalidThreshold from codex32.profiles import Profile -SECRET_BYTES, PAYLOAD_LENGTH, TEXT_LENGTH = 32, 52, 74 - - -def _has_generation_padding(secret: CoreLightningSecret) -> bool: - return secret.payload_symbols[-1] & 15 == 0 +PAYLOAD_LENGTH, TEXT_LENGTH = 52, 74 class CoreLightningSecret(Secret): @@ -25,21 +21,6 @@ def secret_bytes(self) -> bytes: return bytes(convertbits(self.payload_symbols, 5, 8, pad=False, accept_any_padding=True)) -def _secret_from_bytes( - secret_bytes: bytes, - identifier: str, - threshold: int = 0, -) -> CoreLightningSecret: - if not isinstance(secret_bytes, bytes): - raise TypeError("secret_bytes must be bytes") - if len(secret_bytes) != SECRET_BYTES: - raise InvalidLength("Core Lightning secrets must contain exactly 32 bytes") - payload = tuple(convertbits(secret_bytes, 8, 5, pad=True, pad_value=0)) - artifact = _from_parts(Profile.CL, Header(threshold, identifier, "s"), payload) - assert isinstance(artifact, CoreLightningSecret) - return artifact - - class _Cl32Rules: profile, label = Profile.CL, "Core Lightning HSM secret" secret_type = CoreLightningSecret @@ -60,8 +41,10 @@ def validate_payload_length(self, payload_length: int) -> None: f"expected a {TEXT_LENGTH}-character codex32 string." ) - def validate_payload(self, _payload: tuple[int, ...], _index: str) -> None: - pass + def validate_payload(self, _payload: tuple[int, ...], header: Header) -> None: + # Core Lightning rejects shares and shared S strings, so neither is a CL backup. + if header.threshold != 0: + raise InvalidThreshold("A Core Lightning HSM secret backup must be unshared, with threshold 0.") RULES = _Cl32Rules() diff --git a/src/codex32/profiles/ms32.py b/src/codex32/profiles/ms32.py index 1a64c7e..ac662ee 100644 --- a/src/codex32/profiles/ms32.py +++ b/src/codex32/profiles/ms32.py @@ -76,7 +76,7 @@ def validate_payload_length(self, payload_length: int) -> None: "16-, 20-, 24-, 28-, 32-, or 64-byte seed." ) - def validate_payload(self, _payload: tuple[int, ...], _index: str) -> None: + def validate_payload(self, _payload: tuple[int, ...], _header: Header) -> None: pass diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 06b9817..8ec4c23 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -1,131 +1,11 @@ """Bitcoin wallet interoperability for validated master seeds.""" -from typing import Literal, Protocol - from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars -from codex32.checksums import DESCSUM from codex32.profiles.ms32 import MasterSeed -_DESCRIPTOR_CHARSET = ( - "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " -) -_TEMPLATES = ( - ("pkh({key})", 44), - ("sh(wpkh({key}))", 49), - ("wpkh({key})", 84), - ("tr({key})", 86), -) - - -class WalletPublicDeriver(Protocol): - """Out-of-process provider for EC-dependent BIP32 public derivation.""" - - def fingerprint(self, secret: MasterSeed) -> bytes: ... - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | Literal["now"] = 0, - ) -> tuple[dict[str, object], ...]: ... - - -def _master(secret: MasterSeed) -> MasterSeed: - if not isinstance(secret, MasterSeed): - raise TypeError("wallet operations accept only MasterSeed") - return secret - - -def _account(value: int) -> int: - if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value < 2**31: - raise ValueError("account must be an integer from 0 through 2^31-1") - return value - - -def _descriptor_symbols(text: str) -> list[int]: - groups: list[int] = [] - symbols: list[int] = [] - for character in text: - position = _DESCRIPTOR_CHARSET.find(character) - if position < 0: - raise ValueError(f"unsupported descriptor character {character!r}") - symbols.append(position & 31) - groups.append(position >> 5) - if len(groups) == 3: - symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2]) - groups.clear() - if groups: - symbols.append(groups[0] if len(groups) == 1 else groups[0] * 3 + groups[1]) - return symbols - - -def _with_checksum(descriptor: str) -> str: - return descriptor + "#" + _u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) - - -def _descriptor_records( - keys: tuple[str, str, str, str], timestamp: int | Literal["now"] -) -> tuple[dict[str, object], ...]: - records = [] - for (template, _purpose), key in zip(_TEMPLATES, keys, strict=True): - records.append( - { - "desc": _with_checksum(template.format(key=key)), - "active": True, - "timestamp": timestamp, - } - ) - return tuple(records) - def master_xprv(secret: MasterSeed, *, testnet: bool = False) -> str: """Return the root BIP32 extended private key with authority over all children.""" - return _master_xprv_from_seed(_master(secret).seed_bytes, testnet=testnet) - - -def core_descriptors( - secret: MasterSeed, - *, - integration: WalletPublicDeriver | None = None, - wallet: str | None = None, - account: int = 0, - testnet: bool = False, - private: bool = False, - timestamp: int | Literal["now"] = 0, -) -> tuple[dict[str, object], ...]: - """Return fixed Bitcoin Core records. - - Private records are constructed with stdlib-only root xprv serialization. - Public records require an explicit out-of-process integration provider. - """ - _master(secret) - account = _account(account) - if not isinstance(testnet, bool): - raise TypeError("testnet must be bool") - if not isinstance(private, bool): - raise TypeError("private must be bool") - if timestamp != "now" and ( - isinstance(timestamp, bool) or not isinstance(timestamp, int) or timestamp < 0 - ): - raise ValueError("timestamp must be a nonnegative integer or 'now'") - if not private: - if integration is None: - raise TypeError("public descriptors require a wallet integration") - if wallet is None: - raise TypeError("public descriptors require a Bitcoin Core wallet name") - return integration.public_descriptors( - secret, - wallet=wallet, - account=account, - timestamp=timestamp, - ) - coin_type = int(testnet) - xprv = master_xprv(secret, testnet=testnet) - keys = [] - for _template, purpose in _TEMPLATES: - path = f"m/{purpose}h/{coin_type}h/{account}h" - keys.append(xprv + path[1:] + "/<0;1>/*") - return _descriptor_records(tuple(keys), timestamp) # type: ignore[arg-type] + if not isinstance(secret, MasterSeed): + raise TypeError("wallet operations accept only MasterSeed") + return _master_xprv_from_seed(secret.seed_bytes, testnet=testnet) diff --git a/tests/data/README.md b/tests/data/README.md index fa57ecc..64fdf03 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -3,7 +3,7 @@ - `bip93_vectors.py` contains BIP93 vectors plus the six-size and checksum boundary fixtures from PR #2258 commit `5117f5831bcbf0485949e5951d2954b792eded28`. -- `sharing_vectors.py` contains data-only CL and BIP39 interpolation fixtures. +- `sharing_vectors.py` contains data-only BIP39 interpolation fixtures. - `p70_correction_vectors.json` is the frozen PR #70 correction corpus. - `malformed_inputs.json` freezes independent rejection cases for parsing, interpolation, correction, and CLI tokenization. diff --git a/tests/data/malformed_inputs.json b/tests/data/malformed_inputs.json index b904864..454b83b 100644 --- a/tests/data/malformed_inputs.json +++ b/tests/data/malformed_inputs.json @@ -6,7 +6,8 @@ {"id": "mixed-case", "text": "Ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"}, {"id": "invalid-checksum", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlq"}, {"id": "non-ascii", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlé"}, - {"id": "embedded-newline", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"} + {"id": "embedded-newline", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"}, + {"id": "shared-core-lightning", "text": "cl12testapppppppppppppppppppppppppppppppppppppppppppppppppppprqasqgqsaeje2"} ], "interpolation": [ { @@ -27,7 +28,7 @@ "id": "mismatched-profile", "artifacts": [ "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM", - "cl12testapppppppppppppppppppppppppppppppppppppppppppppppppppprqasqgqsaeje2" + "bip39_12w12testapppppppppppppppppppppppppppnz8ygjskeu3nc" ] }, { diff --git a/tests/data/sharing_vectors.py b/tests/data/sharing_vectors.py index 29f1113..d3a536f 100644 --- a/tests/data/sharing_vectors.py +++ b/tests/data/sharing_vectors.py @@ -1,6 +1,5 @@ -"""Frozen non-ms sharing fixtures. +"""Frozen BIP39 sharing fixtures. -The CL secret payload comes from Core Lightning's published ``peev`` example. The BIP39 secrets use the frozen zero-entropy validation fixtures in ``test_bip39.py``. Each table fixes a 2-point S/A basis and independently recorded C/D codewords. These are data only: production arithmetic is not @@ -9,12 +8,6 @@ """ SHARING_VECTORS = { - "cl": { - "S": "cl12testst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqs3zcw0ee6q2xuc", - "A": "cl12testapppppppppppppppppppppppppppppppppppppppppppppppppppprqasqgqsaeje2", - "C": "cl12testc6umke5r4jpf88qca737v37dw80q96s3hvq4044cpxdennzwu69k848y5zw6tfcdqu", - "D": "cl12testd2d8ya7mnepjxxvsqhwhzwh0cx4vu2rwfzvn4nnspt0a55kcd2uyxt2vd60xm05ygz", - }, "bip39_12w": { "S": "bip39_12w12testsqqqqqqqqqqqqqqqqqqqqqqqqqqc38e6s58qr9lnk", "A": "bip39_12w12testapppppppppppppppppppppppppppnz8ygjskeu3nc", diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index f6323c9..838c542 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -21,7 +21,6 @@ from codex32.bip93 import parse_codex32 from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _with_checksum _parsed = parse_codex32("ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw") assert isinstance(_parsed, MasterSeed) @@ -76,11 +75,9 @@ def _descriptor_info(descriptor: str) -> dict[str, object]: else: normalized = f"tr({key})" return { - "descriptor": _with_checksum(normalized), + "descriptor": f"{normalized}#00000000", "hasprivatekeys": False, - "multipath_expansion": [ - _with_checksum(normalized.replace("<0;1>", str(branch))) for branch in (0, 1) - ], + "multipath_expansion": [f"{normalized.replace('<0;1>', str(branch))}#00000000" for branch in (0, 1)], } diff --git a/tests/test_cli.py b/tests/test_cli.py index 889b175..07124c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,7 +21,6 @@ from codex32 import ( ConfirmationResult, - CoreLightningSecret, CorrectionCandidate, CorrectionContext, MasterSeed, @@ -275,16 +274,12 @@ def test_check_supports_every_registered_application() -> None: assert forbidden not in result.stdout -@pytest.mark.parametrize( - "value", - (SHARING_VECTORS["cl"]["A"], SHARING_VECTORS["cl"]["S"]), -) -def test_check_accepts_shared_core_lightning_artifacts(value: str) -> None: - result = _invoke(["check"], value) +@pytest.mark.parametrize("index", ("a", "s")) +def test_check_rejects_shared_core_lightning_artifacts(index: str) -> None: + result = _invoke(["check"], bech32_encode("cl", _chars_to_u5(f"2test{index}" + "q" * 52), _CODEX32)) - assert result.exit_code == 0 - assert "Core Lightning HSM" in result.stdout - assert result.stderr == "" + assert result.exit_code == 2 + assert "A Core Lightning HSM secret backup must be unshared, with threshold 0." in result.stderr def test_check_does_not_derive_wallet_keys(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1174,13 +1169,8 @@ def answer(_prompt: str) -> str: assert editor.hook is None -def test_share_supports_ms_cl_and_bip39() -> None: +def test_share_supports_ms_and_bip39() -> None: ms = _invoke(["share", "d"], VECTOR_2["share_A"], VECTOR_2["share_C"]) - cl = _invoke( - ["share", "d"], - SHARING_VECTORS["cl"]["A"], - SHARING_VECTORS["cl"]["C"], - ) bip39 = _invoke( ["share", "d"], SHARING_VECTORS["bip39_12w"]["A"], @@ -1189,8 +1179,7 @@ def test_share_supports_ms_cl_and_bip39() -> None: assert ms.exit_code == 0 assert ms.stdout.strip() == VECTOR_2["derived_D"] - assert cl.exit_code == bip39.exit_code == 0 - assert cl.stdout.strip() == SHARING_VECTORS["cl"]["D"] + assert bip39.exit_code == 0 assert bip39.stdout.strip() == SHARING_VECTORS["bip39_12w"]["D"] @@ -1860,14 +1849,6 @@ def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions assert "Add x at position 38, counting backward from the end." in residue.stdout -def test_correct_accepts_valid_shared_core_lightning_artifacts() -> None: - for value in (SHARING_VECTORS["cl"]["A"], SHARING_VECTORS["cl"]["S"]): - result = _invoke(["correct"], value) - - assert result.exit_code == 0 - assert "already valid" in result.stdout - - def test_correct_accepts_a_valid_legacy_core_lightning_secret() -> None: result = _invoke(["correct"], VECTOR_6["codex32_cln2"]) @@ -2135,9 +2116,9 @@ def test_wallet_cli_rejects_non_ms_profiles() -> None: ("wallet",), ): result = ( - _invoke_initialized_wallet(list(command), SHARING_VECTORS["cl"]["S"])[0] + _invoke_initialized_wallet(list(command), VECTOR_6["codex32_peev"])[0] if command[0] == "wallet" - else _invoke(list(command), SHARING_VECTORS["cl"]["S"]) + else _invoke(list(command), VECTOR_6["codex32_peev"]) ) assert result.exit_code != 0 assert "only Bitcoin master seed input" in result.stderr @@ -2309,13 +2290,13 @@ def test_creation_region_retry_keeps_original_ceremony_wallet_source( cli_module = importlib.import_module("codex32.cli") core = _FakeBitcoinCore() stdout, stderr = _TTYOutput(), _TTYOutput() - finished: list[MasterSeed | CoreLightningSecret] = [] + finished: list[MasterSeed] = [] finish = cli_module.CreationCeremony.finish - def remember(ceremony: object) -> MasterSeed | CoreLightningSecret: + def remember(ceremony: object) -> MasterSeed: result = finish(ceremony) finished.append(result) - return cast(MasterSeed | CoreLightningSecret, result) + return cast(MasterSeed, result) def answer(prompt: str, **options: object) -> str: if prompt.startswith("Write this share"): @@ -2781,7 +2762,7 @@ def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind): if kind == "share": artifact = parse_codex32(VECTOR_2["share_A"]) elif kind == "wrong_profile": - artifact = parse_codex32(SHARING_VECTORS["cl"]["S"]) + artifact = parse_codex32(VECTOR_6["codex32_peev"]) candidate = SimpleNamespace(artifact=artifact, search_complete=True, low_checksum_discrimination=False) candidates = () if kind == "none" else (candidate, candidate) if kind == "ambiguous" else (candidate,) monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: candidates) diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index a8edc80..4f7fa74 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_5 +from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_5, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings from hypothesis import strategies as st @@ -150,7 +150,7 @@ def test_every_consecutive_erasure_burst( ( (Profile.MS, VECTOR_1["secret_s"]), (Profile.MS, VECTOR_5["secret_s"]), - (Profile.CL, SHARING_VECTORS["cl"]["S"]), + (Profile.CL, VECTOR_6["codex32_peev"]), (Profile.BIP39_12W, SHARING_VECTORS["bip39_12w"]["S"]), (Profile.BIP39_24W, SHARING_VECTORS["bip39_24w"]["S"]), ), @@ -504,7 +504,7 @@ def test_fixed_input_is_bounded_before_algebra( def test_suspected_profile_is_not_inferred() -> None: result = _correct_fixed( - SHARING_VECTORS["cl"]["S"], + VECTOR_6["codex32_peev"], suspected_profile=Profile.MS, ) assert result is None diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 646e8b5..e9c88e4 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest -from data.bip93_vectors import VECTOR_1, VECTOR_5 -from data.sharing_vectors import SHARING_VECTORS +from data.bip93_vectors import VECTOR_1, VECTOR_5, VECTOR_6 from test_bip39 import BIP39_12W_ZERO, BIP39_24W_ZERO from codex32 import CorrectionContext, MasterSeed, Profile, correct @@ -336,7 +335,7 @@ def test_unknown_target_search_recovers_reachable_valid_length() -> None: ("profile", "source"), ( (Profile.MS, VECTOR_5["secret_s"]), - (Profile.CL, SHARING_VECTORS["cl"]["S"]), + (Profile.CL, VECTOR_6["codex32_peev"]), (Profile.BIP39_12W, BIP39_12W_ZERO), (Profile.BIP39_24W, BIP39_24W_ZERO), ), diff --git a/tests/test_generation.py b/tests/test_generation.py index 878e795..9d20ff4 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -1,11 +1,10 @@ -"""Security invariants for incremental ``ms`` and CL creation ceremonies.""" +"""Security invariants for incremental ``ms`` creation ceremonies.""" import copy import pickle from itertools import combinations import pytest -from _codex32_oracle import oracle_encode from data.bip93_vectors import VECTOR_2, VECTOR_4, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings @@ -13,21 +12,17 @@ import codex32.generation as generation_module from codex32 import ( - CoreLightningSecret, CreationCeremony, MasterSeed, Share, - generate_core_lightning_secret, generate_master_seed, parse_codex32, recover_secret, ) -from codex32.bech32 import _u5_to_chars from codex32.errors import ( CeremonyStateError, CodexError, HeaderCollision, - InvalidIdentifier, InvalidLength, InvalidShareSelection, InvalidThreshold, @@ -37,7 +32,7 @@ from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint -def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed | CoreLightningSecret, tuple[Share, ...]]: +def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed, tuple[Share, ...]]: shares = [] while True: try: @@ -95,18 +90,12 @@ def test_fresh_generation_recovers_at_every_threshold(threshold: int) -> None: assert recover_secret(shares) == secret -@pytest.mark.parametrize(("threshold", "kind"), ((2, 16), (3, "cl"), (9, 64))) -def test_generated_subsets_recover_across_profiles_and_threshold_boundaries( - threshold: int, kind: int | str +@pytest.mark.parametrize(("threshold", "byte_length"), ((2, 16), (3, 32), (9, 64))) +def test_generated_subsets_recover_across_sizes_and_threshold_boundaries( + threshold: int, byte_length: int ) -> None: - ceremony = ( - CreationCeremony.core_lightning(threshold=threshold, share_count=threshold + 2) - if kind == "cl" - else CreationCeremony.master_seed( - byte_length=kind, - threshold=threshold, - share_count=threshold + 2, # type: ignore[arg-type] - ) + ceremony = CreationCeremony.master_seed( + byte_length=byte_length, threshold=threshold, share_count=threshold + 2 ) secret, shares = _complete(ceremony) @@ -197,7 +186,6 @@ def unexpected_normalization(_value: object) -> str: source = generate_master_seed(bytes(range(16)), identifier="test") operations = ( lambda: CreationCeremony.master_seed(threshold=2, indices="a" * 32, identifier="test"), - lambda: CreationCeremony.core_lightning(threshold=2, indices="a" * 32, identifier="test"), lambda: CreationCeremony.from_secret(source, threshold=2, indices="a" * 32, identifier="name"), ) for operation in operations: @@ -353,52 +341,9 @@ def test_resharing_preserves_padding_and_requires_a_new_header() -> None: assert randomized.next_share().header.identifier != secret.header.identifier -@pytest.mark.parametrize("threshold", range(2, 10)) -def test_core_lightning_generation_and_splitting_target_zero_padding(threshold: int) -> None: - fresh, shares = _complete( - CreationCeremony.core_lightning( - identifier="peev", threshold=threshold, indices=ORDINARY_INDICES[:threshold] - ) - ) - assert isinstance(fresh, CoreLightningSecret) - assert fresh.payload_symbols[-1] & 15 == 0 - assert recover_secret(shares) == fresh - - if threshold == 2: - raw = generate_core_lightning_secret(bytes(range(32)), identifier="name") - split, split_shares = _complete( - CreationCeremony.from_secret(raw, threshold=2, indices="ac", identifier="test") - ) - assert split.payload_symbols == raw.payload_symbols - assert recover_secret(split_shares).secret_bytes == bytes(range(32)) - - -def test_resharing_preserves_nonzero_core_lightning_padding() -> None: - source = parse_codex32(VECTOR_6["codex32_peev"]) - assert isinstance(source, CoreLightningSecret) - payload = (*source.payload_symbols[:-1], source.payload_symbols[-1] | 15) - nonzero = parse_codex32(oracle_encode("cl", "0peevs" + _u5_to_chars(payload))) - assert isinstance(nonzero, CoreLightningSecret) - secret, shares = _complete( - CreationCeremony.from_secret(nonzero, threshold=2, indices="ac", identifier="name") - ) - assert secret.payload_symbols == nonzero.payload_symbols - assert recover_secret(shares).payload_symbols == nonzero.payload_symbols - - -def test_core_lightning_generation_validates_size_and_identifier() -> None: - for value in (b"x" * 31, b"x" * 33): - with pytest.raises(InvalidLength): - generate_core_lightning_secret(value, identifier="test") - with pytest.raises(TypeError): - generate_core_lightning_secret("x" * 32, identifier="test") # type: ignore[arg-type] - assert len(generate_core_lightning_secret().header.identifier) == 4 - with pytest.raises(InvalidIdentifier): - generate_core_lightning_secret(identifier="bad") - - def test_from_secret_rejects_non_secret_artifacts() -> None: artifacts = ( + parse_codex32(VECTOR_6["codex32_peev"]), parse_codex32(SHARING_VECTORS["bip39_12w"]["S"]), parse_codex32(VECTOR_2["share_A"]), VECTOR_2["secret_S"], diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca9..c70174d 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -26,6 +26,7 @@ ) from codex32.bip93 import _checksum_for_encoded_length from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.correction import _correct_fixed from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -169,6 +170,15 @@ def test_core_lightning_constructor_and_parsed_padding() -> None: assert parsed.secret_bytes == original.secret_bytes +@pytest.mark.parametrize("index", ("a", "s")) +def test_core_lightning_rejects_shares_and_shared_secrets(index: str) -> None: + # Core Lightning imports only threshold-0 HSM secrets, so correction never suggests one. + shared = _oracle_encode("cl", f"2test{index}" + "q" * 52) + with pytest.raises(InvalidThreshold, match="must be unshared"): + parse_codex32(shared) + assert _correct_fixed(shared, suspected_profile="cl") is None + + @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 6d54c9b..ab6e774 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -10,7 +10,6 @@ from hypothesis import strategies as st from codex32 import ( - CoreLightningSecret, MasterSeed, Share, derive_share, @@ -36,7 +35,7 @@ from codex32.profiles.ms32 import SEED_BYTE_LENGTHS -def _payload_text(artifact: Share | MasterSeed | CoreLightningSecret) -> str: +def _payload_text(artifact: Share | MasterSeed) -> str: return _u5_to_chars(artifact.payload_symbols) @@ -112,9 +111,9 @@ def test_every_threshold_recovers_exactly_k_shares(threshold: int) -> None: def test_share_set_mismatches_have_distinct_errors() -> None: ms_secret, ms_masks = _ms_basis() - cl = parse_codex32(oracle_encode("cl", "2testa" + "q" * 52)) + opaque = parse_codex32(oracle_encode("xy", "2testa" + "q" * len(ms_secret.payload_symbols))) with pytest.raises(MismatchedProfile): - derive_share([ms_secret, cl], "c") # type: ignore[list-item] + derive_share([ms_secret, opaque], "c") # type: ignore[list-item] threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload_symbols))) with pytest.raises(MismatchedThreshold): @@ -186,7 +185,7 @@ def test_every_ordinary_index_can_be_a_fresh_target() -> None: assert derived.header.index == target -@pytest.mark.parametrize("profile", ("cl", "bip39_12w", "bip39_24w")) +@pytest.mark.parametrize("profile", ("bip39_12w", "bip39_24w")) def test_frozen_profile_sharing_vectors(profile: str) -> None: vector = SHARING_VECTORS[profile] secret = parse_codex32(vector["S"]) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 7809862..3186dd3 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,66 +1,10 @@ """Wallet interoperability is stateless and accepts only validated ms secrets.""" import pytest -from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_5 +from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_5, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS -from codex32 import ( - MasterSeed, - core_descriptors, - master_xprv, - parse_codex32, -) -from codex32.wallet import _with_checksum - -_MAIN_DESCRIPTORS = ( - ( - "pkh([3f3521a6/44h/0h/0h]" - "xpub6CeZ5XxHp6rXSwi2GCi7UT25rswWQtoPvj36MbzRBr3QEoEmBFNGgnMy329ZMk" - "fjRKBZHtKKpYfpkrPWohTjHZZn7y1NR9EHnojaGLKdMAR/<0;1>/*)#smv8ra2a" - ), - ( - "sh(wpkh([3f3521a6/49h/0h/0h]" - "xpub6D9YUddFXuNKQvNrT9RQh8ueiTvHwF3RzdgU6uTEri73WTnBpKaDCGhTUiPBTy" - "VJxtR5u2atDmCHE7tw369ahXddCNqJBxFpseud3j7pjX8/<0;1>/*))#gylcnnd3" - ), - ( - "wpkh([3f3521a6/84h/0h/0h]" - "xpub6CNhWVRpA49Bz3LSaBibGqfBV4qa5NH1CStbQfsxWKScwrws5jioMunWKj2uM2" - "rrfdJSroNuJBNDUmmdYXQw5LwVro39pH5nqEgAqrzTPyc/<0;1>/*)#zy06y40v" - ), - ( - "tr([3f3521a6/86h/0h/0h]" - "xpub6C5pT77VWNhWvrB3TqSEbpm7NCpMYEzbJreYbB68RCUoAMkT7rdhafinmdKL4M5" - "275TyDqNAWCnssYnDNaPoXMiAg3sWvCgAiYqY8dHk1k4/<0;1>/*)#r8r04qrm" - ), -) - - -class _FakePublicDeriver: - def fingerprint(self, secret: MasterSeed) -> bytes: - del secret - return bytes.fromhex("3f3521a6") - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | str = 0, - ) -> tuple[dict[str, object], ...]: - del secret - if wallet != "signer": - raise AssertionError("unexpected wallet") - if account != 0: - raise AssertionError("unexpected frozen descriptor request") - return tuple({"desc": desc, "active": True, "timestamp": timestamp} for desc in _MAIN_DESCRIPTORS) - - -def _master() -> MasterSeed: - artifact = parse_codex32(VECTOR_1["secret_s"]) - assert isinstance(artifact, MasterSeed) - return artifact +from codex32 import MasterSeed, master_xprv, parse_codex32 @pytest.mark.parametrize("vector", (VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_5)) @@ -71,72 +15,22 @@ def test_master_xprv_matches_bip93_vectors(vector: dict[str, str]) -> None: assert master_xprv(secret) == vector["xprv"] -def test_public_core_descriptors_are_fixed_and_private_free() -> None: - records = core_descriptors(_master(), integration=_FakePublicDeriver(), wallet="signer") - - assert len(records) == 4 - assert [record["desc"].split("(", 1)[0] for record in records] == [ - "pkh", - "sh", - "wpkh", - "tr", - ] - assert all(record["active"] is True for record in records) - assert all(record["timestamp"] == 0 for record in records) - assert all("xpub" in str(record["desc"]) for record in records) - assert all("xprv" not in str(record["desc"]) for record in records) - assert records[0]["desc"] == ( - "pkh([3f3521a6/44h/0h/0h]" - "xpub6CeZ5XxHp6rXSwi2GCi7UT25rswWQtoPvj36MbzRBr3QEoEmBFNGgnMy329ZMk" - "fjRKBZHtKKpYfpkrPWohTjHZZn7y1NR9EHnojaGLKdMAR/<0;1>/*)#smv8ra2a" - ) - - -def test_private_core_descriptors_use_root_xprv_and_explicit_inputs() -> None: - records = core_descriptors(_master(), account=3, testnet=True, private=True, timestamp=123) - - assert all(record["timestamp"] == 123 for record in records) - for purpose, record in zip((44, 49, 84, 86), records, strict=True): - descriptor = str(record["desc"]) - assert "tprv" in descriptor and "tpub" not in descriptor - assert f"/{purpose}h/1h/3h/<0;1>/*" in descriptor - - -def test_core_descriptors_accept_bitcoin_core_now_timestamp() -> None: - assert all( - record["timestamp"] == "now" - for record in core_descriptors( - _master(), timestamp="now", integration=_FakePublicDeriver(), wallet="signer" - ) - ) - +def test_master_xprv_separates_test_network_serialization() -> None: + secret = parse_codex32(VECTOR_1["secret_s"]) + assert isinstance(secret, MasterSeed) -def test_descriptor_checksum_matches_published_example() -> None: - assert _with_checksum("raw(deadbeef)") == "raw(deadbeef)#89f8spxm" + assert master_xprv(secret, testnet=True).startswith("tprv") @pytest.mark.parametrize( "invalid", ( - parse_codex32(SHARING_VECTORS["cl"]["S"]), + parse_codex32(VECTOR_6["codex32_peev"]), parse_codex32(SHARING_VECTORS["bip39_12w"]["S"]), parse_codex32(VECTOR_2["share_A"]), b"not an artifact", ), ) def test_wallet_boundary_rejects_every_non_master_seed(invalid: object) -> None: - for function in (master_xprv, core_descriptors): - with pytest.raises(TypeError, match="only MasterSeed"): - function(invalid) # type: ignore[arg-type] - - -@pytest.mark.parametrize("account", (-1, 2**31, True, "0")) -def test_account_is_explicitly_bounded(account: object) -> None: - with pytest.raises((TypeError, ValueError)): - core_descriptors(_master(), account=account, integration=_FakePublicDeriver(), wallet="signer") # type: ignore[arg-type] - - -@pytest.mark.parametrize("timestamp", (-1, True, "yesterday")) -def test_timestamp_is_a_supported_core_value(timestamp: object) -> None: - with pytest.raises((TypeError, ValueError)): - core_descriptors(_master(), timestamp=timestamp) # type: ignore[arg-type] + with pytest.raises(TypeError, match="only MasterSeed"): + master_xprv(invalid) # type: ignore[arg-type] diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 56c9742..9e125c8 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -18,7 +18,7 @@ from codex32._bitcoin_core import BitcoinCore from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import core_descriptors +from codex32.wallet import master_xprv # Frozen public BIP93 vector material; it has never controlled a funded wallet. _SEED = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" @@ -225,9 +225,8 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if rpc("gettransaction", spend, wallet="restore")["confirmations"] < 1: raise RuntimeError("recovered wallet did not sign and broadcast") - main_private = core_descriptors(secret, private=True, timestamp=0) - test_private = core_descriptors(secret, testnet=True, private=True, timestamp=0) - if "xprv" not in json.dumps(main_private) or "tprv" not in json.dumps(test_private): + mainnet, test_network = master_xprv(secret), master_xprv(secret, testnet=True) + if not mainnet.startswith("xprv") or not test_network.startswith("tprv"): raise RuntimeError("mainnet/test-network root serialization was not separated") print(json.dumps({"bitcoin_core": network["subversion"], "status": "pass"})) diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 3d276ed..0a4fbef 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -10,7 +10,6 @@ CreationCeremony, MasterSeed, Profile, - core_descriptors, correct, derive_share, master_xprv, @@ -45,9 +44,6 @@ def main() -> None: secret = parse_codex32(_SECRET) assert isinstance(secret, MasterSeed) assert master_xprv(secret) == _XPRV - private = core_descriptors(secret, private=True) - assert len(private) == 4 - assert all("xprv" in record["desc"] for record in private) assert "bip32" not in sys.modules assert "coincurve" not in sys.modules