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
21 changes: 11 additions & 10 deletions docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ The format layer first validates ASCII, case, separator, characters, and the
absolute size bound. The application parser selects the checksum type from the
generic encoded length and validates the common header before verifying the
outer checksum. Only afterward does an `ms` HRP add the BIP93 master-seed
length rule and the `MasterSeed` type for S. Every other HRP crosses the
boundary as a base `Secret` or `Share`, with normalized `hrp` and no invented
length rule and the `MasterSeed` type for S. Every other string, including
`ms` shares, crosses the boundary as a `Codex32String`, with normalized `hrp` and no invented
payload meaning. Applications with their own HRP add their rules on top of
these generic artifacts.

Expand Down Expand Up @@ -51,7 +51,8 @@ generic parse-length failure.

### Boundaries

- An artifact stores only its validated string; its header, HRP and payload are decoded from it.
- A `Codex32String` stores only its validated string `s`; `hrp`, `k`, `ident`,
`share_idx`, `payload` and `checksum` are slices of it. S has index `s`.
- `bech32.py` keeps the recognizable BIP173 reference names and accumulator
structure. Encoding takes an explicit checksum specification. Decoding
without one returns the HRP and complete data part after checking only the
Expand All @@ -60,10 +61,10 @@ generic parse-length failure.
codex32 headers, or decode application payloads.
- The library knows only BIP93's `ms` application. Other HRPs stay generic;
there is no profile registry.
- Headers and artifacts are immutable; shares expose symbols, not bytes.
- Artifacts are immutable; only `MasterSeed` exposes bytes (`data`).
- Artifact `str()` and `repr()` render redacted placeholders. Code that
intentionally serializes a validated `Share` or `Secret` must use its explicit
`.text` attribute.
intentionally serializes a validated `Codex32String` must use its explicit
`.s` attribute.
- Sharing interpolates payload and checksum together, explicitly constructs the
target header, and reparses the result.
- `generation.py` is the only entropy owner; it generates and shares only `ms`.
Expand Down Expand Up @@ -118,7 +119,7 @@ its own lengths or payload checks applies them to those artifacts itself.
|---|---:|---:|
| parse S/share, recover, derive shares | yes | yes |
| fixed BCH and bounded structural correction | yes | yes |
| semantic S bytes (`MasterSeed.seed_bytes`) | no | 16, 20, 24, 28, 32, or 64 |
| semantic S bytes (`MasterSeed.data`) | no | 16, 20, 24, 28, 32, or 64 |
| generation, ceremonies, existing-S splitting | no | six supported sizes |
| wallet API and `ms32` | no | yes |

Expand Down Expand Up @@ -251,12 +252,12 @@ shape; registered semantics are reapplied when every output is reparsed.
### Public operations

```python
recover_secret(shares: Sequence[Share]) -> Secret
recover_secret(shares: Sequence[Codex32String]) -> Codex32String

derive_share(
basis: Sequence[Share | Secret],
basis: Sequence[Codex32String],
fresh_index: str,
) -> Share
) -> Codex32String
```

Recovery accepts exactly `k` ordinary shares. Derivation accepts exactly `k`
Expand Down
2 changes: 1 addition & 1 deletion docs/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
These rules are mandatory. The [security model](model.md) defines their limits
and evidence.

1. Only the parser and `MasterSeed.from_seed` validate text. Generic container
1. Constructing a `Codex32String` or `MasterSeed` validates its text. Generic container
and header checks precede the checksum; the `ms` length rule follows it.
Other HRPs remain generic and never gain invented application semantics.
2. APIs accept only immutable validated artifacts. Outputs are reparsed;
Expand Down
6 changes: 3 additions & 3 deletions docs/security/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive

