Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 29 additions & 42 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Comment thread
BenWestgate marked this conversation as resolved.
- 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.
Expand All @@ -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
Expand All @@ -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 |
Expand All @@ -142,51 +142,48 @@ 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.

## 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.

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
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 1 addition & 4 deletions src/codex32/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
77 changes: 0 additions & 77 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import json
import re
import shutil
import subprocess
from collections.abc import Callable
Expand All @@ -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):
Expand All @@ -26,9 +24,7 @@ class BitcoinCoreError(Exception):
("regtest", "regtest"),
)

_ORIGIN = re.compile(r"\[(?P<fingerprint>[0-9a-f]{8})(?P<path>(?:/[0-9]+[h']?)*)\]")
_PRIVATE_MARKERS = ("xprv", "tprv")
_PURPOSES = (44, 49, 84, 86)
_OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m")


Expand Down Expand Up @@ -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):
Expand Down
6 changes: 1 addition & 5 deletions src/codex32/checksums.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Immutable checksum specifications used by codex32 and descriptors."""
"""Immutable checksum specifications used by codex32."""

from dataclasses import dataclass

Expand All @@ -16,7 +16,6 @@
0x0C577EAECCF1990D13C,
0x1887F74F8DC71B10651,
)
_DESCSUM_GEN = (0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading