diff --git a/docs/developer/api.md b/docs/developer/api.md index 4a290d8..f66a24a 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 @@ -67,13 +67,13 @@ 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 and generates only `ms`; it can + also share an existing `cl` secret. - 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 +100,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 @@ -121,7 +121,7 @@ base artifact types rather than falling back to a registered application. | 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 | +| 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 | | wallet API | no | S only | no | no | @@ -142,11 +142,10 @@ 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 codex32 HSM secrets. CLN-produced codex32 S strings +use its zero-padding convention; parsed nonzero discarded bits remain valid and +are preserved when re-sharing. The generic `codex32` façade supports CL and BIP39 inspection, correction, recovery, and share derivation. The `ms32` façade accepts only `ms` artifacts. @@ -154,14 +153,14 @@ 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. +master seeds and splits a validated master seed or Core Lightning HSM secret. Core Lightning now defaults to mnemonic recovery, but retains a codex32 HSM secret import path for recovery on an unused node. 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 +168,13 @@ 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) ``` -Shared creation uses `CreationCeremony.master_seed(...)`, -`CreationCeremony.core_lightning(...)`, or +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 +182,8 @@ 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. CLI creation does not accept Core Lightning profiles; sharing +an existing CL secret remains API-only. 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 @@ -578,27 +575,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 @@ -638,7 +625,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. @@ -679,7 +666,7 @@ 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 | @@ -703,9 +690,9 @@ 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. A random identifier does not make a weak + supplied seed safe. - 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 2cd177f..a41db8a 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -119,9 +119,9 @@ 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. Existing CL secrets may still +be parsed, recovered, inspected, corrected, derived, and re-shared through the +generic API; fresh CL generation is not supported. 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). 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 cfec702..16ac89e 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import re import shutil import subprocess from collections.abc import Callable @@ -11,7 +10,6 @@ from codex32._bip32 import _master_xprv_from_seed from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _descriptor_records class BitcoinCoreError(Exception): @@ -26,9 +24,7 @@ class BitcoinCoreError(Exception): ("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") @@ -154,79 +150,6 @@ def fingerprint(self, secret: MasterSeed) -> bytes: raise TypeError("wallet operations accept only MasterSeed") return self.fingerprint_seed(secret.seed_bytes) - 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/checksums.py b/src/codex32/checksums.py index 19262d1..95bf742 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -1,4 +1,4 @@ -"""Immutable checksum specifications used by codex32 and descriptors.""" +"""Immutable checksum specifications used by codex32.""" from dataclasses import dataclass @@ -16,7 +16,6 @@ 0x0C577EAECCF1990D13C, 0x1887F74F8DC71B10651, ) -_DESCSUM_GEN = (0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD) @dataclass(frozen=True, slots=True) @@ -52,9 +51,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/generation.py b/src/codex32/generation.py index f135d19..577a2c6 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 of supported secrets.""" from __future__ import annotations @@ -29,11 +29,7 @@ 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 CoreLightningSecret from codex32.profiles.cl32 import ( _has_generation_padding as _cl_padding, ) @@ -175,14 +171,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.""" @@ -256,28 +244,6 @@ 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, diff --git a/src/codex32/profiles/cl32.py b/src/codex32/profiles/cl32.py index ca5b757..f1cf00f 100644 --- a/src/codex32/profiles/cl32.py +++ b/src/codex32/profiles/cl32.py @@ -3,11 +3,11 @@ from __future__ import annotations from codex32.bech32 import convertbits -from codex32.bip93 import Header, Secret, _from_parts +from codex32.bip93 import Secret from codex32.errors import InvalidLength from codex32.profiles import Profile -SECRET_BYTES, PAYLOAD_LENGTH, TEXT_LENGTH = 32, 52, 74 +PAYLOAD_LENGTH, TEXT_LENGTH = 52, 74 def _has_generation_padding(secret: CoreLightningSecret) -> bool: @@ -25,21 +25,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 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/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 72d6676..2c2cf8b 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -12,7 +12,6 @@ from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _with_checksum _parsed = parse_codex32("ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw") assert isinstance(_parsed, MasterSeed) @@ -60,11 +59,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_generation.py b/tests/test_generation.py index 878e795..44ec1d9 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -17,7 +17,6 @@ CreationCeremony, MasterSeed, Share, - generate_core_lightning_secret, generate_master_seed, parse_codex32, recover_secret, @@ -27,7 +26,6 @@ CeremonyStateError, CodexError, HeaderCollision, - InvalidIdentifier, InvalidLength, InvalidShareSelection, InvalidThreshold, @@ -100,7 +98,11 @@ def test_generated_subsets_recover_across_profiles_and_threshold_boundaries( threshold: int, kind: int | str ) -> None: ceremony = ( - CreationCeremony.core_lightning(threshold=threshold, share_count=threshold + 2) + CreationCeremony.from_secret( + parse_codex32(VECTOR_6["codex32_peev"]), # type: ignore[arg-type] + threshold=threshold, + share_count=threshold + 2, + ) if kind == "cl" else CreationCeremony.master_seed( byte_length=kind, @@ -197,7 +199,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,26 +354,6 @@ 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) @@ -386,17 +367,6 @@ def test_resharing_preserves_nonzero_core_lightning_padding() -> None: 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(SHARING_VECTORS["bip39_12w"]["S"]), diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 7809862..8050f02 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -4,63 +4,7 @@ from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_5 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,48 +15,11 @@ 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( @@ -125,18 +32,5 @@ def test_descriptor_checksum_matches_published_example() -> None: ), ) 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 e7c8e3e..a7884a8 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" @@ -221,9 +221,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