| Boundary | Security requirement |
|---|---|
| Untrusted text | Text remains untrusted until the parser or `MasterSeed.from_seed` returns a validated artifact. |
| Untrusted text | Text remains untrusted until a `Codex32String` or `MasterSeed` constructor accepts it. |
| Package API | Recovery, derivation, sharing, and wallet operations accept validated artifacts, not unchecked text. |
| Python process | Secret objects remain in process memory; normal output must not reveal them unintentionally. |
| Operator and paper | The operator controls transcription, physical recovery cards, wallet records, and acceptance of correction suggestions. |
Expand Down Expand Up @@ -91,9 +91,9 @@ meaning.
Artifacts are immutable and store only their validated string; the header,
HRP and payload are decoded from it, so they cannot disagree. A share has symbol semantics and cannot be converted to bytes.
Default `str()` and `repr()` rendering of artifacts is redacted; callers must
use `.text` explicitly when they intentionally need the serialized recovery
use `.s` explicitly when they intentionally need the serialized recovery
string. This reduces accidental disclosure through logs and interpolation but
does not make `.text` safe to expose.
does not make `.s` safe to expose.
Generic parsing, recovery, derivation, and correction work for any HRP.
Wallet and generation APIs accept only `MasterSeed`, never other HRPs'
artifacts or raw strings.
Expand Down
8 changes: 2 additions & 6 deletions src/codex32/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
"""Small, typed BIP93 and codex32 reference API."""

from .bip93 import (
Header,
Codex32String,
MasterSeed,
Secret,
Share,
derive_share,
parse_codex32,
recover_secret,
Expand All @@ -26,17 +24,15 @@
from .wallet import master_xprv

__all__ = [
"Codex32String",
"CodexError",
"ConfirmationResult",
"CorrectionCandidate",
"CorrectionContext",
"CorrectionEdit",
"CreationCeremony",
"Header",
"InvalidCorrectionInput",
"MasterSeed",
"Secret",
"Share",
"WorksheetCorrection",
"correct",
"correct_worksheet_residue",
Expand Down
4 changes: 2 additions & 2 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def fingerprint(self, secret: MasterSeed) -> bytes:
"""Return the BIP32 master fingerprint for a validated master seed."""
if not isinstance(secret, MasterSeed):
raise TypeError("wallet operations accept only MasterSeed")
return self.fingerprint_seed(secret.seed_bytes)
return self.fingerprint_seed(secret.data)

def verify_identity(self, secret: MasterSeed, expected_fingerprint: bytes | None) -> None:
"""Refuse a recovered seed that is not the recorded wallet before any wallet is touched."""
Expand Down Expand Up @@ -200,7 +200,7 @@ def _target(self, name: str) -> tuple[bool, bool] | None:
return (unlocked is not None, unlocked == 0) if eligible else None

def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None:
root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main")
root = _master_xprv_from_seed(secret.data, testnet=self.chain != "main")
added = self._rpc("addhdkey", wallet=wallet, stdin=root + "\n")
xpub = added.get("xpub") if isinstance(added, dict) else None
if not isinstance(xpub, str) or not xpub.startswith("xpub" if self.chain == "main" else "tpub"):
Expand Down
63 changes: 31 additions & 32 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
import os
import sys
from collections.abc import Callable, Iterator, Sequence
from typing import Any, Literal, cast
from typing import Any, Literal

from codex32.bip93 import (
MS,
Codex32String,
MasterSeed,
Secret,
Share,
_text_length,
_validate_ms_length,
_validate_recovery_prefix,
Expand All @@ -31,7 +30,6 @@
InvalidLength,
)

Artifact = Share | Secret
_MAX_INPUT = 9 * 1025

_line_editor: Any
Expand Down Expand Up @@ -176,7 +174,7 @@ def _card_text(text: str, highlight: bool = True, observed: str = "") -> str:

def _confirm_correction(
candidate: CorrectionCandidate,
accepted: list[Artifact],
accepted: list[Codex32String],
basis: bool,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
) -> bool | None:
Expand All @@ -186,12 +184,12 @@ def _confirm_correction(
preview = artifact
try:
if (
isinstance(artifact, Share)
artifact.share_idx != "s"
and artifact.hrp == MS
and not basis
and (len(accepted) + 1 == artifact.header.threshold)
and (len(accepted) + 1 == int(artifact.k))
):
preview = recover_secret(cast(list[Share], [*accepted, artifact]))
preview = recover_secret([*accepted, artifact])
fingerprint_text = (
f"Master fingerprint: {fingerprint(preview).hex().upper()}\n\n"
if isinstance(preview, MasterSeed) and fingerprint is not None
Expand All @@ -200,7 +198,7 @@ def _confirm_correction(
except CodexError:
_stderr("Rejected: Could not recover a valid Bitcoin master seed using this correction.")
return None
_stderr(f"Possible correction:\n\n{fingerprint_text}{_card_text(artifact.text, sys.stderr.isatty())}\n")
_stderr(f"Possible correction:\n\n{fingerprint_text}{_card_text(artifact.s, sys.stderr.isatty())}\n")
return _confirmation_input(
"Does this entire string exactly match your recovery card? [y/N]: "
).strip().lower() in ("y", "yes")
Expand All @@ -226,7 +224,7 @@ def _render_groups(
).rstrip()


def _parse(value: str, hrp: str | None) -> Artifact:
def _parse(value: str, hrp: str | None) -> Codex32String:
try:
artifact = parse_codex32(value)
except InvalidChecksum as error:
Expand Down Expand Up @@ -267,10 +265,10 @@ def _prefix_for_suffix(prefix: str, suffix: str) -> str:
return prefix


def _accepted_prefix(accepted: list[Artifact]) -> str:
def _accepted_prefix(accepted: list[Codex32String]) -> str:
first = accepted[0]
prefix = f"{first.hrp}1{first.header.threshold}{first.header.identifier}"
return prefix.upper() if all(artifact.text.isupper() for artifact in accepted) else prefix
prefix = f"{first.hrp}1{first.k}{first.ident}"
return prefix.upper() if all(artifact.s.isupper() for artifact in accepted) else prefix


def _display_prefix(prefix: str, grouped: bool) -> str:
Expand All @@ -292,10 +290,10 @@ def _fingerprint_matcher(

def matches(candidate: CorrectionCandidate) -> bool | None:
artifact = candidate.artifact
if not isinstance(artifact, MasterSeed) or artifact.header.threshold:
if not isinstance(artifact, MasterSeed) or artifact.k != "0":
return None
try:
return _fingerprint_identifier(fingerprint(artifact)) == artifact.header.identifier
return _fingerprint_identifier(fingerprint(artifact)) == artifact.ident
except CodexError:
return None

Expand All @@ -306,7 +304,7 @@ def _suggestions(
value: str,
prefix: str,
required_hrp: str | None,
accepted: Sequence[Artifact],
accepted: Sequence[Codex32String],
*,
allowed: Callable[[CorrectionCandidate], bool] | None = None,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
Expand All @@ -327,7 +325,7 @@ def _corrections(
value: str,
prefix: str,
required_hrp: str | None,
accepted: Sequence[Artifact],
accepted: Sequence[Codex32String],
*,
allowed: Callable[[CorrectionCandidate], bool] | None = None,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
Expand All @@ -338,11 +336,12 @@ def _corrections(
hrp = value[: max(separator, 0)].lower()
if separator <= 0 or required_hrp not in (None, hrp):
return (), True
target = len(accepted[0].text) if accepted else None
target = len(accepted[0].s) if accepted else None
if isinstance(byte_length, int):
target = _text_length(byte_length)
immutable = prefix if prefix and value.lower().startswith(prefix.lower()) else value[: separator + 1]
excluded = tuple(artifact.header.index for artifact in accepted)
# A derivation basis may hold the secret S, which is never a correction candidate's index.
excluded = tuple(artifact.share_idx for artifact in accepted if artifact.share_idx != "s")
context = CorrectionContext(hrp, target, _prefix_for_suffix(immutable, value), excluded)
try:
candidates, complete = _correct_complete(context, value, allowed=allowed, competitors=True)
Expand All @@ -355,17 +354,17 @@ def _corrections(


def _validate_operational_artifact(
artifact: Artifact,
accepted: list[Artifact],
artifact: Codex32String,
accepted: list[Codex32String],
*,
basis: bool,
one: bool,
excluded_index: str | None,
) -> None:
if basis and artifact.header.index == excluded_index:
if basis and artifact.share_idx == excluded_index:
raise ExistingTargetIndex("That index was requested for the additional share.")
if not one and (accepted or isinstance(artifact, Share) or basis):
recovering = not basis and isinstance(artifact, Share)
if not one and (accepted or artifact.share_idx != "s" or basis):
recovering = not basis and artifact.share_idx != "s"
if recovering:
_validate_recovery_prefix([*accepted, artifact])
else:
Expand All @@ -379,13 +378,13 @@ def _redirected(
one: bool,
excluded_index: str | None,
fingerprint: Callable[[MasterSeed], bytes] | None,
) -> list[Artifact]:
) -> list[Codex32String]:
tokens = _stdin().split()
if not tokens:
raise InputError("No input was provided.")
if len(tokens) > 9:
raise InputError("At most nine codex32 strings may be provided at once.")
accepted: list[Artifact] = []
accepted: list[Codex32String] = []
for token in tokens:
try:
artifact = _parse(token, hrp)
Expand Down Expand Up @@ -438,12 +437,12 @@ def _interactive(
hrp: str | None,
initial_prefix: str,
fingerprint: Callable[[MasterSeed], bytes] | None,
) -> list[Artifact]:
accepted: list[Artifact] = []
) -> list[Codex32String]:
accepted: list[Codex32String] = []
prefix = initial_prefix
prefill, required, grouped_prefix = "", 1, False

def validate(artifact: Artifact) -> None:
def validate(artifact: Codex32String) -> None:
_validate_operational_artifact(
artifact,
accepted,
Expand Down Expand Up @@ -511,10 +510,10 @@ def allowed(candidate: CorrectionCandidate) -> bool:
prefill = ""
if one:
return [artifact]
if isinstance(artifact, Secret) and not basis:
if artifact.share_idx == "s" and not basis:
return [artifact]
if not accepted:
required = artifact.header.threshold
required = int(artifact.k)
accepted.append(artifact)
prefix = _accepted_prefix(accepted)
grouped_prefix = len(entered.split()) > 1
Expand All @@ -531,7 +530,7 @@ def read_artifacts(
hrp: str | None = None,
initial_prefix: str = "",
fingerprint: Callable[[MasterSeed], bytes] | None = None,
) -> list[Artifact]:
) -> list[Codex32String]:
if not sys.stdin.isatty():
return _redirected(
hrp,
Expand Down
13 changes: 6 additions & 7 deletions src/codex32/_competitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,10 @@ def _covered_candidates(
# Fixed BCH was completed first, including a failed/ineligible result.
return () if fixed is None else (fixed,)
for candidate in results:
if candidate.artifact.hrp != state.context.hrp or len(candidate.artifact.text) != state.target:
if candidate.artifact.hrp != state.context.hrp or len(candidate.artifact.s) != state.target:
continue
known_distance = sum(
a in CHARSET and a != b
for a, b in zip(observed, candidate.artifact.text[state.immutable :].lower())
a in CHARSET and a != b for a, b in zip(observed, candidate.artifact.s[state.immutable :].lower())
)
if explicit + known_distance + changed < 9:
return (candidate,)
Expand All @@ -76,7 +75,7 @@ def _known_swaps(
) -> Iterator[_View]:
"""Enumerate only swap scripts that could explain this known artifact."""
source = tuple(CHARSET.index(c.lower()) for c in state.text[state.base :])
target = tuple(CHARSET.index(c.lower()) for c in candidate.artifact.text[state.base :])
target = tuple(CHARSET.index(c.lower()) for c in candidate.artifact.s[state.base :])
boundary = state.immutable - state.base
initial = _View(source, ((0, len(source)),), len(source))
seen: set[tuple[tuple[int, ...], tuple[str, ...]]] = set()
Expand Down Expand Up @@ -138,14 +137,14 @@ def _competitor_views(
distance = sum(
a in CHARSET and a != b
for a, b in zip(
state.text[state.immutable :].lower(), candidate.artifact.text[state.immutable :].lower()
state.text[state.immutable :].lower(), candidate.artifact.s[state.immutable :].lower()
)
)
if distance > shape.unit * (2 * (shape.adjacent + shape.distant) + shape.corrupted) + substitutions:
return iter(())
if not shape.corrupted:
observed = Counter(c for c in state.text[state.immutable :].lower() if c in CHARSET)
target = Counter(candidate.artifact.text[state.immutable :].lower())
target = Counter(candidate.artifact.s[state.immutable :].lower())
if sum((observed - target).values()) > substitutions:
return iter(())
if shape.unit == 1 and not (shape.inserted or shape.omitted) and erasures == 0:
Expand Down Expand Up @@ -177,7 +176,7 @@ def _search_competitors(
seed_candidates: Sequence[CorrectionCandidate] = (),
optional_only: bool = False,
) -> tuple[tuple[CorrectionCandidate, ...], bool]:
results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates}
results = {candidate.artifact.s.lower(): candidate for candidate in seed_candidates}
fixed: dict[int, CorrectionCandidate | None] = {}
completed: set[_Layer] = set()
try:
Expand Down
Loading
Loading