diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1fe15af5..ba10150c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,7 +37,12 @@ jobs: - run: python -m ruff format --check . - run: python tools/differential_correction.py --verify - run: python -m build --no-isolation - - run: python tools/verify_wheel_environment.py + - name: Test the built wheel + shell: bash + run: | + python -m pip install --force-reinstall --no-deps dist/codex32-*.whl + python -c "import codex32, pathlib; assert 'site-packages' in pathlib.Path(codex32.__file__).parts" + python -m pytest -q compatibility: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index fd47d954..6565c58d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ concise, with the outcome, relevant evidence, and any remaining limitations. ## Project structure Production code lives in `src/codex32/`; keep modules narrow and do not duplicate -domain logic in `cli.py` or `profiles/`. Tests live in `tests/test_*.py`, with +domain logic in `cli.py`. Tests live in `tests/test_*.py`, with frozen external vectors under `tests/data/`. Durable documentation is grouped under `docs/user/`, `docs/developer/`, and `docs/security/`. Local plans and unfinished reports belong in the ignored `docs/planning/` directory. `tools/` @@ -28,27 +28,19 @@ contains offline verification utilities. ## Development and verification -Use the existing virtual environment when available. Python 3.10 is the minimum; -the supported range is Python 3.10 through 3.15. Install development -dependencies only when needed: -`python -m pip install -e '.[dev]'`. Run the CLI with `codex32 --help`. - -Choose checks according to the changed behavior: - -- Runtime changes: run affected pytest tests, Ruff lint and formatting checks, - and `python -m mypy src/codex32` when types or source code change. -- Changes spanning modules or security boundaries: run `python -m pytest -q` - and `python -O -m pytest -q`; use the relevant differential tools for changes - to correction or wallet derivation. -- Packaging or release changes: run `python -m build` and - `python -m twine check dist/*` in addition to affected checks. -- Documentation or agent-configuration changes: inspect the diff, check links - and configuration syntax as applicable; do not run application tests solely - for prose or instruction edits. - -Use `python -m ruff check .` and `python -m ruff format --check .` for Python -style checks. Once applicable checks pass, repeat or broaden them only for a new -change, failure, or unresolved concern. Report checks that could not run and +Before setting up an environment or choosing checks, read the setup and check +list in `CONTRIBUTING.md`. Use the existing virtual environment when available. +Pick checks by what changed: + +- Runtime changes: affected pytest tests, Ruff, and mypy. +- Changes spanning modules or security boundaries: the full suite, also under + `python -O`, plus the differential tools for correction or wallet derivation. +- Packaging or release changes: the build and `twine check` steps as well. +- Documentation or agent-configuration changes: inspect the diff and check links + and syntax; do not run application tests for prose edits alone. + +Once applicable checks pass, repeat or broaden them only for a new change, +failure, or unresolved concern. Report checks that could not run and pre-existing failures accurately; do not claim unverified success. ## Reviewability and style @@ -61,8 +53,7 @@ avoid comments or tests that restate the implementation. Add or update concise docstrings when changing public behavior. Write codex32 in lowercase except when referring to the Codex32 Book. -Keep the installed package below 5,200 logical review lines, as enforced by the -existing test. New dependencies, public API signature or return-shape changes, +Respect the review-line budgets in `CONTRIBUTING.md`. New dependencies, public API signature or return-shape changes, and lint suppressions require user authorization; an explicit request can already provide that authorization. @@ -86,13 +77,9 @@ Resolve scope questions from the changed code and the invariants first. Ask only if a material security decision remains unclear; identify the decision rather than stopping on general uncertainty. Ask before running a recommended security review. -Follow `docs/developer/AI_POLICY.md` for contributions and `SECURITY.md` for -private vulnerability reporting. Local edits, checks, and atomic commits are -allowed; do not automatically push, open pull requests, write replies, contact -maintainers, or claim authorship. A human publishes contributions. Never commit -to master or push to an open pull request branch without explicit user direction. - -When asked to commit, keep commits focused and independently passing unless the -user explicitly requests a combined commit. Use an imperative subject of at -most 50 characters, followed by rationale, security implications when relevant, -and validation. Use `refs #123` or `fixes #123` when applicable. +Before committing, opening a pull request, or writing an issue, pull request, +or review comment, read `docs/developer/AI_POLICY.md` and the commit and pull +request sections of `CONTRIBUTING.md`; they decide authorship, commit messages, +and who publishes. Local edits, checks, and focused commits are allowed. Never +commit to master or push to an open pull request branch without explicit user +direction. Follow `SECURITY.md` for private vulnerability reporting. diff --git a/README.md b/README.md index fbc128c9..e03e43e3 100644 --- a/README.md +++ b/README.md @@ -47,23 +47,21 @@ On Windows, activate the environment with `.venv\Scripts\activate` instead. The installed commands are `codex32` and `ms32`. -`codex32` checks, corrects, recovers, and derives shares for registered or opaque -application prefixes. `ms32` additionally creates Bitcoin master-seed backups +`codex32` checks, corrects, recovers, and derives shares for any application +prefix. `ms32` additionally creates Bitcoin master-seed backups and provides key and wallet setup. -BIP39 worksheet profiles are supported for existing-backup recovery but are -[not recommended for creating backups](https://secretcodex32.com/docs/index.html). Powerful correction searches, including recovery of genuinely unreadable characters, require interactive confirmation. ## Start here -Start Bitcoin Core 32 or newer with local RPC enabled. codex32 detects and -reports the local Bitcoin Core network. To practice with Bitcoin-Qt on signet, -start it with: +Start Bitcoin Core 32 or newer with local RPC enabled. codex32 uses the +network `bitcoin-cli` is configured for and reports it. To practice on signet, +add `signet=1` to `bitcoin.conf`, then start Bitcoin-Qt with: ```bash -bitcoin-qt -signet -server +bitcoin-qt -server ``` Then run: diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..ae1e22d2 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -5,7 +5,7 @@ The package uses one narrow dependency direction: ```text -text -> format/header/checksum -> optional profile module -> immutable artifact +text -> format/header/checksum -> ms length rule -> immutable artifact |-> BIP93 sharing |-> ms generation |-> bounded correction @@ -17,13 +17,14 @@ CLI -> public APIs above -> private bitcoin-cli subprocess adapter 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 may the HRP select a registered application -profile. The parser then checks its application length and payload semantics. -An unregistered HRP crosses the boundary only as an opaque base `Secret` or -`Share`, with normalized `hrp`, `profile is None`, and no invented payload meaning. +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 +payload meaning. Applications with their own HRP add their rules on top of +these generic artifacts. CLI input diagnostics preserve generic container, header, and length failures. -After a checksum failure, the CLI may report an unsupported profile text length +After a checksum failure, the CLI may report an unsupported `ms` text length instead. This diagnostic does not validate the input or change parser ordering; alignment correction remains available. Frozen-prefix hints do not override a generic parse-length failure. @@ -33,9 +34,8 @@ generic parse-length failure. | Concept | Single owner | Evidence | |---|---|---| | Bech32 characters, container, and `convertbits` | `bech32.py` | `test_bech32.py` | -| codex32 checksum selection and header boundary | `bip93.py` | `test_profiles.py`, BIP93 vectors | +| codex32 checksum selection, header boundary, and `ms` lengths | `bip93.py` | `test_lengths.py`, BIP93 vectors | | checksum and CRC arithmetic | `checksums.py` | official vectors, `test_crc.py` | -| optional application rules and S types | `profiles/ms32.py`, `cl32.py`, `bip39.py` | profile and BIP39 tests | | immutable base artifacts and interpolation | `bip93.py` | BIP93 vectors, `test_sharing.py` | | entropy, masks, identifiers, output indices | `generation.py` | `test_generation.py` | | shared GF(32) arithmetic | `gf32.py` | sharing vectors and correction corpus | @@ -43,7 +43,7 @@ generic parse-length failure. | structural alignment and admission | `indel.py` | `test_correction_indel.py`, `test_correction_capture.py` | | 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 | +| stdlib-only BIP32 root validation and serialization | `_bip32.py` | BIP32 and wallet vectors | | 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` | @@ -51,16 +51,16 @@ generic parse-length failure. ### Boundaries -- Only parsing and profile-specific factories construct artifacts. +- Parsing and `MasterSeed.from_seed` construct artifacts. Direct construction + skips validation and is unsupported; nothing blocks it. - `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 Bech32 container rules; decoding with one also verifies and removes that checksum. The module does not select codex32 checksum lengths, validate codex32 headers, or decode application payloads. -- `profiles/__init__.py` optionally selects one of the registered application - modules. Labels, lengths, padding, diagnostics, and S types stay with their - application; unknown HRPs remain opaque and there is no runtime registration. +- 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. - Artifact `str()` and `repr()` render redacted placeholders. Code that intentionally serializes a validated `Share` or `Secret` must use its explicit @@ -99,7 +99,7 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 22-name package `__all__`; direct use of private helpers is +surface is the package `__all__`; direct use of private helpers is unsupported but remains in the review scope. ### Size budget @@ -109,46 +109,25 @@ blank and comment-only lines while counting subpackages recursively. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. -## Profile and opaque-HRP capabilities +## `ms` and other HRPs -There is no runtime registration. An unknown HRP uses generic codex32 rules and -base artifact types rather than falling back to a registered application. +The library implements BIP93's `ms` application and nothing else. Any other HRP +uses generic codex32 rules and base artifact types; an application that needs +its own lengths or payload checks applies them to those artifacts itself. -| Capability | opaque HRP | `ms` | `cl` | `bip39_12w/24w` | -|---|---:|---:|---:|---:| -| 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 | 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 | no | no | -| wallet API | no | S only | no | no | - -Registered application semantics remain: - -| Capability | `ms` | `cl` | `bip39_12w/24w` | -|---|---:|---:|---:| -| semantic S bytes | 16, 20, 24, 28, 32, or 64 | exactly 32 | no | -| fixed BCH API | yes | yes | yes | -| bounded structural API | yes | yes | yes | +| Capability | other HRP | `ms` | +|---|---:|---:| +| 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 | +| generation, ceremonies, existing-S splitting | no | six supported sizes | +| wallet API and `ms32` | no | yes | `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 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. - -Current Core Lightning defaults to mnemonic recovery, but its recovery command -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 inspection and correction, and BIP39 -inspection, correction, recovery, and share derivation. The `ms32` façade -accepts only `ms` artifacts. +import, and CLI `--bytes` enforce the same six sizes. The generic `codex32` +command checks, corrects, recovers, and derives shares for any HRP; `ms32` +accepts only `ms`. ## Secret generation @@ -239,7 +218,7 @@ case and useful spacing without crossing frozen boundaries. Every newly matching complete group freezes immediately, and the leftmost remaining run is next. Empty and unchanged retries are unsuccessful; retries are unlimited. A completely correct full-string retry confirms through the same callback. -An incorrect retry beginning with the expected profile and separator and longer +An incorrect retry beginning with the expected HRP and separator and longer than the active canonical region is recognized as a full-string attempt. It preserves current progress, clarifies which region remains incorrect, and redisplays that region. Other submissions are aligned locally. @@ -261,7 +240,7 @@ 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. There is no entropy injection, sorting, caller-supplied partial-basis -completion, or BIP39 generation. Ceremonies reject copying and serialization; +completion. Ceremonies reject copying and serialization; the CLI does not resume an interrupted ceremony. ## Recovery and additional-share derivation @@ -296,7 +275,6 @@ Input collections are bounded before at most nine artifacts are copied. 6. Extract the complete payload-plus-checksum tail from each artifact. 7. Interpolate the tail in GF(32) at S or the fresh target. 8. Construct the target header explicitly and reparse the complete string. -9. For BIP39 derivation, first interpolate and validate the implied S. The HRP and common threshold/identifier fields are not interpolated. The target index is explicit. Output is uppercase only when every input is uppercase; @@ -311,29 +289,18 @@ existing checksum symbols produces the checksum for the explicit target index and interpolated payload. This keeps sharing visibly symbol-only and avoids a second checksum-generation step. Reparsing the result is mandatory: it verifies the checksum relationship, restores the immutable artifact boundary, and -applies registered S semantics when the HRP has a profile. +applies the `ms` length rule and `MasterSeed` type. `tests/test_sharing.py` proves that recovery and derivation still work after checksum creation is disabled. Official BIP93 vectors anchor the GF(32) -arithmetic. BIP39 uses compact frozen string/result fixtures rather than +arithmetic. Other HRPs use compact frozen string/result fixtures rather than a duplicate test implementation of interpolation or checksumming. -### BIP39 migration profiles - -Ordinary BIP39 shares are validated only as exact-length codex32 symbol masks. -A recovered S must additionally have zero outer padding and a valid embedded -BIP39 checksum. Derivation validates the implied S before propagating the set. -The public API may recover or derive codex32 artifacts; it never exposes BIP39 -entropy, a mnemonic, generation, or wallet derivation. The generic `codex32` -CLI exposes BIP39 recovery and derivation. Derivation first reconstructs and -validates the implied BIP39 S. BIP39 construction, mnemonic output, and wallet -interpretation remain unavailable. - ### Deliberate Rust-reference differences - Exactly `k` artifacts are required; extra points are not silently accepted. - A requested target that is already present is rejected rather than returned. -- Profile-specific S semantics, including BIP39, are applied after recovery. +- The `MasterSeed` type is applied to a recovered `ms` S. - The target header is constructed from validated common fields rather than obtained by interpolating constant header columns. @@ -348,23 +315,21 @@ sharing, recovery, or wallet APIs. ### Public full-string API -`correct(CorrectionContext(hrp, ...), damaged_text)` supports registered and -opaque HRPs. `hrp` accepts a lowercase-insensitive string or `Profile` and is -stored as a normalized lowercase string. The context fixes that HRP and may supply: +`correct(CorrectionContext(hrp, ...), damaged_text)` supports `ms` and any +other HRP. `hrp` is a case-insensitive string stored in lowercase. The context fixes that HRP and may supply: - `expected_length`, the complete canonical string length; - `immutable_prefix`, program-supplied text outside the correction domain; and - `excluded_indices`, ordinary share indices already accepted in a recovery. -With or without `expected_length`, registered correction searches only valid -profile lengths reachable by a supported structural family. An opaque target +With or without `expected_length`, `ms` correction searches only valid +master-seed lengths reachable by a supported structural family. Another HRP considers character offsets -4 through +4 and group offsets -8/-4/0/+4/+8, -filtered through generic codex32 shape validation. Registered profile length -and payload validation is unchanged. Every attempt has a ten-second deadline. +filtered through generic codex32 shape validation. Every attempt has a ten-second deadline. The HRP, separator, and any confirmed five-character threshold/identifier header -are immutable. Profile registration is never a candidate-ranking signal and -cannot transform one application namespace into another. Every returned +are immutable. The HRP is never a candidate-ranking signal and correction +cannot change it. Every returned artifact crosses `parse_codex32`; suggestions remain untrusted and require exact whole-string confirmation before operational use. @@ -376,7 +341,7 @@ For included classes `(V, b)`, let `B = max(b)` and `C = sum(V << (B - b))`. The fields store `C` and `B`. `low_checksum_discrimination` is exactly `32*C > 2**B`; equality leaves five bits and does not trigger. Candidate ranking remains based on its own volume. -No profile semantics, CRC, fingerprint, pruning, or timeout discount this sum. +No `ms` length rule, CRC, fingerprint, pruning, or timeout discount this sum. The API remains noninteractive. Clients displaying candidates can use this property to enforce the CLI's low-discrimination disclosure boundary. `search_complete=False` identifies a unique-so-far result from interrupted @@ -435,7 +400,7 @@ the mutable body length. All fixed, character, group and target-length layers share one admission ledger. Complete equal-volume batches are admitted only while their cumulative mass is at or below `1`. Integer scaling handles 65-bit and 75-bit checksum spaces together. Consecutive fixed erasures are -included in this ledger, with no independent allowance. Profile validation, +included in this ledger, with no independent allowance. Length validation, CRC and fingerprint hints do not enlarge it. The inclusive ceiling permits full 13/15-erasure completion, whose capture @@ -460,8 +425,7 @@ character expansion, with lower capture-volume layers first within each phase. A layer can be omitted once its rank is strictly worse than the current best result. Otherwise the deadline is checked during enumeration. -The required baseline is `A<=2` and `G<=2` at every current public profile -length. Host restarts prevented an uninterrupted timing certification; the +The required baseline is `A<=2` and `G<=2` at every valid `ms` length. Host restarts prevented an uninterrupted timing certification; the recorded capped measurements do not promote a wall-time guarantee. Deeper `A=3/4` work is best effort within the deadline. No tentative 40/66/93/127 cutoff is a protocol constant or a promoted deeper guarantee. See [alignment benchmark evidence](alignment-benchmarks.md) for measured public @@ -487,7 +451,7 @@ whole-string acceptance boundary. Public Python search signatures and required versus optional completion behavior are unchanged. CLI pruning proves coverage using fixed BCH's `E + 2*S <= 8` sphere or the -minimum distance of nine at the same profile and length. It accounts for +minimum distance of nine at the same HRP and length. It accounts for residual substitutions, existing erasures, and cancelling structural edits. Discovery coverage alone is insufficient: a redundant layer may give a known artifact a lower capture volume or Hamming weight. Symbol-count and positional @@ -499,7 +463,7 @@ Completed simpler mask/deletion layers can cover repeated masks and masks remove by deletion. The original mass ledger is preserved without expanding admission to spend savings from redundant hypotheses. Execution remains on one CPU. -`tools/alignment_benchmark.py --public` exercises valid profile lengths, target +`tools/alignment_benchmark.py --public` exercises valid `ms` lengths, target knowledge, header freezing, reachable offsets and explicit erasures. `--kernel` separately samples BCH-body lengths 40/66/93/127/1015/1023, character depths 2/3/4 and group depth 2, and reports generation, syndrome, BCH, locator, @@ -509,7 +473,7 @@ may represent a synthetic word that is not a valid public application string. ### Worksheet residue API `correct_worksheet_residue` accepts only a 13- or 15-symbol residue and uses -zero-based reverse positions. It has no profile, HRP, or complete-string length. +zero-based reverse positions. It has no HRP or complete-string length. `()` means already correct, a tuple contains unique addends, and `None` means no unique correction. The CLI alone converts displayed positions to one-based. @@ -576,8 +540,7 @@ conditioning. Direct `MasterSeed.from_seed` encodes the same convention. ## Wallet interoperability 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. +stateless and never accepts shares, other HRPs' secrets, or raw bytes. The public adapter has one function: `master_xprv(secret, testnet=False)` returns the BIP32 root extended private key, using stdlib-only serialization. @@ -664,9 +627,7 @@ secret-root warning. Bitcoin Core 32 or newer and exercises direct wallet restoration, account-0 and timestamp handling, Core-created public descriptors, balance discovery, sign/broadcast behavior on regtest, relocking, and mainnet/test-network root -serialization. `tools/bitcoin_core_main_smoke.py` repeats the descriptor, -account-0, timestamp, and relocking checks against an isolated main-chain Core -instance without connecting to peers. +serialization. ## Deliberate divergences and non-goals @@ -674,17 +635,17 @@ These choices are not presented as BIP93 requirements. | Decision | Reason | |---|---| -| `ms` accepts only six seed sizes | follows the frozen PR #2258 profile with no legacy decoder | +| `ms` accepts only six seed sizes | follows frozen bips PR #2258 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, 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 | +| only `ms` has application rules | other applications (Core Lightning, BIP39 worksheets) apply their own rules to generic artifacts | | 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 | | no caller-supplied partial-basis completion | unauthenticated points can create incompatible same-header polynomials | Unknown-HRP application interpretation, GUI, direct sockets, a general RPC -client, secret storage, runtime profiles, BIP39 mnemonics, and arbitrary descriptor parsing are +client, secret storage, application profiles, BIP39 mnemonics, and arbitrary descriptor parsing are explicit v1 non-goals. Structural correction is bounded as documented above; broader multi-candidate recovery remains a separate-tool concern. Pending-standard compatibility, the @@ -715,7 +676,7 @@ difference highlighting. Only `correct` highlights corrected four-character windows; creation retains its separate transcription-error localization. Acceptance requires “Does this entire string exactly match your recovery card? [y/N]:” with explicit `y` or `yes`; rejection preserves the input flow. `check` -never searches for corrections. An intact first-entry profile prefix is immutable. +never searches for corrections. An intact first-entry HRP prefix is immutable. Bitcoin secret candidates display their master fingerprint above the card text. Final-share candidates in recovery commands provisionally reconstruct an isolated @@ -724,8 +685,8 @@ Preview failures reject the candidate. No candidate enters accepted state, key export, descriptor construction, or wallet initialization before confirmation. Interactive `create --existing` offers bounded correction for a mistyped codex32 -secret of the selected profile. Compare the entire proposed string with the -original recovery card before answering yes. Bitcoin candidates show their master -fingerprint above the text. Declining or finding no usable correction returns to +`ms` secret. Compare the entire proposed string with the +original recovery card before answering yes. The fingerprint stays hidden until +the wallet-record check. Declining or finding no usable correction returns to source entry; hexadecimal seeds are never corrected. Confirmation of newly created cards is a separate step after accepting the source. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce52..6b559e75 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -3,9 +3,9 @@ These rules are mandatory. The [security model](model.md) defines their limits and evidence. -1. Only parsers and profile factories validate text. Generic container and header - checks precede the checksum; registered application rules follow it. Unknown - HRPs remain opaque and never gain invented application semantics. +1. Only the parser and `MasterSeed.from_seed` validate 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; recovery and derivation reject sets with mismatched normalized HRPs or shapes. 3. Shared creation uses a separate OS-CSPRNG call for each random initial share, diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..6dcd2bc3 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -7,9 +7,8 @@ immutable Python object created after all required checks pass. The [security invariants](invariants.md) are the mandatory high-level contract. This document gives their controls, limitations, and verification evidence. -This model covers generic opaque-HRP codex32 artifacts, the `ms` master-seed -profile, the `cl` Core Lightning profile, and supported BIP39 migration artifacts. Creation and wallet controls -focus on Bitcoin master seeds. User safety and vulnerability reporting belong +This model covers generic codex32 artifacts for any HRP and BIP93's `ms` +master seeds. Creation and wallet controls focus on Bitcoin master seeds. User safety and vulnerability reporting belong in [SECURITY.md](../../SECURITY.md); procedures belong in the [user guide](../user/guide.md). @@ -22,7 +21,7 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive | Boundary | Security requirement | |---|---| -| Untrusted text | Text remains untrusted until a parser or profile factory returns a validated artifact. | +| Untrusted text | Text remains untrusted until the parser or `MasterSeed.from_seed` returns a validated artifact. | | 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. | @@ -85,19 +84,19 @@ The operator must: Parsing first checks the generic Bech32 container, selects the checksum type from the generic encoded length, and validates the common codex32 header. It -then verifies the codex32 checksum. Only after checksum verification may the -human-readable part (HRP) select a profile or may codex32 enforce application -lengths or interpret payload semantics. +then verifies the codex32 checksum. Only after checksum verification does an +`ms` human-readable part (HRP) add the master-seed length rule and seed-byte +meaning. -Only parsers and profile-specific factories construct immutable validated +Only the parser and `MasterSeed.from_seed` construct immutable validated artifacts. 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 string. This reduces accidental disclosure through logs and interpolation but does not make `.text` safe to expose. -Registration adds semantics but is not required for generic parsing, recovery, -derivation, or correction. Wallet and profile-specific generation APIs do not -accept opaque artifacts or raw strings. +Generic parsing, recovery, derivation, and correction work for any HRP. +Wallet and generation APIs accept only `MasterSeed`, never other HRPs' +artifacts or raw strings. Every derived, recovered, or corrected result is reparsed through the same boundary. This prevents internal arithmetic from bypassing format, header, @@ -120,10 +119,7 @@ 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. 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. +without new entropy. The `ms32` façade accepts only `ms`. 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). @@ -177,8 +173,7 @@ 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 and corrects unshared CL secrets, and checks, corrects, -recovers, and derives shares for compatible BIP39 and opaque-HRP sets. `ms32` +`codex32` checks, corrects, recovers, and derives shares for any HRP. `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`. @@ -219,10 +214,6 @@ supplied erasure positions. It uses the same threshold before releasing addends. The Python API remains an expert, noninteractive primitive: candidate objects carry cumulative volume and its denominator exponent for clients to inspect. -BIP39 worksheet profiles are compatibility formats and are not recommended -for creation. New Bitcoin backups should use the secure random generation in -`ms32 create`. - ## Bitcoin Core controls Automatic initialization applies to fresh Bitcoin master-seed creation, @@ -255,8 +246,8 @@ initialization. | Boundary | Focused evidence | |---|---| -| Parsing and profiles | [`test_bech32.py`](../../tests/test_bech32.py), [`test_bip93.py`](../../tests/test_bip93.py), and [`test_profiles.py`](../../tests/test_profiles.py) | +| Parsing and `ms` lengths | [`test_bech32.py`](../../tests/test_bech32.py), [`test_bip93.py`](../../tests/test_bip93.py), and [`test_lengths.py`](../../tests/test_lengths.py) | | Creation, sharing, and recovery | [`test_generation.py`](../../tests/test_generation.py), [`test_sharing.py`](../../tests/test_sharing.py), and the BIP93 vectors under `tests/data/` | | Correction | [`test_correction_bch.py`](../../tests/test_correction_bch.py), [`test_correction_indel.py`](../../tests/test_correction_indel.py), [`correction_capture.py`](../../tools/correction_capture.py), and [`differential_correction.py --verify`](../../tools/differential_correction.py) | -| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py), and [`bitcoin_core_main_smoke.py`](../../tools/bitcoin_core_main_smoke.py) | +| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), and [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py) | | CLI channels and input | [`test_cli.py`](../../tests/test_cli.py) | diff --git a/docs/user/guide.md b/docs/user/guide.md index b875ab04..ecc5dd31 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -3,9 +3,6 @@ `codex32` checks, corrects, recovers, and derives shares across application prefixes. `ms32` provides Bitcoin master-seed workflows, including secure creation. Use `ms32 create` for new Bitcoin backups. -BIP39 worksheet profiles are -[not recommended for creation](https://secretcodex32.com/docs/index.html), -but existing backups can be recovered and corrected. When correction has little checksum evidence left, either executable warns: @@ -77,16 +74,14 @@ Run Bitcoin Core before starting. If practical, disconnect the computer from external networks while recovery text is on screen. codex32 talks only to the local Core instance at `127.0.0.1` during setup. -For graphical practice on signet, start Bitcoin-Qt with: +codex32 uses the network `bitcoin-cli` is configured for and reports it before +it does anything else. For graphical practice on signet, add `signet=1` to +`bitcoin.conf`, then start Bitcoin-Qt with: ```bash -bitcoin-qt -signet -server +bitcoin-qt -server ``` -codex32 detects and reports the local Bitcoin Core network. You do not need to -change `bitcoin.conf` when using the standard local data directory and RPC -port. - Use a computer you believe is malware-free and whose other software you trust. Only codex32 and Bitcoin Core should perform recovery, derivation, wallet initialization, or signing. The QR tools below transport only public @@ -298,13 +293,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 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 generic `codex32` façade can check, correct, recover, and derive shares for +any application prefix, such as Core Lightning (`cl`) or BIP39 worksheet +backups. It treats them as plain codex32 symbols and applies none of their +application's own rules: Core Lightning, for example, accepts only unshared +secrets. Keep the matching application worksheet and original wallet instructions with the inheritance plan. Published BIP-93 still defines the `ms` application; the arbitrary-HRP format direction is not yet merged into that specification. @@ -321,9 +314,9 @@ and exact limits are documented in the [API and architecture guide](../developer Auditors should also read the [security model](../security/model.md). Interactive `create --existing` offers bounded correction for a mistyped codex32 -secret of the selected profile. Compare the entire proposed string with the -original recovery card before answering yes. Bitcoin candidates show their master -fingerprint above the text. Declining or finding no usable correction returns to +`ms` secret. Compare the entire proposed string with the +original recovery card before answering yes. The master fingerprint stays hidden +until the wallet-record check. Declining or finding no usable correction returns to source entry; hexadecimal seeds are never corrected. Confirmation of newly created cards is a separate step after accepting the source. diff --git a/src/codex32/__init__.py b/src/codex32/__init__.py index 56ceb771..3613d987 100644 --- a/src/codex32/__init__.py +++ b/src/codex32/__init__.py @@ -2,6 +2,7 @@ from .bip93 import ( Header, + MasterSeed, Secret, Share, derive_share, @@ -22,17 +23,11 @@ CreationCeremony, 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 master_xprv __all__ = [ - "Bip39Secret", "CodexError", "ConfirmationResult", - "CoreLightningSecret", "CorrectionCandidate", "CorrectionContext", "CorrectionEdit", @@ -40,7 +35,6 @@ "Header", "InvalidCorrectionInput", "MasterSeed", - "Profile", "Secret", "Share", "WorksheetCorrection", diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 606594f6..f2060cb7 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import shutil import string @@ -11,9 +10,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars, convertbits -from codex32.generation import _fingerprint_identifier -from codex32.profiles.ms32 import MasterSeed +from codex32.bip93 import MasterSeed class BitcoinCoreError(Exception): @@ -24,13 +21,7 @@ class FingerprintMismatch(BitcoinCoreError): """The recovered seed is not the wallet the operator's record describes.""" -_CHAINS = ( - ("main", "mainnet"), - ("test", "testnet3"), - ("testnet4", "testnet4"), - ("signet", "signet"), - ("regtest", "regtest"), -) +_CHAIN_NAMES = {"main": "mainnet", "test": "testnet3"} _PRIVATE_MARKERS = ("xprv", "tprv") _OUTPUT_TYPES = ("legacy", "p2sh-segwit", "bech32", "bech32m") @@ -54,36 +45,6 @@ def parse_fingerprint(text: str) -> bytes: ) -def identifier_note(origin: str | None) -> str: - # Say what `identifier_origin` found, for an operator restoring without a record. - if origin is None: - return ( - "The backup identifier was not made from this seed. That can be normal for codex32 backups " - "made from split shares, supplied seed bytes or an explicit identifier. Bails made every " - "identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards." - ) - return ( - f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, " - "but not cards replaced on purpose." - ) - - -def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: - """Check codex32's fingerprint or Bails' three-character seed-digest identifier.""" - identifier = secret.header.identifier - if identifier == _fingerprint_identifier(fingerprint): - return "codex32" - for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): - try: - hashed = hashlib.new(digest, secret.seed_bytes).digest() - except ValueError: - continue - derived = convertbits(hashed, 8, 5, pad=True) - if identifier[:3] == _u5_to_chars(tuple(derived[:3])): - return name - return None - - @dataclass(frozen=True) class BitcoinCore: executable: str @@ -91,48 +52,29 @@ class BitcoinCore: version: int @classmethod - def connect( - cls, - ask: Callable[[str], str] | None = None, - tell: Callable[[str], None] | None = None, - ) -> BitcoinCore: + def connect(cls, tell: Callable[[str], None] | None = None) -> BitcoinCore: + """Use the network bitcoin-cli is configured for, and say which one it is.""" executable = shutil.which("bitcoin-cli") if executable is None: raise BitcoinCoreError("Install a reviewed bitcoin-cli before creating a backup.") - choices: list[BitcoinCore] = [] - for chain, _label in _CHAINS: - client = cls(executable, chain, 0) - try: - network = client._rpc("getnetworkinfo", timeout=5) - blockchain = client._rpc("getblockchaininfo", timeout=5) - except BitcoinCoreError: - continue - version = network.get("version") if isinstance(network, dict) else None - reported = blockchain.get("chain") if isinstance(blockchain, dict) else None - if isinstance(version, bool) or not isinstance(version, int) or reported != chain: - continue - if version >= 320000: - choices.append(cls(executable, chain, version)) - if not choices: + client = cls(executable, "", 0) + try: + network = client._rpc("getnetworkinfo", timeout=5) + blockchain = client._rpc("getblockchaininfo", timeout=5) + except BitcoinCoreError as error: raise BitcoinCoreError( - "No local Bitcoin Core RPC server found.\nStart Bitcoin Core " - "with local RPC enabled.\nFor signet practice: bitcoin-qt -signet -server" - ) - if len(choices) > 1: - if ask is None or tell is None: - raise BitcoinCoreError("More than one local Bitcoin Core network is running.") - tell("Local Bitcoin Core networks:") - for number, choice in enumerate(choices, 1): - tell(f" {number}. {dict(_CHAINS)[choice.chain]}") - while not ( - (answer := ask("Choose a network number")).isdecimal() and 1 <= int(answer) <= len(choices) - ): - tell("Enter one of the displayed numbers.") - choices = [choices[int(answer) - 1]] - client = choices[0] + "No local Bitcoin Core RPC server found.\nStart Bitcoin Core with local RPC enabled.\n" + "bitcoin-cli uses the network set in bitcoin.conf; for signet practice add signet=1." + ) from error + version = network.get("version") if isinstance(network, dict) else None + chain = blockchain.get("chain") if isinstance(blockchain, dict) else None + if isinstance(version, bool) or not isinstance(version, int) or not isinstance(chain, str): + raise BitcoinCoreError("Bitcoin Core returned unexpected network information.") + if version < 320000: + raise BitcoinCoreError("Bitcoin Core 32.0 or newer is required.") if tell is not None: - tell(f"Using Bitcoin Core on {dict(_CHAINS)[client.chain]}.") - return client + tell(f"Using Bitcoin Core on {_CHAIN_NAMES.get(chain, chain)}.") + return cls(executable, chain, version) def _rpc( self, @@ -141,7 +83,7 @@ def _rpc( stdin: str | None = None, timeout: int = 120, ) -> object: - command = [self.executable, f"-chain={self.chain}", "-rpcconnect=127.0.0.1"] + command = [self.executable, "-rpcconnect=127.0.0.1"] if wallet is not None: command.append(f"-rpcwallet={wallet}") if stdin is not None: diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index cf5d25df..196639a1 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -7,45 +7,32 @@ import os import sys from collections.abc import Callable, Iterator, Sequence -from dataclasses import replace -from functools import partial -from time import monotonic from typing import Any, Literal, cast -from codex32.bech32 import interpret_mixed_case from codex32.bip93 import ( + MS, + MasterSeed, Secret, Share, - _checksum_for_encoded_length, - _validate_basis_prefix, + _text_length, + _validate_ms_length, _validate_recovery_prefix, + _validate_share_set, parse_codex32, recover_secret, ) -from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass +from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _correct_complete from codex32.errors import ( CodexError, DuplicateShareIndex, ExistingTargetIndex, InvalidChecksum, + InvalidCorrectionInput, InvalidLength, - InvalidThreshold, - MismatchedIdentifier, - MismatchedPayloadLength, - MismatchedProfile, - MismatchedThreshold, - SecretInRecoverySet, -) -from codex32.profiles import Profile, _optional_profile_rules, _profile_rules -from codex32.profiles.ms32 import ( - TEXT_LENGTHS, - MasterSeed, - _text_length, ) Artifact = Share | Secret _MAX_INPUT = 9 * 1025 -_MAX_CORRECTION_LENGTH_DELTA = 8 _line_editor: Any try: @@ -67,22 +54,9 @@ class InteractiveConfirmationRequired(Exception): def _confirmation_input(prompt: str) -> str: - if sys.stdin.isatty(): - return _editable_input(prompt) - if not sys.stderr.isatty(): + if not sys.stdin.isatty(): raise InteractiveConfirmationRequired - terminal_name = "CONIN$" if os.name == "nt" else "/dev/tty" - try: - with open(terminal_name, encoding="utf-8", buffering=1) as terminal: - if not terminal.isatty(): - raise InteractiveConfirmationRequired - print(prompt, end="", file=sys.stderr, flush=True) - answer = terminal.readline() - except OSError: - raise InteractiveConfirmationRequired from None - if not answer: - raise EOFError - return answer.rstrip("\r\n") + return _editable_input(prompt) def _require_correction_confirmation(low_discrimination: bool) -> None: @@ -213,7 +187,7 @@ def _confirm_correction( try: if ( isinstance(artifact, Share) - and artifact.profile is Profile.MS + and artifact.hrp == MS and not basis and (len(accepted) + 1 == artifact.header.threshold) ): @@ -232,67 +206,6 @@ def _confirm_correction( ).strip().lower() in ("y", "yes") -def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: - # Keep entered tokens whole unless they exactly span complete groups. - # Unspaced input retains edit/group minimization and edit-order ties. - positions = [i for i, char in enumerate(observed) if not char.isspace()] - compact = "".join(observed.split()) - expected = "".join(expected.split()).lower() - tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected - boundaries = {0} - splits: set[tuple[int, int]] = set() - position = 0 - for token in tokens: - for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == token.lower() and ( - len(token) % 4 == 0 or offset + len(token) == len(expected) - ): - splits.update((position + i, offset + i) for i in range(4, len(token), 4)) - position += len(token) - boundaries.add(position) - scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] - for offset in range(0, len(expected), 4): - canonical = expected[offset : offset + 4] - following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} - for start, (edits, disturbed, trace, ends) in scores.items(): - row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] - for end in range(start, len(compact) + 1): - if end > start: - current = [(end - start, b"\x02" * (end - start))] - for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char - current.append( - min( - (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), - (row[j][0] + 1, row[j][1] + b"\x02"), - (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), - ) - ) - row = current - if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: - continue - score = ( - edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), - trace + row[-1][1], - (*ends, end), - ) - if end not in following or score < following[end]: - following[end] = score - scores = following - groups = [] - start = 0 - for end in scores[len(compact)][3]: - stop = positions[end] if end < len(positions) else len(observed) - groups.append(observed[start:stop]) - start = stop - changed = { - i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] - } - return groups, changed - - def _render_groups( groups: list[str], changed: set[int], @@ -313,25 +226,21 @@ def _render_groups( ).rstrip() -def _parse(value: str, profiles: tuple[Profile, ...] | None) -> Artifact: +def _parse(value: str, hrp: str | None) -> Artifact: try: artifact = parse_codex32(value) + except InvalidChecksum as error: + # The parser checks checksums before master-seed lengths; explain a + # wrong length without changing that order. + try: + _validate_ms_length(value[: value.rfind("1")].lower(), len(value)) + except InvalidLength as length_error: + raise InputError(str(length_error)) from error + raise InputError(str(error)) from error except CodexError as error: - message = _FRIENDLY_SET_ERRORS.get(type(error), str(error)) - if isinstance(error, InvalidChecksum): - # Parsing has already checked the container and generic length. - # Explain an unsupported profile length without validating input - # or changing the parser's checksum-before-profile boundary. - try: - _profile_rules(value[: value.rfind("1")]).validate_text_length(len(value)) - except InvalidLength as length_error: - message = str(length_error) - except CodexError: - pass - raise InputError(message) from error - if profiles is not None and artifact.profile not in profiles: - allowed = " or ".join(_profile_rules(profile).label for profile in profiles) - raise InputError(f"This command accepts only {allowed} input.") + raise InputError(str(error)) from error + if hrp is not None and artifact.hrp != hrp: + raise InputError("This command accepts only Bitcoin master seed input.") return artifact @@ -374,180 +283,6 @@ def _display_prefix(prefix: str, grouped: bool) -> str: return displayed -def _case_interpretation( - value: str, - prefix: str, - profiles: tuple[Profile, ...] | None, - allowed: Callable[[CorrectionCandidate], bool] | None, -) -> tuple[CorrectionCandidate | None, str, str, str] | None: - # Normalize likely casing and mark contrary-case data as erasures. - separator = value.find("1") - base_length = separator + 1 if separator >= 0 else 0 - immutable_length = len(prefix) if prefix and value.lower().startswith(prefix.lower()) else base_length - interpretation = interpret_mixed_case(value, immutable_length) - if interpretation is None: - return None - corrected, erased, uppercase = interpretation - corrected_prefix = prefix.upper() if uppercase else prefix.lower() - try: - artifact = _parse(corrected, profiles) - except InputError: - candidate = None - else: - bits = ( - 5 * _checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length - ) - proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits) - candidate = proposed if allowed is None or allowed(proposed) else None - return candidate, corrected, erased, corrected_prefix - - -_PRIMARY_MS = (48, 74, 127) - - -def _correction_plan( - hrp: str | Profile, - byte_length: int | Literal["?"] | None, - count: int, - target: int | None, -) -> tuple[tuple[int, ...], frozenset[int], frozenset[int], bool]: - if target is not None: - return ( - (target,), - frozenset((target,)), - frozenset(), - True, - ) - normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() - if normalized_hrp == Profile.CL.value: - return (74,), frozenset((74,)), frozenset(), True - if isinstance(byte_length, int): - return ( - ((length := _text_length(byte_length)),), - frozenset((length,)), - frozenset(), - True, - ) - if byte_length == "?": - return TEXT_LENGTHS, frozenset(TEXT_LENGTHS), frozenset(), True - if normalized_hrp == Profile.MS.value: - nearest = min(_PRIMARY_MS, key=lambda length: abs(count - length)) - targets = (nearest, *(length for length in TEXT_LENGTHS if length != nearest)) - return targets, frozenset(targets), frozenset(), True - rules = _optional_profile_rules(normalized_hrp) - if rules is not None and hasattr(rules, "text_length"): - targets = (rules.text_length,) - return targets, frozenset(targets), frozenset(), True - targets = tuple(sorted({count + delta for delta in (*range(-4, 5), -8, 8)})) - return targets, frozenset(targets), frozenset(), True - - -def _correction_candidates( - value: str, - profile: str | Profile, - byte_length: int | Literal["?"] | None, - immutable: str, - excluded: tuple[str, ...] = (), - *, - target: int | None = None, - allowed: Callable[[CorrectionCandidate], bool] | None = None, - deadline: float | None = None, - capture_layers: list[tuple[int, int]] | None = None, - fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, - seed_candidates: Sequence[CorrectionCandidate] = (), - required_only: bool = False, - optional_only: bool = False, -) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: - count = len(value.replace(" ", "")) - targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target) - deadline = monotonic() + 10 if deadline is None else deadline - contexts = tuple(CorrectionContext(profile, length, immutable, excluded) for length in targets) - from codex32.indel import _search_many - - candidates, complete = _search_many( - contexts, - value, - primary=primary, - reduced=reduced, - deadline=deadline, - competitors=True, - allowed=allowed, - capture_layers=capture_layers, - seed_candidates=seed_candidates, - required_only=required_only, - optional_only=optional_only, - ) - if allowed is not None: - candidates = tuple(candidate for candidate in candidates if allowed(candidate)) - results = ( - _best(candidates, prefer_common=byte_length == "?", fingerprint_match=fingerprint_match) - if complete - else candidates - if len(candidates) == 1 and not candidates[0].search_complete - else () - ) - return results, complete, deadline - - -def _scheduled_candidates( - value: str, - erased: str, - profile: str | Profile, - byte_length: int | Literal["?"] | None, - immutable: str, - excluded: tuple[str, ...] = (), - *, - target: int | None = None, - allowed: Callable[[CorrectionCandidate], bool] | None = None, - deadline: float | None = None, - fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None, -) -> tuple[tuple[CorrectionCandidate, ...], bool]: - """Search both case interpretations under one deadline and capture ledger.""" - search = partial( - _correction_candidates, - profile=profile, - byte_length=byte_length, - immutable=immutable, - excluded=excluded, - target=target, - allowed=allowed, - fingerprint_match=fingerprint_match, - ) - first, retry = (erased, value) if erased != value else (value, None) - seeded: tuple[CorrectionCandidate, ...] = () - if retry is not None: - # Find required candidates for both case interpretations before either - # full search can spend the shared deadline on optional alignment. - # These discovery passes deliberately do not charge capture_layers; - # the full searches below account each admitted frontier once. - for required_value in (first, retry): - seeded, complete, deadline = search( - required_value, deadline=deadline, seed_candidates=seeded, required_only=True - ) - if not complete: - return (), False - capture_layers: list[tuple[int, int]] = [] - full_search = partial(search, capture_layers=capture_layers, optional_only=retry is not None) - candidates, complete, deadline = full_search(first, deadline=deadline, seed_candidates=seeded) - if retry is None: - return candidates, complete - retry_candidates, retry_complete, _deadline = full_search( - retry, deadline=deadline, seed_candidates=(*seeded, *candidates) - ) - complete = complete and retry_complete - annotated = [] - for item in (*candidates, *retry_candidates): - volume, bits = _capture_mass(capture_layers, item.capture_volume) - annotated.append(replace(item, cumulative_capture_volume=volume, capture_space_bits=bits)) - unique: dict[str, CorrectionCandidate] = {} - for item in _best(annotated, prefer_common=byte_length == "?", fingerprint_match=fingerprint_match): - # A copy from a completed earlier pass must not hide later truncation. - unique.setdefault( - item.artifact.text.lower(), item if complete else replace(item, search_complete=False) - ) - return tuple(unique.values()), complete - - def _fingerprint_matcher( fingerprint: Callable[[MasterSeed], bytes] | None, ) -> Callable[[CorrectionCandidate], bool | None] | None: @@ -570,47 +305,55 @@ def matches(candidate: CorrectionCandidate) -> bool | None: def _suggestions( value: str, prefix: str, - profiles: tuple[Profile, ...] | None, - accepted: list[Artifact], + required_hrp: str | None, + accepted: Sequence[Artifact], *, allowed: Callable[[CorrectionCandidate], bool] | None = None, fingerprint: Callable[[MasterSeed], bytes] | None = None, + byte_length: int | Literal["?"] | None = None, ) -> tuple[CorrectionCandidate, ...]: - fingerprint_match = _fingerprint_matcher(fingerprint) - erased = value - if interpretation := _case_interpretation(value, prefix, profiles, allowed): - candidate, value, erased, prefix = interpretation - if candidate is not None: - return (candidate,) - separator = value.lower().rfind("1") - if separator <= 0: - return () - hrp = value[:separator].lower() - rules = _optional_profile_rules(hrp) - profile = rules.profile if rules is not None else None - if profiles is not None and profile not in profiles: - return () - excluded = tuple(artifact.header.index for artifact in accepted) - target = len(accepted[0].text) if accepted else None - immutable = ( - value[: len(prefix)] - if prefix and value.lower().startswith(prefix.lower()) - else prefix or value[: separator + 1] - ) - return _scheduled_candidates( + return _corrections( value, - erased, - hrp, - None, - immutable, - excluded, - target=target, + prefix, + required_hrp, + accepted, allowed=allowed, - deadline=monotonic() + 10, - fingerprint_match=fingerprint_match, + fingerprint=fingerprint, + byte_length=byte_length, )[0] +def _corrections( + value: str, + prefix: str, + required_hrp: str | None, + accepted: Sequence[Artifact], + *, + allowed: Callable[[CorrectionCandidate], bool] | None = None, + fingerprint: Callable[[MasterSeed], bytes] | None = None, + byte_length: int | Literal["?"] | None = None, +) -> tuple[tuple[CorrectionCandidate, ...], bool]: + """Run the library search, then the CLI's Hamming, CRC, and fingerprint tie breakers.""" + separator = value.rfind("1") + 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 + 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) + context = CorrectionContext(hrp, target, _prefix_for_suffix(immutable, value), excluded) + try: + candidates, complete = _correct_complete(context, value, allowed=allowed) + except InvalidCorrectionInput: + return (), True + best = _best( + candidates, prefer_common=byte_length == "?", fingerprint_match=_fingerprint_matcher(fingerprint) + ) + return best, complete + + def _validate_operational_artifact( artifact: Artifact, accepted: list[Artifact], @@ -623,12 +366,14 @@ def _validate_operational_artifact( 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) - validator = _validate_recovery_prefix if recovering else _validate_basis_prefix - validator([*accepted, artifact]) + if recovering: + _validate_recovery_prefix([*accepted, artifact]) + else: + _validate_share_set([*accepted, artifact], require_exact=False) def _redirected( - profiles: tuple[Profile, ...] | None, + hrp: str | None, *, basis: bool, one: bool, @@ -643,7 +388,7 @@ def _redirected( accepted: list[Artifact] = [] for token in tokens: try: - artifact = _parse(token, profiles) + artifact = _parse(token, hrp) except InputError: if one: raise @@ -664,7 +409,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: candidates = _suggestions( token, "", - profiles, + hrp, accepted, allowed=allowed, fingerprint=fingerprint, @@ -685,58 +430,12 @@ def allowed(candidate: CorrectionCandidate) -> bool: return accepted -_FRIENDLY_SET_ERRORS: dict[type[Exception], str] = { - InvalidChecksum: "The checksum does not match.", - MismatchedProfile: "These strings are for different applications.", - MismatchedThreshold: "These strings require different numbers of shares.", - MismatchedIdentifier: "These strings have different identifiers.", - MismatchedPayloadLength: "These strings have different lengths.", - DuplicateShareIndex: "That share index was already entered.", - SecretInRecoverySet: "Enter ordinary shares rather than the shared secret.", -} - - -def _prefixed_input_error( - error: InputError, - value: str, - prefix: str, - accepted: list[Artifact], -) -> str: - cause = error.__cause__ - if isinstance(cause, InvalidThreshold) and prefix.lower() == "ms1": - found = value[len(prefix) : len(prefix) + 1].lower() - if found: - return f"After the prefilled {prefix}, enter a threshold of 0 or 2 through 9; found {found!r}." - if not isinstance(cause, InvalidChecksum): - return str(error) - if accepted: - if accepted[0].profile is not Profile.MS: - return str(error) - target = len(accepted[0].text) - difference = len(value) - target - if abs(difference) <= _MAX_CORRECTION_LENGTH_DELTA: - return str(error) - direction = "too long for" if difference > 0 else "short of" - return ( - f"The string is {abs(difference)} characters {direction} the required {target}-character length." - ) - if prefix.lower() != "ms1": - return str(error) - if min(abs(len(value) - target) for target in TEXT_LENGTHS) <= _MAX_CORRECTION_LENGTH_DELTA: - return str(error) - lengths = ", ".join(str(length) for length in TEXT_LENGTHS[:-1]) + f", or {TEXT_LENGTHS[-1]}" - return ( - f"The string has {len(value)} characters; valid Bitcoin master-seed codex32 lengths are " - f"{lengths} characters." - ) - - def _interactive( *, basis: bool, one: bool, excluded_index: str | None, - profiles: tuple[Profile, ...] | None, + hrp: str | None, initial_prefix: str, fingerprint: Callable[[MasterSeed], bytes] | None, ) -> list[Artifact]: @@ -771,7 +470,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: supplied_prefix = bool(prefix) and "1" not in value complete_value = value if not supplied_prefix else _prefix_for_suffix(prefix, value) + value try: - artifact = _parse(complete_value, profiles) + artifact = _parse(complete_value, hrp) except InputError as error: candidates = ( () @@ -779,7 +478,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: else _suggestions( complete_value, prefix, - profiles, + hrp, accepted, allowed=allowed, fingerprint=fingerprint, @@ -798,19 +497,14 @@ def allowed(candidate: CorrectionCandidate) -> bool: if confirmation is True: artifact = candidates[0].artifact else: - message = ( - _prefixed_input_error(error, complete_value, prefix, accepted) - if supplied_prefix - else str(error) - ) - _stderr(f"Rejected: {message}\n") + _stderr(f"Rejected: {error}\n") prefill = _retry_text(entered, prefix) prefix = _prefix_for_suffix(prefix, prefill) continue try: validate(artifact) except CodexError as error: - _stderr(f"Rejected: {_FRIENDLY_SET_ERRORS.get(type(error), str(error))}\n") + _stderr(f"Rejected: {error}\n") duplicate = isinstance(error, (DuplicateShareIndex, ExistingTargetIndex)) prefill = "" if duplicate else _retry_text(entered, prefix) continue @@ -834,13 +528,13 @@ def read_artifacts( basis: bool = False, one: bool = False, excluded_index: str | None = None, - profiles: tuple[Profile, ...] | None = None, + hrp: str | None = None, initial_prefix: str = "", fingerprint: Callable[[MasterSeed], bytes] | None = None, ) -> list[Artifact]: if not sys.stdin.isatty(): return _redirected( - profiles, + hrp, basis=basis, one=one, excluded_index=excluded_index, @@ -850,7 +544,7 @@ def read_artifacts( basis=basis, one=one, excluded_index=excluded_index, - profiles=profiles, + hrp=hrp, initial_prefix=initial_prefix, fingerprint=fingerprint, ) diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c2472109..bb19eb05 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -8,7 +8,7 @@ from importlib.metadata import version from typing import Literal, NoReturn -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS +from codex32.bip93 import SEED_BYTE_LENGTHS class _Parser(argparse.ArgumentParser): diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 238f7dd2..927aa07c 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -11,8 +11,9 @@ bech32_decode, bech32_encode, bech32_verify_checksum, + convertbits, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum, _crc_pad from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -32,10 +33,31 @@ ) from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply -from codex32.profiles import Profile, _optional_profile_rules, _profile_rules, _ProfileRules IDX_SORT = "sacdefghjklmnpqrtuvwxyz023456789" -_CONSTRUCTION_TOKEN = object() + +# BIP93 master seeds: the "ms" HRP with one of six seed sizes (bytes, payload, text). +MS = "ms" +_MS_SIZES = ((16, 26, 48), (20, 32, 54), (24, 39, 61), (28, 45, 67), (32, 52, 74), (64, 103, 127)) +SEED_BYTE_LENGTHS, DEFAULT_SEED_BYTES = tuple(size[0] for size in _MS_SIZES), 16 +TEXT_LENGTHS = tuple(size[2] for size in _MS_SIZES) + + +def _payload_length(byte_length: int) -> int: + return (byte_length * 8 + 4) // 5 + + +def _text_length(byte_length: int) -> int: + payload_length = _payload_length(byte_length) + return payload_length + (22 if payload_length + 22 <= 91 else 24) + + +def _validate_ms_length(hrp: str, text_length: int) -> None: + if hrp == MS and text_length not in TEXT_LENGTHS: + raise InvalidLength( + f"This input has {text_length} characters. A Bitcoin master-seed backup must have " + "exactly 48, 54, 61, 67, 74, or 127 characters." + ) @dataclass(frozen=True, slots=True) @@ -95,7 +117,7 @@ def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") -def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]: +def _decode_codex32(text: str) -> tuple[str, tuple[int, ...], _Checksum]: hrp, encoded = bech32_decode(text) if len(hrp) + 1 + len(encoded) < 21: raise InvalidLength("codex32 string must contain at least 21 characters") @@ -103,40 +125,20 @@ def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, .. body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) if not bech32_verify_checksum(hrp, encoded, checksum): - raise InvalidChecksum(f"invalid {checksum.kind} checksum") - profile_rules = _optional_profile_rules(hrp) - if profile_rules is not None: - profile_rules.validate_text_length(len(text)) - profile_rules.validate_payload_length(len(body) - 6) - return hrp, profile_rules, tuple(encoded), checksum + raise InvalidChecksum("The checksum does not match.") + _validate_ms_length(hrp, len(text)) + return hrp, tuple(encoded), checksum -@dataclass(frozen=True, slots=True, init=False) +@dataclass(frozen=True, slots=True) class _Artifact: + """Create artifacts with parse_codex32 or the factories, which validate them.""" + text: str header: Header hrp: str - profile: Profile | None payload_symbols: tuple[int, ...] - def __init__( - self, - text: str, - header: Header, - hrp: str, - profile: Profile | None, - payload_symbols: tuple[int, ...], - *, - _token: object, - ) -> None: - if _token is not _CONSTRUCTION_TOKEN: - raise TypeError("codex32 artifacts must be created by the public factories") - object.__setattr__(self, "text", text) - object.__setattr__(self, "header", header) - object.__setattr__(self, "hrp", hrp) - object.__setattr__(self, "profile", profile) - object.__setattr__(self, "payload_symbols", payload_symbols) - def __str__(self) -> str: return f"<{type(self).__name__}: redacted>" @@ -167,47 +169,48 @@ class Secret(_Artifact): __slots__ = () -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) +class MasterSeed(Secret): + """A validated BIP93 ``ms`` secret.""" + __slots__ = () -def _artifact( - text: str, - hrp: str, - profile: Profile | None, - header: Header, - payload: tuple[int, ...], -) -> Share | Secret: - rules = _optional_profile_rules(hrp) - artifact_type = Share if header.index != "s" else (rules.secret_type if rules is not None else Secret) - return artifact_type(text, header, hrp, profile, payload, _token=_CONSTRUCTION_TOKEN) + @property + def seed_bytes(self) -> bytes: + """Return the BIP32 master-seed bytes represented by S.""" + return bytes(convertbits(self.payload_symbols, 5, 8, pad=False, accept_any_padding=True)) + + @classmethod + def from_seed(cls, seed_bytes: bytes, *, identifier: str, threshold: int = 0) -> "MasterSeed": + """Encode a supported BIP93 master seed as S with generation-only CRC padding.""" + if not isinstance(seed_bytes, bytes): + raise TypeError("seed_bytes must be bytes") + if len(seed_bytes) not in SEED_BYTE_LENGTHS: + raise InvalidLength("master seed must contain 16, 20, 24, 28, 32, or 64 bytes") + payload = tuple(convertbits(seed_bytes, 8, 5, pad=True, pad_value=_crc_pad(seed_bytes))) + artifact = _from_parts(MS, Header(threshold, identifier, "s"), payload) + assert isinstance(artifact, MasterSeed) + return artifact + + +def _has_generation_padding(secret: MasterSeed) -> bool: + padding_bits = (len(secret.payload_symbols) * 5) % 8 + padding = secret.payload_symbols[-1] & ((1 << padding_bits) - 1) if padding_bits else 0 + return padding == _crc_pad(secret.seed_bytes) def parse_codex32(text: str) -> Share | Secret: """Validate one codex32 string and return an immutable artifact.""" - hrp, profile_rules, encoded, checksum = _decode_codex32(text) + hrp, encoded, checksum = _decode_codex32(text) body = tuple(encoded[: -checksum.length]) header = Header._from_symbols(body[:6]) - payload = body[6:] - profile = profile_rules.profile if profile_rules is not None else None - if profile is not None: - _validate_payload(profile, header, payload) - return _artifact(text, hrp, profile, header, payload) + artifact_type = Share if header.index != "s" else MasterSeed if hrp == MS else Secret + return artifact_type(text, header, hrp, body[6:]) def _from_parts( - hrp: str | Profile, - header: Header, - payload: tuple[int, ...], - *, - uppercase: bool = False, + hrp: str, header: Header, payload: tuple[int, ...], *, uppercase: bool = False ) -> Share | Secret: - normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() - rules = _optional_profile_rules(normalized_hrp) - if rules is not None: - _validate_payload(rules.profile, header, payload) + normalized_hrp = hrp.lower() body = [*header._symbols, *payload] expanded_body_length = 2 * len(normalized_hrp) + 1 + len(body) checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG @@ -234,7 +237,6 @@ def _lagrange_weights(points: tuple[int, ...], target: int) -> tuple[int, ...]: class _ShareSet: artifacts: tuple[Share | Secret, ...] hrp: str - profile: Profile | None threshold: int identifier: str tails: tuple[tuple[int, ...], ...] @@ -245,68 +247,41 @@ def indices(self) -> tuple[str, ...]: return tuple(item.header.index for item in self.artifacts) -def _bounded_artifacts( - artifacts: Sequence[Share | Secret], -) -> tuple[Share | Secret, ...]: - if isinstance(artifacts, (str, bytes, bytearray)): - raise TypeError("share inputs must be validated artifacts, not text") - try: - count = len(artifacts) - except TypeError as error: - raise TypeError("share inputs must be a Sequence of artifacts") from error - if count == 0 or count > 9: - raise WrongShareCount("a share set must contain between 1 and 9 artifacts") - try: - copied = tuple(artifacts[position] for position in range(count)) - except IndexError as error: - raise TypeError("share input Sequence changed length while being read") from error - if not all(isinstance(item, _Artifact) for item in copied): - raise TypeError("all share inputs must be validated codex32 artifacts") - return copied - - -def _artifact_tail(artifact: Share | Secret) -> tuple[tuple[int, ...], int, int]: - hrp, profile_rules, encoded, checksum = _decode_codex32(artifact.text) - profile = profile_rules.profile if profile_rules is not None else None - if hrp != artifact.hrp or profile is not artifact.profile: - raise InvalidShareSet("artifact text and validated application prefix disagree") - return tuple(encoded[6:]), checksum.length, len(encoded) +def _artifact_tail(artifact: Share | Secret) -> tuple[int, ...]: + """Return the payload and checksum symbols of already-validated text.""" + return tuple(_chars_to_u5(artifact.text[len(artifact.hrp) + 7 :].lower())) def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: bool = True) -> _ShareSet: - copied = _bounded_artifacts(artifacts) + copied = tuple(artifacts) + if not all(isinstance(item, _Artifact) for item in copied): + raise TypeError("all share inputs must be validated codex32 artifacts") + if not 1 <= len(copied) <= 9: + raise WrongShareCount("a share set must contain between 1 and 9 artifacts") first = copied[0] threshold = first.header.threshold if threshold not in range(2, 10): - raise MismatchedThreshold("linear sharing requires threshold 2 through 9") + raise MismatchedThreshold("Only strings with a threshold of 2 through 9 can be combined.") if len(copied) > threshold or (require_exact and len(copied) != threshold): raise WrongShareCount(f"threshold is {threshold}, but {len(copied)} artifacts were supplied") - first_tail, checksum_length, encoded_length = _artifact_tail(first) - tails = [first_tail] + tails = [_artifact_tail(first)] indices = [first.header.index] for item in copied[1:]: if item.hrp != first.hrp: - raise MismatchedHrp(f"{first.hrp} and {item.hrp} cannot be combined") + raise MismatchedHrp("These strings are for different applications.") if item.header.threshold != threshold: - raise MismatchedThreshold("share thresholds do not match") + raise MismatchedThreshold("These strings require different numbers of shares.") if item.header.identifier != first.header.identifier: - raise MismatchedIdentifier("share identifiers do not match") - tail, item_checksum_length, item_encoded_length = _artifact_tail(item) - item_shape = ( - len(item.payload_symbols), - item_checksum_length, - item_encoded_length, - ) - if item_shape != (len(first.payload_symbols), checksum_length, encoded_length): - raise MismatchedPayloadLength("share payload/checksum lengths do not match") - tails.append(tail) + raise MismatchedIdentifier("These strings have different identifiers.") + if len(item.text) != len(first.text): + raise MismatchedPayloadLength("These strings have different lengths.") + tails.append(_artifact_tail(item)) indices.append(item.header.index) if len(set(indices)) != len(indices): - raise DuplicateShareIndex("share indices must be distinct") + raise DuplicateShareIndex("That share index was already entered.") return _ShareSet( copied, first.hrp, - first.profile, threshold, first.header.identifier, tuple(tails), @@ -317,11 +292,7 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b def _validate_recovery_prefix(shares: Sequence[Share | Secret]) -> None: share_set = _validate_share_set(shares, require_exact=False) if any(isinstance(item, Secret) for item in share_set.artifacts): - raise SecretInRecoverySet("recovery accepts ordinary shares only") - - -def _validate_basis_prefix(basis: Sequence[Share | Secret]) -> None: - _validate_share_set(basis, require_exact=False) + raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: @@ -342,7 +313,7 @@ def recover_secret(shares: Sequence[Share]) -> Secret: """Recover S from exactly k compatible, distinct ordinary shares.""" share_set = _validate_share_set(shares) if any(isinstance(item, Secret) for item in share_set.artifacts): - raise SecretInRecoverySet("recovery accepts ordinary shares only") + raise SecretInRecoverySet("Enter ordinary shares rather than the shared secret.") recovered = _interpolate_tail(share_set, "s") if not isinstance(recovered, Secret): raise InvalidShareSet("interpolation did not produce a secret") @@ -364,13 +335,6 @@ def derive_share(basis: Sequence[Share | Secret], fresh_index: str) -> Share: target = _normalize_target(fresh_index, label="fresh_index") if target in share_set.indices: raise ExistingTargetIndex(f"Choose a different share index; {target.upper()} was already supplied.") - profile_rules = _optional_profile_rules(share_set.hrp) - if profile_rules is not None and profile_rules.basis_secret_type is not None: - implied_secret = _interpolate_tail(share_set, "s") - if not isinstance(implied_secret, Secret): - raise InvalidShareSet("interpolation did not produce a secret") - if not isinstance(implied_secret, profile_rules.basis_secret_type): - raise InvalidShareSet(profile_rules.basis_error) derived = _interpolate_tail(share_set, target) if not isinstance(derived, Share): raise InvalidShareSet("interpolation did not produce an ordinary share") diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 1ffd1f13..6def8770 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -6,6 +6,7 @@ import json import sys from collections.abc import Callable, Sequence +from functools import partial from typing import Literal, NamedTuple, cast from codex32._bitcoin_core import ( @@ -13,21 +14,17 @@ BitcoinCore, BitcoinCoreError, FingerprintMismatch, - identifier_note, - identifier_origin, parse_fingerprint, ) from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, _card_text, - _case_interpretation, _confirm_correction, - _entered_groups, + _corrections, _fingerprint_matcher, _render_groups, _require_correction_confirmation, - _scheduled_candidates, _suggestions, ) from codex32._cli_input import InputError as _UsageError @@ -36,16 +33,18 @@ from codex32._cli_parser import parser as _parser from codex32.bip93 import ( IDX_SORT, + MS, Header, + MasterSeed, Secret, Share, _normalize_target, + _text_length, derive_share, parse_codex32, recover_secret, ) from codex32.correction import ( - CorrectionCandidate, _best, _residue_low_discrimination, correct_worksheet_residue, @@ -54,13 +53,9 @@ from codex32.generation import ( ConfirmationResult, CreationCeremony, + _confirmation, generate_master_seed, ) -from codex32.profiles import Profile, _profile_rules -from codex32.profiles.ms32 import MasterSeed -from codex32.profiles.ms32 import ( - _text_length as _ms_text_length, -) from codex32.wallet import master_xprv Artifact = Share | Secret @@ -69,12 +64,12 @@ class _CliContext(NamedTuple): prog: str master_seed: bool - profiles: tuple[Profile, ...] | None + hrp: str | None initial_prefix: str _GENERIC = _CliContext("codex32", False, None, "") -_MASTER_SEED = _CliContext("ms32", True, (Profile.MS,), "MS1") +_MASTER_SEED = _CliContext("ms32", True, MS, "MS1") class _CommandError(Exception): @@ -109,7 +104,7 @@ def _secret(artifacts: list[Artifact]) -> Secret: def _master_seed(fingerprint: Callable[[MasterSeed], bytes] | None = None) -> MasterSeed: if isinstance( - value := _secret(_artifacts(profiles=(Profile.MS,), initial_prefix="MS1", fingerprint=fingerprint)), + value := _secret(_artifacts(hrp=MS, initial_prefix="MS1", fingerprint=fingerprint)), MasterSeed, ): return value @@ -118,7 +113,7 @@ def _master_seed(fingerprint: Callable[[MasterSeed], bytes] | None = None) -> Ma def _summary(artifact: Artifact, *, valid: bool = False) -> list[str]: header = artifact.header - if artifact.profile is None: + if artifact.hrp != MS: heading = ( f"codex32 share {header.index.upper()}" if isinstance(artifact, Share) @@ -128,7 +123,7 @@ def _summary(artifact: Artifact, *, valid: bool = False) -> list[str]: heading = heading[0].lower() + heading[1:] lines = [f"{'Valid ' if valid else ''}{heading}.", f"HRP: {artifact.hrp.upper()}"] else: - name = _profile_rules(artifact.profile).label + name = "Bitcoin master seed" if isinstance(artifact, Share): name = name.replace("master seed", "master-seed") heading = f"{name} share {header.index.upper()}" @@ -181,7 +176,7 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC artifacts = _artifacts( basis=True, excluded_index=index, - profiles=context.profiles, + hrp=context.hrp, initial_prefix=context.initial_prefix, fingerprint=core.fingerprint if core is not None else None, ) @@ -200,19 +195,15 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC return 0 -def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None]: +def _creation_header(value: str | None) -> tuple[int | None, str | None]: if value is None: - return Profile.MS, None, None + return None, None lowered = value.lower() if lowered != value and value.upper() != value: raise _UsageError("The set header must use either uppercase or lowercase.") - if "1" in lowered: - hrp, header = lowered.rsplit("1", 1) - if hrp != Profile.MS.value: - raise _UsageError("The set header must begin with ms1.") - profile = Profile.MS - else: - profile, header = Profile.MS, lowered + hrp, _separator, header = lowered.rpartition("1") + if hrp not in ("", MS): + raise _UsageError("The set header must begin with ms1.") if len(header) not in (1, 5) or header[0] not in "023456789": raise _UsageError( "Enter a threshold alone or a complete backup header containing " @@ -220,18 +211,15 @@ def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None ) threshold = int(header[0]) if len(header) == 1: - return profile, threshold, None + return threshold, None try: identifier = Header(threshold, header[1:], "s").identifier except CodexError as error: raise _UsageError(f"Invalid backup header: {error}") from error - return profile, threshold, identifier + return threshold, identifier -def _creation_source( - profile: Profile, - fingerprint: Callable[[MasterSeed], bytes] | None = None, -) -> bytes | Artifact: +def _creation_source() -> bytes | Artifact: prefill = "" while True: value = _text( @@ -249,22 +237,15 @@ def _creation_source( try: return parse_codex32(value) except CodexError as error: - if not sys.stdin.isatty(): - raise _UsageError(str(error)) from error candidates = _suggestions( value, "", - (profile,), + MS, [], - allowed=lambda item: isinstance(item.artifact, Secret) and item.artifact.profile is profile, - fingerprint=fingerprint, + allowed=lambda item: isinstance(item.artifact, MasterSeed), ) - if ( - len(candidates) == 1 - and isinstance(candidate := candidates[0].artifact, Secret) - and candidate.profile is profile - ): - confirmation = _confirm_correction(candidates[0], [], False, fingerprint) + if len(candidates) == 1 and isinstance(candidate := candidates[0].artifact, MasterSeed): + confirmation = _confirm_correction(candidates[0], [], False) if confirmation is True: return candidate if confirmation is False: @@ -292,10 +273,13 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul preserve_groups=True, ) expected = artifact.text - groups, changed = _entered_groups(entered, expected) - while changed: - start = min(changed) - end = start + 1 + confirm = confirm or partial(_confirmation, expected=expected) + result = confirm(entered) + groups, changed = list(result.entered_groups), {group - 1 for group in result.mismatched_groups} + while not result.accepted: + if not changed: + raise RuntimeError("Confirmation rejected.") + start = end = min(changed) while end in changed: end += 1 _print(_render_groups(groups, changed, len(expected), range(start, end)), err=True) @@ -311,40 +295,20 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul ) compact = "".join(replacement.split()) if compact.lower() == expected.lower(): - groups = [replacement] - break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + groups, changed = [replacement], set() + elif len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( (expected.split("1", 1)[0] + "1").lower() ): - _print( - "Please re-enter only the highlighted region that remains incorrect.", - err=True, - ) + _print("Please re-enter only the highlighted region that remains incorrect.", err=True) continue - if not compact: - continue - groups[start:end], remaining = _entered_groups(replacement, expected[start * 4 : end * 4]) - changed.difference_update(range(start, end)) - changed.update(start + i for i in remaining) - entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): - raise RuntimeError("Confirmation mismatch.") - if confirm is not None and not confirm(entered).accepted: - raise RuntimeError("Confirmation rejected.") - - -def _generated_secret( - source: bytes | None, - byte_length: int | None, - identifier: str | None, - fingerprint: Callable[[bytes], bytes] | None = None, -) -> MasterSeed: - return generate_master_seed( - source, - byte_length=byte_length, - identifier=identifier, - fingerprint=fingerprint, - ) + elif compact: + # Align a retry only within its region so confirmed neighbors never shift. + region = _confirmation(replacement, expected[start * 4 : end * 4]) + groups[start:end] = region.entered_groups + changed.difference_update(range(start, end)) + changed.update(start + group - 1 for group in region.mismatched_groups) + if not changed: + result = confirm("".join(groups)) def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: @@ -355,10 +319,7 @@ def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> Non def _without_record(core: BitcoinCore, secret: MasterSeed) -> bool: - fingerprint = core.fingerprint(secret) - _print(f"\nMaster fingerprint: {fingerprint.hex().upper()}", err=True) - _print(f"Backup identifier: {secret.header.identifier.upper()}", err=True) - _print(identifier_note(identifier_origin(secret, fingerprint)), err=True) + _print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True) _print(NO_RECORD_WARNING, err=True) return _text("Restore without a wallet record? [y/N]", optional=True).lower() in ("y", "yes") @@ -443,10 +404,7 @@ def _initialize_wallet( def _connected_core(fallback: str | None = None) -> BitcoinCore: try: - core = BitcoinCore.connect( - lambda prompt: _text(prompt, optional=True), - lambda message: _print(message, err=True), - ) + core = BitcoinCore.connect(lambda message: _print(message, err=True)) _print("", err=True) return core except KeyboardInterrupt as error: @@ -467,10 +425,8 @@ def _create( indices: str | None, existing: bool, ) -> int: - profile, selected_threshold, identifier = _creation_header(header) + selected_threshold, identifier = _creation_header(header) threshold = 0 if selected_threshold is None else selected_threshold - if profile is not Profile.MS: - raise _UsageError("Only Bitcoin master-seed backups can be created.") if shares is not None and indices is not None: raise _UsageError("Choose either --shares or --indices, not both.") if byte_length is not None and existing: @@ -483,11 +439,9 @@ def _create( else: raise _UsageError("For thresholds 4 through 9, choose --shares or --indices.") core = _connected_core() - source = _creation_source(profile) if existing else None - if not existing and not sys.stdin.isatty() and _text("", optional=True): - raise _UsageError("Use --existing when supplying a seed or secret.") + source = _creation_source() if existing else None if isinstance(source, (Share, Secret)) and not isinstance(source, MasterSeed): - raise _UsageError(f"Enter one {_profile_rules(profile).label}, not a share or another backup type.") + raise _UsageError("Enter one Bitcoin master seed, not a share or another backup type.") try: if threshold == 0: if isinstance(source, MasterSeed): @@ -497,16 +451,13 @@ def _create( ) secret = source else: - secret = _generated_secret(source, byte_length, identifier, core.fingerprint_seed) - _emit(secret, False, fingerprint=None if existing else core.fingerprint) - if sys.stdin.isatty(): - _confirm_card(secret) - return ( - _initialize_wallet( - core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing + secret = generate_master_seed( + source, byte_length=byte_length, identifier=identifier, fingerprint=core.fingerprint_seed ) - if core is not None - else 0 + _emit(secret, False, fingerprint=None if existing else core.fingerprint) + _confirm_card(secret) + return _initialize_wallet( + core, secret, timestamp=0 if existing else "now", fresh=not existing, restore=existing ) if isinstance(source, MasterSeed): ceremony = CreationCeremony.from_secret( @@ -517,7 +468,9 @@ def _create( indices=indices, ) elif source is not None: - source_secret = _generated_secret(source, None, identifier, core.fingerprint_seed) + source_secret = generate_master_seed( + source, identifier=identifier, fingerprint=core.fingerprint_seed + ) ceremony = CreationCeremony.from_secret( source_secret, threshold=threshold, @@ -544,13 +497,9 @@ def _create( _confirm_card(artifact, ceremony.confirm) _print(f"Recovery card {position + 1} of {output_count} confirmed.", err=True) finished = ceremony.finish() - assert isinstance(finished, MasterSeed) - if core is not None: - return _initialize_wallet( - core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing - ) - _print("\nEvery recovery card was confirmed from its re-entered text.", err=True) - return 0 + return _initialize_wallet( + core, finished, timestamp=0 if existing else "now", fresh=not existing, restore=existing + ) def _correct( @@ -600,34 +549,18 @@ def _correct( if len(raw_hrp := normalized[:separator]) > 83 or not all("!" <= c <= "~" for c in raw_hrp): raise _UsageError("The application prefix must be at most 83 printable ASCII characters.") hrp = raw_hrp.lower() - if context.master_seed and hrp != Profile.MS.value: + if context.master_seed and hrp != MS: raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.") try: parse_codex32(normalized) except CodexError: pass else: - if isinstance(byte_length, int) and len(normalized) != _ms_text_length(byte_length): + if isinstance(byte_length, int) and len(normalized) != _text_length(byte_length): raise _UsageError("--bytes does not match the valid master-seed backup length.") _print("The codex32 string is already valid.") return 0 - search_value, erased, immutable = normalized, normalized, normalized[: separator + 1] - interpreted = _case_interpretation(normalized, immutable, context.profiles, None) - if interpreted is not None: - candidate, search_value, erased, immutable = interpreted - if ( - candidate is not None - and isinstance(byte_length, int) - and len(candidate.artifact.text) != _ms_text_length(byte_length) - ): - raise _UsageError("--bytes does not match the corrected master-seed backup length.") - else: - candidate = None - if candidate is not None: - candidates: tuple[CorrectionCandidate, ...] = (candidate,) - complete = True - else: - candidates, complete = _scheduled_candidates(search_value, erased, hrp, byte_length, immutable) + candidates, complete = _corrections(normalized, "", context.hrp, [], byte_length=byte_length) if not complete and not candidates: raise _CommandError("The correction search did not complete within ten seconds.") if not candidates: @@ -687,7 +620,7 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: if command == "check": artifacts = _artifacts( one=True, - profiles=context.profiles, + hrp=context.hrp, initial_prefix=context.initial_prefix, ) _print("\n\n".join("\n".join(_summary(item, valid=True)) for item in artifacts)) @@ -696,7 +629,7 @@ def _dispatch(arguments: argparse.Namespace, context: _CliContext) -> int: _emit( _secret( _artifacts( - profiles=context.profiles, + hrp=context.hrp, initial_prefix=context.initial_prefix, fingerprint=fingerprint_core.fingerprint if fingerprint_core is not None else None, ) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 1d4999ef..34a6cd6b 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -39,17 +39,18 @@ from codex32.bip93 import ( IDX_SORT, Header, + MasterSeed, Secret, Share, _checksum_for_encoded_length, + _has_generation_padding, + _validate_ms_length, parse_codex32, ) from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum from codex32.errors import CodexError, InvalidCorrectionInput from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply -from codex32.profiles import Profile, _optional_profile_rules -from codex32.profiles.ms32 import MasterSeed, _has_generation_padding @dataclass(frozen=True, slots=True) @@ -68,15 +69,14 @@ class CorrectionContext: def __init__( self, - hrp: str | Profile, + hrp: str, expected_length: int | None = None, immutable_prefix: str | None = None, excluded_indices: tuple[str, ...] = (), ) -> None: - if not isinstance(hrp, (str, Profile)): - raise TypeError("hrp must be str or Profile") - normalized = hrp.value if isinstance(hrp, Profile) else hrp.lower() - object.__setattr__(self, "hrp", normalized) + if not isinstance(hrp, str): + raise TypeError("hrp must be str") + object.__setattr__(self, "hrp", hrp.lower()) object.__setattr__(self, "expected_length", expected_length) object.__setattr__(self, "immutable_prefix", immutable_prefix) object.__setattr__(self, "excluded_indices", excluded_indices) @@ -262,7 +262,7 @@ def _spec_for_checksum(checksum: _Checksum) -> _Spec: return _SHORT_SPEC if checksum is _CODEX32_LONG: return _LONG_SPEC - raise AssertionError("registered profile selected a non-codex32 checksum") + raise AssertionError("a non-codex32 checksum was selected") def _residue(spec: _Spec, hrp: str, body: list[int]) -> list[int]: @@ -382,28 +382,14 @@ def _small_locator(sequence: list[int], maximum: int) -> list[int] | None: return [1, *coefficients] if len(coefficients) <= maximum else None -def _locator_poly( - syndromes: list[int], - erasure_poly: list[int], - maximum: int | None, - erasure_products: tuple[tuple[int, ...], ...] | None = None, -) -> list[int] | None: +def _locator_poly(syndromes: list[int], erasure_poly: list[int], maximum: int | None) -> list[int] | None: degree = len(erasure_poly) - 1 - if degree == 2 and erasure_products is not None: - first, second = erasure_products - modified = [ - syndromes[index - 2] ^ first[syndromes[index]] ^ second[syndromes[index - 1]] - for index in range(2, len(syndromes)) - ] - else: - modified = [] - for output_index in range(degree, len(syndromes)): - value = syndromes[output_index - degree] - products = erasure_products - for index, coefficient in enumerate(erasure_poly[:-1]): - syndrome = syndromes[output_index - index] - value ^= _gf1024_mul(coefficient, syndrome) if products is None else products[index][syndrome] - modified.append(value) + modified = [] + for output_index in range(degree, len(syndromes)): + value = syndromes[output_index - degree] + for index, coefficient in enumerate(erasure_poly[:-1]): + value ^= _gf1024_mul(coefficient, syndromes[output_index - index]) + modified.append(value) if maximum is not None and maximum <= 2: return _small_locator(modified, maximum) coefficients, _adjustment = _synthesize_rec(list(reversed(modified))) @@ -431,13 +417,12 @@ def _bch_syndrome_corrections( word_length: int, max_substitutions: int | None, erasure_state: tuple[tuple[int, ...], list[int]] | None = None, - erasure_products: tuple[tuple[int, ...], ...] | None = None, ) -> list[tuple[int, int]] | None: word_roots = _word_roots(spec, word_length) erasure_roots, erasure_poly = ( _erasure_state(spec, word_length, tuple(erasure_indices)) if erasure_state is None else erasure_state ) - locator = _locator_poly(syndromes, erasure_poly, max_substitutions, erasure_products) + locator = _locator_poly(syndromes, erasure_poly, max_substitutions) if locator is None or max_substitutions is not None and len(locator) - 1 > max_substitutions: return None errors = [ @@ -471,7 +456,6 @@ def _repair_body( alignment: tuple[int, tuple[tuple[int, ...], ...]], erasure_indices: Sequence[int] | None, erasure_state: tuple[tuple[int, ...], list[int]] | None, - erasure_products: tuple[tuple[int, ...], ...] | None, packed_syndromes: int | None = None, ) -> tuple[list[int], list[tuple[int, int]]] | None: erasures = ( @@ -491,7 +475,6 @@ def _repair_body( len(body), max_substitutions, erasure_state, - erasure_products, ) if len(erasures) <= len(spec.roots) else None @@ -526,7 +509,6 @@ class _FixedCorrector: __slots__ = ( "alignment", "erasure_indices", - "erasure_products", "erasure_state", "hrp", "max_substitutions", @@ -538,17 +520,15 @@ class _FixedCorrector: def __init__( self, - hrp: str | Profile, + hrp: str, body_length: int, uppercase: bool, max_substitutions: int | None, mutable_start: int = 0, ) -> None: - normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() - profile_rules = _optional_profile_rules(normalized_hrp) + normalized_hrp = hrp.lower() checksum = _checksum_for_encoded_length(normalized_hrp, body_length) - if profile_rules is not None: - profile_rules.validate_payload_length(body_length - checksum.length - 6) + _validate_ms_length(normalized_hrp, len(normalized_hrp) + 1 + body_length) self.hrp = normalized_hrp self.prefix = f"{normalized_hrp}1" self.spec = _spec_for_checksum(checksum) @@ -557,7 +537,6 @@ def __init__( self.max_substitutions = max_substitutions self.mutable_start = mutable_start self.erasure_indices: tuple[int, ...] | None = None - self.erasure_products: tuple[tuple[int, ...], ...] | None = None self.erasure_state: tuple[tuple[int, ...], list[int]] | None = None def correct( @@ -575,9 +554,6 @@ def correct( return None self.erasure_indices = tuple(erasure_indices) self.erasure_state = _erasure_state(self.spec, len(values), self.erasure_indices) - # Alignment locations change frequently. Building 2048 products per - # pair costs more than the handful of GF(1024) multiplies per call. - self.erasure_products = None repair = _repair_body( self.spec, self.hrp, @@ -586,7 +562,6 @@ def correct( self.alignment, erasure_indices, self.erasure_state if erasure_indices is not None else None, - self.erasure_products if erasure_indices is not None else None, packed_syndromes, ) if repair is None: @@ -722,13 +697,13 @@ def _corrections_reach_target(spec: _Spec, residue: list[int], corrections: list def _correct_fixed( damaged_text: str, *, - suspected_profile: str | Profile, + hrp: str, max_substitutions: int | None = None, immutable_prefix: str | None = None, ) -> CorrectionCandidate | None: - if not isinstance(suspected_profile, (str, Profile)): - raise TypeError("suspected_profile must be str or Profile") - hrp = suspected_profile.value if isinstance(suspected_profile, Profile) else suspected_profile.lower() + if not isinstance(hrp, str): + raise TypeError("hrp must be str") + hrp = hrp.lower() try: uppercase = _validate_single_case_ascii(damaged_text) except TypeError: @@ -779,9 +754,7 @@ def _validate_context(context: CorrectionContext) -> None: checksum = _checksum_for_encoded_length(context.hrp, body_length) if body_length < checksum.length + 6: raise ValueError("expected_length must contain a header and checksum") - rules = _optional_profile_rules(context.hrp) - if rules is not None: - rules.validate_payload_length(body_length - checksum.length - 6) + _validate_ms_length(context.hrp, length) prefix = context.immutable_prefix if prefix is not None: if not isinstance(prefix, str): @@ -883,7 +856,11 @@ def _best( def _correct_complete( - context: CorrectionContext, damaged_text: str, *, deadline: float | None = None + context: CorrectionContext, + damaged_text: str, + *, + deadline: float | None = None, + allowed: Callable[[CorrectionCandidate], bool] | None = None, ) -> tuple[tuple[CorrectionCandidate, ...], bool]: if not isinstance(context, CorrectionContext): raise TypeError("context must be CorrectionContext") @@ -952,6 +929,8 @@ def _correct_complete( c.expected_length for c in preflight_contexts if c.expected_length is not None ), deadline=deadline, + competitors=True, + allowed=allowed, observed_text=damaged_text, seed_candidates=candidates, required_only=True, @@ -979,6 +958,8 @@ def _correct_complete( value, primary=frozenset(c.expected_length for c in contexts if c.expected_length is not None), deadline=deadline, + competitors=True, + allowed=allowed, capture_layers=capture_layers, observed_text=damaged_text, seed_candidates=candidates, @@ -987,9 +968,13 @@ def _correct_complete( complete &= current_complete if not current_complete and not candidates: return (), False - candidates = ( - _restore_case_edits(candidates, uppercase=uppercase) if interpretation is not None else candidates - ) + if interpretation is not None: + candidates = _restore_case_edits(candidates, uppercase=uppercase) + if allowed is not None: + candidates = tuple(candidate for candidate in candidates if allowed(candidate)) + if not complete: + # A candidate from a completed pass must not hide a later truncation. + candidates = tuple(replace(candidate, search_complete=False) for candidate in candidates) return candidates, complete diff --git a/src/codex32/errors.py b/src/codex32/errors.py index 03fce90c..e796bc12 100644 --- a/src/codex32/errors.py +++ b/src/codex32/errors.py @@ -14,24 +14,18 @@ def _error(name: str, base: type[CodexError], doc: str) -> type[CodexError]: InvalidCharacter = _error("InvalidCharacter", CodexError, "Input contains an invalid character.") MissingSeparator = _error("MissingSeparator", CodexError, "Input lacks a Bech32 separator.") InvalidCase = _error("InvalidCase", CodexError, "Input mixes upper- and lowercase characters.") -InvalidLength = _error("InvalidLength", CodexError, "Input length is not permitted by its profile.") -UnknownProfile = _error("UnknownProfile", CodexError, "The HRP is not a registered profile.") +InvalidLength = _error("InvalidLength", CodexError, "Input length is not permitted.") InvalidChecksum = _error("InvalidChecksum", CodexError, "The outer checksum does not validate.") -InvalidHeader = _error("InvalidHeader", CodexError, "The six-symbol header is invalid.") -InvalidThreshold = _error("InvalidThreshold", InvalidHeader, "The threshold is not 0 or 2 through 9.") -InvalidIdentifier = _error("InvalidIdentifier", InvalidHeader, "The identifier is not four symbols.") -InvalidShareIndex = _error("InvalidShareIndex", InvalidHeader, "The share index conflicts with its header.") +InvalidThreshold = _error("InvalidThreshold", CodexError, "The threshold is not 0 or 2 through 9.") +InvalidIdentifier = _error("InvalidIdentifier", CodexError, "The identifier is not four symbols.") +InvalidShareIndex = _error("InvalidShareIndex", CodexError, "The share index conflicts with its header.") -InvalidPayload = _error("InvalidPayload", CodexError, "The payload is not permitted by its profile.") -InvalidPadding = _error("InvalidPadding", InvalidPayload, "The discarded payload padding is invalid.") -InvalidBip39Checksum = _error("InvalidBip39Checksum", InvalidPayload, "The BIP39 checksum is invalid.") +InvalidPadding = _error("InvalidPadding", CodexError, "The discarded payload padding is invalid.") InvalidShareSet = _error("InvalidShareSet", CodexError, "Artifacts cannot form an interpolation set.") WrongShareCount = _error("WrongShareCount", InvalidShareSet, "The share count differs from its threshold.") MismatchedHrp = _error("MismatchedHrp", InvalidShareSet, "Application prefixes differ.") -# Compatibility name retained across the 1.0 boundary. -MismatchedProfile = MismatchedHrp MismatchedThreshold = _error("MismatchedThreshold", InvalidShareSet, "Thresholds differ.") MismatchedIdentifier = _error("MismatchedIdentifier", InvalidShareSet, "Identifiers differ.") MismatchedPayloadLength = _error("MismatchedPayloadLength", InvalidShareSet, "Payload lengths differ.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 7ad8c2e3..df0d6853 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -6,16 +6,22 @@ from collections.abc import Callable, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass -from typing import NoReturn, SupportsIndex, cast +from typing import cast from codex32._bip32 import _valid_root -from codex32.bech32 import CHARSET, _u5_to_chars, convertbits +from codex32.bech32 import _u5_to_chars, convertbits from codex32.bip93 import ( + DEFAULT_SEED_BYTES, IDX_SORT, + MS, + SEED_BYTE_LENGTHS, Header, + MasterSeed, Secret, Share, _from_parts, + _has_generation_padding, + _payload_length, derive_share, recover_secret, ) @@ -23,21 +29,10 @@ CeremonyStateError, CodexError, HeaderCollision, - InvalidIdentifier, InvalidLength, InvalidShareSelection, InvalidThreshold, ) -from codex32.profiles import Profile -from codex32.profiles.ms32 import ( - DEFAULT_SEED_BYTES, - SEED_BYTE_LENGTHS, - MasterSeed, - _payload_length, -) -from codex32.profiles.ms32 import ( - _has_generation_padding as _ms_padding, -) ORDINARY_INDICES = tuple(IDX_SORT[1:]) @@ -48,23 +43,81 @@ class ConfirmationResult: accepted: bool mismatched_groups: tuple[int, ...] = () - - -def _threshold(value: object, *, allow_zero: bool = True) -> int: - if isinstance(value, bool) or not isinstance(value, int): - raise InvalidThreshold("threshold must be an integer") - if value not in ((0, *range(2, 10)) if allow_zero else tuple(range(2, 10))): - raise InvalidThreshold(f"threshold must be {'0 or ' if allow_zero else ''}2 through 9") - return value - - -def _identifier(value: object) -> str: - if not isinstance(value, str): - raise InvalidIdentifier("identifier must be str") - value = value.lower() - if len(value) != 4 or any(character not in CHARSET for character in value): - raise InvalidIdentifier("identifier must be four Bech32 symbols") - return value + entered_groups: tuple[str, ...] = () + + +def _confirmation(text: str, expected: str) -> ConfirmationResult: + """Compare re-entered text with a card, group by group, allowing shifts.""" + groups, changed = _entered_groups(text, expected) + return ConfirmationResult(not changed, tuple(index + 1 for index in sorted(changed)), tuple(groups)) + + +def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: + # Keep entered tokens whole unless they exactly span complete groups. + # Unspaced input retains edit/group minimization and edit-order ties. + positions = [i for i, char in enumerate(observed) if not char.isspace()] + compact = "".join(observed.split()) + expected = "".join(expected.split()).lower() + tokens = observed.split() + grouped = len(tokens) > 1 and compact.lower() != expected + boundaries = {0} + splits: set[tuple[int, int]] = set() + position = 0 + for token in tokens: + for offset in range(0, len(expected), 4): + if expected[offset : offset + len(token)] == token.lower() and ( + len(token) % 4 == 0 or offset + len(token) == len(expected) + ): + splits.update((position + i, offset + i) for i in range(4, len(token), 4)) + position += len(token) + boundaries.add(position) + scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] + for offset in range(0, len(expected), 4): + canonical = expected[offset : offset + 4] + following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} + for start, (edits, disturbed, trace, ends) in scores.items(): + row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] + for end in range(start, len(compact) + 1): + if end > start: + current = [(end - start, b"\x02" * (end - start))] + for j, char in enumerate(canonical, 1): + edit = compact[end - 1].lower() != char + current.append( + min( + (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), + (row[j][0] + 1, row[j][1] + b"\x02"), + (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), + ) + ) + row = current + if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: + continue + score = ( + edits + row[-1][0], + disturbed + (not grouped and compact[start:end].lower() != canonical), + trace + row[-1][1], + (*ends, end), + ) + if end not in following or score < following[end]: + following[end] = score + scores = following + groups = [] + start = 0 + for end in scores[len(compact)][3]: + stop = positions[end] if end < len(positions) else len(observed) + groups.append(observed[start:stop]) + start = stop + changed = { + i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] + } + return groups, changed + + +def _shared_header(threshold: int, identifier: str) -> Header: + """Validate a new share set's header fields once, through Header.""" + if threshold == 0: + raise InvalidThreshold("A shared backup needs a threshold of 2 through 9.") + return Header(threshold, identifier, "a") def _index(value: object) -> str: @@ -118,7 +171,7 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: 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.MS, Header(threshold, identifier, index), symbols) + artifact = _from_parts(MS, Header(threshold, identifier, index), symbols) assert isinstance(artifact, Share) return artifact @@ -150,13 +203,13 @@ def generate_master_seed( if supplied is not None: if not _valid_root(supplied): raise CodexError("master seed does not form a valid BIP32 root") - identifier = _random_identifier() if identifier is None else _identifier(identifier) + identifier = _random_identifier() if identifier is None else identifier return MasterSeed.from_seed(supplied, identifier=identifier) if identifier is None and fingerprint is None: raise ValueError( "fresh unshared master-seed generation requires a fingerprint provider or identifier" ) - selected_identifier = _identifier(identifier) if identifier is not None else None + selected_identifier = Header(0, identifier, "s").identifier if identifier is not None else None while True: fresh = secrets.token_bytes(length) if not _valid_root(fresh): @@ -181,12 +234,6 @@ class CreationCeremony: _set_identifier: str _threshold: int - def __init__(self) -> None: - raise TypeError("use a CreationCeremony class constructor") - - def __reduce_ex__(self, _protocol: SupportsIndex) -> NoReturn: - raise TypeError("creation ceremonies cannot be copied or serialized") - @classmethod def _start( cls, @@ -205,7 +252,7 @@ def _start( if secret is None: self._secret, self._basis, self._direct_count = None, [], threshold else: - reheadered = _from_parts(Profile.MS, Header(threshold, identifier, "s"), secret.payload_symbols) + reheadered = _from_parts(MS, Header(threshold, identifier, "s"), secret.payload_symbols) assert isinstance(reheadered, MasterSeed) self._secret, self._basis, self._direct_count = ( reheadered, @@ -225,15 +272,14 @@ def master_seed( identifier: str | None = None, ) -> CreationCeremony: """Start a ceremony for a fresh shared Bitcoin master seed.""" - threshold = _threshold(threshold, allow_zero=False) + header = _shared_header(threshold, _random_identifier() if identifier is None else identifier) _supplied, byte_length = _seed_input(None, byte_length) - identifier = _random_identifier() if identifier is None else _identifier(identifier) return cls._start( _payload_length(byte_length), - threshold, + header.threshold, share_count, indices, - identifier, + header.identifier, None, ) @@ -250,9 +296,9 @@ def from_secret( """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) + header = _shared_header(threshold, _random_identifier() if identifier is None else identifier) + threshold, identifier = header.threshold, header.identifier while (threshold, identifier) == ( secret.header.threshold, secret.header.identifier, @@ -270,7 +316,7 @@ def from_secret( ) def _candidate_is_accepted(self, secret: MasterSeed) -> bool: - return _valid_root(secret.seed_bytes) and _ms_padding(secret) + return _valid_root(secret.seed_bytes) and _has_generation_padding(secret) def next_share(self) -> Share: """Generate or derive the next card after the previous card is confirmed.""" @@ -309,20 +355,14 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()).lower() - expected = self._pending.text.lower() - mismatched = tuple( - group + 1 - for group in range((max(len(observed), len(expected)) + 3) // 4) - if observed[group * 4 : group * 4 + 4] != expected[group * 4 : group * 4 + 4] - ) - if mismatched: - return ConfirmationResult(False, mismatched) + result = _confirmation(text, self._pending.text) + if not result.accepted: + return result if self._position < self._direct_count: self._basis.append(self._pending) self._position += 1 self._pending = None - return ConfirmationResult(True) + return result def finish(self) -> MasterSeed: """Return the secret after every requested card is confirmed.""" diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 26c00717..c19143c8 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -431,7 +431,7 @@ def _search_fixed( ) -> CorrectionCandidate | None: fixed = _correct_fixed( state.text, - suspected_profile=state.context.hrp, + hrp=state.context.hrp, immutable_prefix=state.context.immutable_prefix, ) if fixed is not None: diff --git a/src/codex32/profiles/__init__.py b/src/codex32/profiles/__init__.py deleted file mode 100644 index 3293130e..00000000 --- a/src/codex32/profiles/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Fixed selection of the supported codex32 application profiles.""" - -from __future__ import annotations - -from enum import Enum -from typing import TYPE_CHECKING, TypeAlias, Union - -from codex32.errors import UnknownProfile - -if TYPE_CHECKING: - from codex32.profiles.bip39 import _Bip39Rules - from codex32.profiles.cl32 import _Cl32Rules - from codex32.profiles.ms32 import _Ms32Rules -_ProfileRules: TypeAlias = Union["_Ms32Rules", "_Cl32Rules", "_Bip39Rules"] - - -class Profile(str, Enum): - __str__ = str.__str__ - - MS = "ms" - CL = "cl" - BIP39_12W = "bip39_12w" - BIP39_24W = "bip39_24w" - - -def _profile_rules(hrp: str | Profile) -> _ProfileRules: - try: - profile = hrp if isinstance(hrp, Profile) else Profile(hrp.lower()) - except (ValueError, AttributeError) as error: - raise UnknownProfile(f"The application prefix {hrp!r} is not supported.") from error - if profile is Profile.MS: - from codex32.profiles.ms32 import RULES as ms32_rules - - return ms32_rules - elif profile is Profile.CL: - from codex32.profiles.cl32 import RULES as cl32_rules - - return cl32_rules - from codex32.profiles.bip39 import BIP39_12W_RULES, BIP39_24W_RULES - - return BIP39_12W_RULES if profile is Profile.BIP39_12W else BIP39_24W_RULES - - -def _optional_profile_rules(hrp: str | Profile) -> _ProfileRules | None: - """Return registered application rules without rejecting an opaque HRP.""" - try: - return _profile_rules(hrp) - except UnknownProfile: - return None diff --git a/src/codex32/profiles/bip39.py b/src/codex32/profiles/bip39.py deleted file mode 100644 index d01d7604..00000000 --- a/src/codex32/profiles/bip39.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Migration-only BIP39 profile rules and validated secret type.""" - -import hashlib -from dataclasses import dataclass - -from codex32.bip93 import Header, Secret -from codex32.errors import ( - InvalidBip39Checksum, - InvalidLength, - InvalidPadding, -) -from codex32.profiles import Profile - - -class Bip39Secret(Secret): - """Migration-only BIP39 S artifact without entropy or mnemonic access.""" - - __slots__ = () - - -@dataclass(frozen=True, slots=True) -class _Bip39Rules: - profile: Profile - label: str - text_length: int - payload_length: int - entropy_bits: int - checksum_bits: int - outer_padding: int - secret_type = Bip39Secret - basis_secret_type: type[Secret] | None = Bip39Secret - basis_error = "BIP39 basis did not imply a valid BIP39 secret" - - def validate_text_length(self, text_length: int) -> None: - if text_length != self.text_length: - raise InvalidLength( - f"This input has {text_length} characters. A {self.label} backup must have " - f"exactly {self.text_length}." - ) - - def validate_payload_length(self, payload_length: int) -> None: - if payload_length != self.payload_length: - article = "an" if self.text_length == 82 else "a" - raise InvalidLength( - f"This input has the wrong length for a {self.label} backup; expected {article} " - f"{self.text_length}-character codex32 string." - ) - - def validate_payload(self, symbols: tuple[int, ...], header: Header) -> None: - if header.index != "s": - return - value = 0 - for symbol in symbols: - value = value << 5 | symbol - if value & ((1 << self.outer_padding) - 1): - raise InvalidPadding("BIP39 S requires zero outer u5 padding") - semantic = value >> self.outer_padding - embedded = semantic & ((1 << self.checksum_bits) - 1) - entropy = semantic >> self.checksum_bits - entropy_bytes = entropy.to_bytes(self.entropy_bits // 8, "big") - expected = hashlib.sha256(entropy_bytes).digest()[0] >> (8 - self.checksum_bits) - if embedded != expected: - raise InvalidBip39Checksum("embedded BIP39 entropy checksum is invalid") - - -BIP39_12W_RULES = _Bip39Rules(Profile.BIP39_12W, "12-word BIP39 worksheet", 56, 27, 128, 4, 3) -BIP39_24W_RULES = _Bip39Rules(Profile.BIP39_24W, "24-word BIP39 worksheet", 82, 53, 256, 8, 1) diff --git a/src/codex32/profiles/cl32.py b/src/codex32/profiles/cl32.py deleted file mode 100644 index 51cc8c35..00000000 --- a/src/codex32/profiles/cl32.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Core Lightning HSM-secret profile rules and validated secret type.""" - -from __future__ import annotations - -from codex32.bech32 import convertbits -from codex32.bip93 import Header, Secret -from codex32.errors import InvalidLength, InvalidThreshold -from codex32.profiles import Profile - -PAYLOAD_LENGTH, TEXT_LENGTH = 52, 74 - - -class CoreLightningSecret(Secret): - """A validated 32-byte Core Lightning HSM secret.""" - - __slots__ = () - - @property - def secret_bytes(self) -> bytes: - """Return the 32-byte Core Lightning HSM secret represented by S.""" - return bytes(convertbits(self.payload_symbols, 5, 8, pad=False, accept_any_padding=True)) - - -class _Cl32Rules: - profile, label = Profile.CL, "Core Lightning HSM secret" - secret_type = CoreLightningSecret - basis_secret_type: type[Secret] | None = None - basis_error = "" - - def validate_text_length(self, text_length: int) -> None: - if text_length != TEXT_LENGTH: - raise InvalidLength( - f"This input has {text_length} characters. A Core Lightning HSM secret backup " - f"must have exactly {TEXT_LENGTH}." - ) - - def validate_payload_length(self, payload_length: int) -> None: - if payload_length != PAYLOAD_LENGTH: - raise InvalidLength( - "This input has the wrong length for a Core Lightning HSM secret backup; " - f"expected a {TEXT_LENGTH}-character codex32 string." - ) - - 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 deleted file mode 100644 index ac662ee7..00000000 --- a/src/codex32/profiles/ms32.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Bitcoin master-seed profile rules and validated secret type.""" - -from __future__ import annotations - -from codex32.bech32 import convertbits -from codex32.bip93 import Header, Secret, _from_parts -from codex32.checksums import _crc_pad -from codex32.errors import InvalidLength -from codex32.profiles import Profile - -_SIZES = ((16, 26, 48), (20, 32, 54), (24, 39, 61), (28, 45, 67), (32, 52, 74), (64, 103, 127)) -SEED_BYTE_LENGTHS, DEFAULT_SEED_BYTES = tuple(size[0] for size in _SIZES), 16 -_PAYLOAD_LENGTHS = tuple(size[1] for size in _SIZES) - - -def _payload_length(byte_length: int) -> int: - return (byte_length * 8 + 4) // 5 - - -def _text_length(byte_length: int) -> int: - payload_length = _payload_length(byte_length) - return payload_length + (22 if payload_length + 22 <= 91 else 24) - - -TEXT_LENGTHS = tuple(size[2] for size in _SIZES) - - -def _payload_padding(secret: MasterSeed) -> int: - padding_bits = (len(secret.payload_symbols) * 5) % 8 - return secret.payload_symbols[-1] & ((1 << padding_bits) - 1) if padding_bits else 0 - - -def _has_generation_padding(secret: MasterSeed) -> bool: - return _payload_padding(secret) == _crc_pad(secret.seed_bytes) - - -class MasterSeed(Secret): - """A validated BIP93 ``ms`` secret.""" - - __slots__ = () - - @property - def seed_bytes(self) -> bytes: - """Return the BIP32 master-seed bytes represented by S.""" - return bytes(convertbits(self.payload_symbols, 5, 8, pad=False, accept_any_padding=True)) - - @classmethod - def from_seed(cls, seed_bytes: bytes, *, identifier: str, threshold: int = 0) -> MasterSeed: - """Encode a supported BIP93 master seed as S with generation-only CRC padding.""" - if not isinstance(seed_bytes, bytes): - raise TypeError("seed_bytes must be bytes") - if len(seed_bytes) not in SEED_BYTE_LENGTHS: - raise InvalidLength("master seed must contain 16, 20, 24, 28, 32, or 64 bytes") - payload = tuple(convertbits(seed_bytes, 8, 5, pad=True, pad_value=_crc_pad(seed_bytes))) - artifact = _from_parts(Profile.MS, Header(threshold, identifier, "s"), payload) - assert isinstance(artifact, MasterSeed) - return artifact - - -class _Ms32Rules: - profile, label, secret_type = Profile.MS, "Bitcoin master seed", MasterSeed - basis_secret_type: type[Secret] | None = None - basis_error = "" - - def validate_text_length(self, text_length: int) -> None: - if text_length not in TEXT_LENGTHS: - raise InvalidLength( - f"This input has {text_length} characters. A Bitcoin master-seed backup must have " - "exactly 48, 54, 61, 67, 74, or 127 characters." - ) - - def validate_payload_length(self, payload_length: int) -> None: - if payload_length not in _PAYLOAD_LENGTHS: - raise InvalidLength( - "This input has the wrong length for a Bitcoin master-seed backup; expected a " - "16-, 20-, 24-, 28-, 32-, or 64-byte seed." - ) - - def validate_payload(self, _payload: tuple[int, ...], _header: Header) -> None: - pass - - -RULES = _Ms32Rules() diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 8ec4c239..6c2b43c4 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -1,7 +1,7 @@ """Bitcoin wallet interoperability for validated master seeds.""" from codex32._bip32 import _master_xprv_from_seed -from codex32.profiles.ms32 import MasterSeed +from codex32.bip93 import MasterSeed def master_xprv(secret: MasterSeed, *, testnet: bool = False) -> str: diff --git a/tests/data/README.md b/tests/data/README.md index 64fdf038..50e4de43 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -3,7 +3,8 @@ - `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 BIP39 interpolation fixtures. +- `sharing_vectors.py` contains data-only interpolation fixtures for HRPs other + than `ms`. - `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 454b83b2..8c0f616f 100644 --- a/tests/data/malformed_inputs.json +++ b/tests/data/malformed_inputs.json @@ -6,8 +6,7 @@ {"id": "mixed-case", "text": "Ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"}, {"id": "invalid-checksum", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlq"}, {"id": "non-ascii", "text": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlé"}, - {"id": "embedded-newline", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"}, - {"id": "shared-core-lightning", "text": "cl12testapppppppppppppppppppppppppppppppppppppppppppppppppppprqasqgqsaeje2"} + {"id": "embedded-newline", "text": "ms10testsxxxxxxxxxxxxxxxx\nxxxxxxxxxx4nzvca9cmczlw"} ], "interpolation": [ { diff --git a/tests/data/sharing_vectors.py b/tests/data/sharing_vectors.py index d3a536f9..b1c4cbee 100644 --- a/tests/data/sharing_vectors.py +++ b/tests/data/sharing_vectors.py @@ -1,8 +1,7 @@ -"""Frozen BIP39 sharing fixtures. +"""Frozen sharing fixtures for HRPs other than ms. -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 +Each table fixes a 2-point S/A basis and independently recorded C/D +codewords. These are data only: production arithmetic is not duplicated in the test suite. Official BIP93 vectors remain the independent GF(32) correctness anchor. """ @@ -21,9 +20,3 @@ "D": "bip39_24w12testdyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy63fsk8u6n3hnu04", }, } - - -INVALID_BIP39_IMPLIED_SECRET = { - "A": "bip39_12w12testapppppppppppppppppppppppppppnz8ygjskeu3nc", - "C": "bip39_12w12testckkkkkkkkkkkkkkkkkkkkkkkkkkk9spn92wl9dcuz", -} diff --git a/tests/test_alignment.py b/tests/test_alignment.py index b67b8ac6..94db14c6 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -187,7 +187,7 @@ def test_raw_admission_bounds_cover_generated_erasure_strata(): def test_structural_alignment_leaves_bch_capacity_for_substitutions(shape_args, swaps, masked_groups): from dataclasses import replace - from codex32 import CorrectionContext, MasterSeed, Profile + from codex32 import CorrectionContext, MasterSeed from codex32.bech32 import CHARSET from codex32.indel import _CLASSES, _frontier, _prepare, _search_target, _StructuralClass @@ -200,7 +200,7 @@ def test_structural_alignment_leaves_bch_capacity_for_substitutions(shape_args, chars[p : p + 4] = [CHARSET[CHARSET.index(c) ^ 1] for c in chars[p : p + 4]] for p in (10, 38, 42)[: 2 if masked_groups else 3]: chars[p] = CHARSET[CHARSET.index(chars[p]) ^ 1] - context = CorrectionContext(Profile.MS, len(source)) + context = CorrectionContext("ms", len(source)) state = _prepare(context, "".join(chars), _CLASSES) assert state is not None shape = _StructuralClass(0, 0, **shape_args) @@ -214,7 +214,7 @@ def test_structural_alignment_leaves_bch_capacity_for_substitutions(shape_args, def test_only_unique_optional_timeout_candidate_can_be_surfaced(interrupt_required, tie): from unittest.mock import patch - from codex32 import CorrectionContext, MasterSeed, Profile + from codex32 import CorrectionContext, MasterSeed from codex32.correction import CorrectionCandidate from codex32.indel import _FIXED, _search_many @@ -233,7 +233,7 @@ def search(state, frontier, results, deadline): with patch("codex32.indel._search_target", side_effect=search): candidates, complete = _search_many( - (CorrectionContext(Profile.MS, 48),), "ms1" + "q" * 45, primary=frozenset((48,)) + (CorrectionContext("ms", 48),), "ms1" + "q" * 45, primary=frozenset((48,)) ) assert not complete if interrupt_required or tie: @@ -271,11 +271,11 @@ def test_global_frontier_admits_exact_ceiling_but_rejects_larger_batch(): @pytest.mark.parametrize(("byte_length", "erasures"), ((16, 13), (64, 15))) @pytest.mark.parametrize("known", (False, True)) def test_full_consecutive_erasure_capacity_at_inclusive_shared_ceiling(byte_length, erasures, known): - from codex32 import CorrectionContext, MasterSeed, Profile, correct + from codex32 import CorrectionContext, MasterSeed, correct source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text damaged = source[:10] + "?" * erasures + source[10 + erasures :] - result = correct(CorrectionContext(Profile.MS, len(source) if known else None), damaged) + result = correct(CorrectionContext("ms", len(source) if known else None), damaged) assert len(result) == 1 and result[0].artifact.text == source assert result[0].search_complete assert result[0].capture_volume == 32**erasures @@ -285,11 +285,10 @@ def test_failed_alignment_does_not_scan_or_materialize_the_body(): from unittest.mock import patch from codex32.correction import _FixedCorrector - from codex32.profiles import Profile source = tuple(i % 32 for i in range(45)) view = _View(source, ((0, 45),), 45).swap(8, 30, 1) - solver = _FixedCorrector(Profile.MS, 45, False, 0) + solver = _FixedCorrector("ms", 45, False, 0) packed = _IncrementalSyndromes(solver.alignment, source).packed(view) with ( patch("codex32.correction._aligned_syndromes", side_effect=AssertionError("full syndrome scan")), @@ -299,10 +298,10 @@ def test_failed_alignment_does_not_scan_or_materialize_the_body(): def test_unknown_length_input_is_bounded_before_normalization(): - from codex32 import CorrectionContext, Profile, correct + from codex32 import CorrectionContext, correct class Oversized(str): def replace(self, *args, **kwargs): pytest.fail("oversized input was normalized") - assert correct(CorrectionContext(Profile.MS), Oversized("ms1" + "q" * 4096)) == () + assert correct(CorrectionContext("ms"), Oversized("ms1" + "q" * 4096)) == () diff --git a/tests/test_bip39.py b/tests/test_bip39.py deleted file mode 100644 index 0b962de9..00000000 --- a/tests/test_bip39.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Migration-only BIP39 profile validation.""" - -import pytest -from _codex32_oracle import oracle_encode as _oracle_encode - -from codex32 import Bip39Secret, Profile, Share, parse_codex32 -from codex32.errors import ( - InvalidBip39Checksum, - InvalidChecksum, - InvalidLength, - InvalidPadding, -) - -BIP39_12W_ZERO = "bip39_12w10testsqqqqqqqqqqqqqqqqqqqqqqqqqqcwa5plrxrewp27" -BIP39_24W_ZERO = "bip39_24w10testsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqxv5hjvxqkrlt8cg" - - -@pytest.mark.parametrize( - ("text", "profile", "payload_length"), - ( - (BIP39_12W_ZERO, Profile.BIP39_12W, 27), - (BIP39_24W_ZERO, Profile.BIP39_24W, 53), - ), -) -def test_frozen_bip39_zero_entropy_fixtures(text: str, profile: Profile, payload_length: int) -> None: - secret = parse_codex32(text) - assert isinstance(secret, Bip39Secret) - assert secret.profile is profile - assert len(secret.payload_symbols) == payload_length - assert parse_codex32(text.upper()).text == text.upper() - - -@pytest.mark.parametrize( - ("hrp", "payload"), - (("bip39_12w", "q" * 27), ("bip39_24w", "q" * 53)), -) -def test_valid_outer_checksum_does_not_mask_bad_bip39_checksum(hrp: str, payload: str) -> None: - with pytest.raises(InvalidBip39Checksum): - parse_codex32(_oracle_encode(hrp, "0tests" + payload)) - - -def test_outer_checksum_precedes_bip39_secret_semantics() -> None: - invalid_bip39 = _oracle_encode("bip39_12w", "0tests" + "q" * 27) - damaged = invalid_bip39[:-1] + ("q" if invalid_bip39[-1] != "q" else "p") - with pytest.raises(InvalidChecksum): - parse_codex32(damaged) - - -def test_bip39_secret_requires_zero_outer_padding() -> None: - body = BIP39_12W_ZERO.rsplit("1", 1)[1][:-13] - payload = body[6:] - # The final three bits are outer u5 padding; change only one of them. - changed = payload[:-1] + "e" - with pytest.raises(InvalidPadding): - parse_codex32(_oracle_encode("bip39_12w", "0tests" + changed)) - - -def test_bip39_share_is_only_a_uniform_symbol_mask() -> None: - share = parse_codex32(_oracle_encode("bip39_12w", "2testa" + "q" * 27)) - assert isinstance(share, Share) - assert not hasattr(share, "seed_bytes") - assert not hasattr(share, "entropy") - assert not hasattr(share, "mnemonic") - - -@pytest.mark.parametrize( - ("hrp", "payload"), - (("bip39_12w", "q" * 26), ("bip39_24w", "q" * 54)), -) -def test_bip39_exact_payload_lengths(hrp: str, payload: str) -> None: - with pytest.raises(InvalidLength): - parse_codex32(_oracle_encode(hrp, "2testa" + payload)) diff --git a/tests/test_bip93.py b/tests/test_bip93.py index a57b1f05..6d172082 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -21,25 +21,20 @@ ) from codex32 import ( - CoreLightningSecret, MasterSeed, - Profile, + Secret, Share, derive_share, parse_codex32, recover_secret, ) +from codex32.bech32 import convertbits from codex32.errors import CodexError, InvalidChecksum -def test_profile_string_matches_its_value() -> None: - assert str(Profile.MS) == "ms" - - def test_vector_1_parts_and_seed() -> None: secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) - assert secret.profile is Profile.MS assert secret.header.threshold == 0 assert secret.header.identifier == VECTOR_1["identifier"] assert secret.header.index == "s" @@ -93,13 +88,18 @@ def test_vector_5_long_uppercase_and_completion() -> None: assert parse_codex32(secret.text).text.isupper() -def test_vector_6_core_lightning_examples_are_immutable() -> None: +def test_vector_6_core_lightning_examples_are_generic_immutable_secrets() -> None: luea = parse_codex32(VECTOR_6["codex32_luea"]) cln2 = parse_codex32(VECTOR_6["codex32_cln2"]) peev = parse_codex32(VECTOR_6["codex32_peev"]) - assert all(isinstance(value, CoreLightningSecret) for value in (luea, cln2, peev)) - assert luea.secret_bytes.hex() == ("6c696768746e696e672d31330000000000000000000000000000000000000000") - assert peev.secret_bytes.hex() == ("5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1") + for value in (luea, cln2, peev): + assert type(value) is Secret and value.hrp == "cl" + + def payload(secret: Secret) -> str: + return bytes(convertbits(secret.payload_symbols, 5, 8, pad=False, accept_any_padding=True)).hex() + + assert payload(luea) == "6c696768746e696e672d31330000000000000000000000000000000000000000" + assert payload(peev) == "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" with pytest.raises((FrozenInstanceError, AttributeError)): luea.header = cln2.header # type: ignore[misc] @@ -132,14 +132,15 @@ def test_all_official_invalid_ms_examples_are_rejected(value: str) -> None: parse_codex32(value) -def test_registered_profile_reinterprets_official_wrong_application_example() -> None: +def test_official_wrong_application_example_is_a_generic_secret() -> None: # BIP93 lists this as invalid when an ms decoder was explicitly expected; - # the fixed registry recognizes that it is instead a valid CL secret. - assert isinstance(parse_codex32(INVALID_MASTER_SEED[0]), CoreLightningSecret) + # without an ms prefix it is an ordinary codex32 secret. + artifact = parse_codex32(INVALID_MASTER_SEED[0]) + assert type(artifact) is Secret and artifact.hrp != "ms" @pytest.mark.parametrize("value", INVALID_PREFIX_OR_SEPARATOR[-2:]) def test_generic_parser_accepts_checksum_valid_non_ms_hrps(value: str) -> None: artifact = parse_codex32(value) assert artifact.hrp in {"m", "s"} - assert artifact.profile is None + assert not isinstance(artifact, MasterSeed) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 838c542a..eda7ad4c 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import re import subprocess @@ -14,13 +13,9 @@ BitcoinCore, BitcoinCoreError, FingerprintMismatch, - identifier_note, - identifier_origin, parse_fingerprint, ) -from codex32.bip93 import parse_codex32 -from codex32.generation import _fingerprint_identifier -from codex32.profiles.ms32 import MasterSeed +from codex32.bip93 import MasterSeed, parse_codex32 _parsed = parse_codex32("ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw") assert isinstance(_parsed, MasterSeed) @@ -97,15 +92,9 @@ def _empty_info(**changes: object) -> dict[str, object]: @pytest.mark.parametrize( ("selected", "label"), - ( - ("main", "mainnet"), - ("test", "testnet3"), - ("testnet4", "testnet4"), - ("signet", "signet"), - ("regtest", "regtest"), - ), + (("main", "mainnet"), ("test", "testnet3"), ("testnet4", "testnet4"), ("signet", "signet")), ) -def test_preflight_discovers_each_supported_chain( +def test_preflight_uses_and_names_the_configured_chain( monkeypatch: pytest.MonkeyPatch, selected: str, label: str ) -> None: commands: list[list[str]] = [] @@ -113,48 +102,18 @@ def test_preflight_discovers_each_supported_chain( def run(command: list[str], **_options: object) -> subprocess.CompletedProcess[str]: commands.append(command) - if f"-chain={selected}" not in command: - return subprocess.CompletedProcess(command, 1, "ignored", "ignored") response = {"version": 320100} if command[-1] == "getnetworkinfo" else {"chain": selected} return subprocess.CompletedProcess(command, 0, json.dumps(response) + "\n", "") monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: "/reviewed/bitcoin-cli") monkeypatch.setattr(subprocess, "run", run) - client = BitcoinCore.connect(tell=messages.append) + client = BitcoinCore.connect(messages.append) assert (client.executable, client.chain, client.version) == ("/reviewed/bitcoin-cli", selected, 320100) assert messages == [f"Using Bitcoin Core on {label}."] - assert all(command[1].startswith("-chain=") for command in commands) - assert all(command[2] == "-rpcconnect=127.0.0.1" for command in commands) - - -def test_preflight_requires_selection_when_multiple_chains_run( - monkeypatch: pytest.MonkeyPatch, -) -> None: - messages: list[str] = [] - answers = iter(("0", "2")) - - def run(command: list[str], **_options: object) -> subprocess.CompletedProcess[str]: - chain = command[1].removeprefix("-chain=") - if chain not in ("main", "signet"): - return subprocess.CompletedProcess(command, 1, "", "") - response = {"version": 320000} if command[-1] == "getnetworkinfo" else {"chain": chain} - return subprocess.CompletedProcess(command, 0, json.dumps(response), "") - - monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: "/reviewed/bitcoin-cli") - monkeypatch.setattr(subprocess, "run", run) - - client = BitcoinCore.connect(lambda _prompt: next(answers), messages.append) - - assert client.chain == "signet" - assert messages == [ - "Local Bitcoin Core networks:", - " 1. mainnet", - " 2. signet", - "Enter one of the displayed numbers.", - "Using Bitcoin Core on signet.", - ] + assert [command[:2] for command in commands] == [["/reviewed/bitcoin-cli", "-rpcconnect=127.0.0.1"]] * 2 + assert not any(argument.startswith("-chain") for command in commands for argument in command) def test_preflight_rejection_is_helpful_without_echoing_core_output( @@ -172,31 +131,30 @@ def test_preflight_rejection_is_helpful_without_echoing_core_output( BitcoinCore.connect() message = str(failure.value) - assert message == ( - "No local Bitcoin Core RPC server found.\n" - "Start Bitcoin Core with local RPC enabled.\n" - "For signet practice: bitcoin-qt -signet -server" - ) + assert message.startswith("No local Bitcoin Core RPC server found.") + assert "signet=1" in message assert marker not in message -def test_preflight_rejects_old_and_mismatched_core_responses( - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize( + ("network", "blockchain", "message"), + ( + ({"version": 319999}, {"chain": "main"}, "32.0 or newer"), + ({"version": True}, {"chain": "main"}, "unexpected"), + ({"version": 320000}, {}, "unexpected"), + ), +) +def test_preflight_rejects_old_and_malformed_core_responses( + monkeypatch: pytest.MonkeyPatch, network: object, blockchain: object, message: str ) -> None: def run(command: list[str], **_options: object) -> subprocess.CompletedProcess[str]: - chain = command[1].removeprefix("-chain=") - if chain not in ("main", "signet"): - return subprocess.CompletedProcess(command, 1, "", "") - if command[-1] == "getnetworkinfo": - response: dict[str, object] = {"version": 319999 if chain == "main" else 320000} - else: - response = {"chain": chain if chain == "main" else "main"} + response = network if command[-1] == "getnetworkinfo" else blockchain return subprocess.CompletedProcess(command, 0, json.dumps(response), "") monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: "/reviewed/bitcoin-cli") monkeypatch.setattr(subprocess, "run", run) - with pytest.raises(BitcoinCoreError, match="No local Bitcoin Core RPC server"): + with pytest.raises(BitcoinCoreError, match=message): BitcoinCore.connect() @@ -769,7 +727,7 @@ def test_subprocess_adapter_uses_loopback_and_never_repeats_raw_core_errors( def run(command: list[str], **options: object) -> subprocess.CompletedProcess[str]: assert marker not in command - assert command[1:3] == ["-chain=main", "-rpcconnect=127.0.0.1"] + assert command[1] == "-rpcconnect=127.0.0.1" assert command[-2:] == ["-stdin", "addhdkey"] assert options["input"] == marker + "\n" return subprocess.CompletedProcess(command, 1, marker, marker) @@ -817,44 +775,3 @@ def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes: monkeypatch.setattr(BitcoinCore, "fingerprint", unused) BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None) - - -# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the -# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing. -_BAILS_SEED = bytes(range(16)) - - -@pytest.mark.parametrize( - ("identifier", "origin"), - ( - (_fingerprint_identifier(_FINGERPRINT), "codex32"), - ("d9k8", "Bails"), - ("d9kq", "Bails"), - ("hezu", "Bails alpha"), - ("test", None), - ), -) -def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None: - secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) - assert identifier_origin(secret, _FINGERPRINT) == origin - assert ("matches this seed" in identifier_note(origin)) is (origin is not None) - - -def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: - original_new = hashlib.new - - def without_ripemd160(name: str, data: bytes = b"") -> object: - if name == "ripemd160": - raise ValueError("unsupported hash type ripemd160") - return original_new(name, data) - - monkeypatch.setattr(hashlib, "new", without_ripemd160) - secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") - assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" - - -def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: - note = identifier_note(None) - assert "split shares" in note - assert "supplied seed bytes" in note - assert "explicit identifier" in note diff --git a/tests/test_cli.py b/tests/test_cli.py index 07124c18..b60ed25e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,14 +17,12 @@ import pytest from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS -from test_bip39 import BIP39_12W_ZERO from codex32 import ( ConfirmationResult, CorrectionCandidate, CorrectionContext, MasterSeed, - Profile, Secret, Share, derive_share, @@ -33,10 +31,10 @@ ) from codex32._bitcoin_core import BitcoinCore from codex32.bech32 import _chars_to_u5, bech32_encode +from codex32.bip93 import SEED_BYTE_LENGTHS from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.cli import main, ms_main -from codex32.generation import _fingerprint_identifier -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS +from codex32.generation import _confirmation, _fingerprint_identifier from tools._wallet_test_vectors import stub_fingerprint @@ -231,13 +229,13 @@ def _output_artifacts(result: _Result, profile: str = "ms") -> list[Share | Secr return [parse_codex32("".join(line.split())) for line in lines if line.lower().startswith(profile + "1")] -def test_check_supports_every_registered_application() -> None: +def test_check_describes_ms_and_generic_hrps() -> None: strings = ( VECTOR_1["secret_s"], VECTOR_3["secret_s"], VECTOR_2["share_A"], VECTOR_6["codex32_cln2"], - BIP39_12W_ZERO, + SHARING_VECTORS["bip39_12w"]["S"], SHARING_VECTORS["bip39_24w"]["S"], ) result = _invoke(["check"], *strings) @@ -252,11 +250,15 @@ def test_check_supports_every_registered_application() -> None: "Valid Bitcoin master-seed share A.\n" "Backup identifier: NAME\n" "Shares needed for recovery: 2\n\n" - "Valid unshared Core Lightning HSM secret.\n" + "Valid unshared codex32 secret.\n" + "HRP: CL\n" "Backup identifier: CLN2\n\n" - "Valid unshared 12-word BIP39 worksheet.\n" - "Backup identifier: TEST\n\n" - "Valid shared 24-word BIP39 worksheet.\n" + "Valid shared codex32 secret.\n" + "HRP: BIP39_12W\n" + "Backup identifier: TEST\n" + "Shares needed for recovery: 2\n\n" + "Valid shared codex32 secret.\n" + "HRP: BIP39_24W\n" "Backup identifier: TEST\n" "Shares needed for recovery: 2\n" ) @@ -274,14 +276,6 @@ def test_check_supports_every_registered_application() -> None: assert forbidden not in result.stdout -@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 == 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: cli_module = importlib.import_module("codex32.cli") @@ -313,24 +307,9 @@ def forbidden(_seed: bytes) -> bytes: 27, ("A Bitcoin master-seed backup must have exactly 48, 54, 61, 67, 74, or 127 characters."), ), - ( - "cl", - 51, - ("This input has 73 characters. A Core Lightning HSM secret backup must have exactly 74."), - ), - ( - "bip39_12w", - 26, - ("This input has 55 characters. A 12-word BIP39 worksheet backup must have exactly 56."), - ), - ( - "bip39_24w", - 52, - ("This input has 81 characters. A 24-word BIP39 worksheet backup must have exactly 82."), - ), ), ) -def test_check_reports_profile_lengths_for_people(hrp: str, payload_length: int, message: str) -> None: +def test_check_reports_ms_lengths_for_people(hrp: str, payload_length: int, message: str) -> None: body = _chars_to_u5("0tests" + "q" * payload_length) expanded_body_length = 2 * len(hrp) + 1 + len(body) checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG @@ -560,7 +539,7 @@ def test_embedded_correction_schedules_both_mixed_case_interpretations_before_al source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" - candidates = input_module._suggestions(damaged, "", (Profile.MS,), []) + candidates = input_module._suggestions(damaged, "", "ms", []) assert len(candidates) == 1 assert candidates[0].artifact.text == source @@ -600,9 +579,9 @@ def test_xprv_explains_prefilled_threshold_and_uncorrectable_length(monkeypatch, assert ms_main(["xprv"]) == 0 rejected = [line for line in capsys.readouterr().err.splitlines() if line.startswith("Rejected:")] assert rejected == [ - "Rejected: After the prefilled MS1, enter a threshold of 0 or 2 through 9; found 'n'.", + "Rejected: The threshold must be 0 or a number from 2 through 9; found 'n'.", ( - "Rejected: The string has 39 characters; valid Bitcoin master-seed codex32 lengths are " + "Rejected: This input has 39 characters. A Bitcoin master-seed backup must have exactly " "48, 54, 61, 67, 74, or 127 characters." ), ] @@ -621,7 +600,7 @@ def test_xprv_reports_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err for length in lengths: - assert f"The string has {length} characters; valid Bitcoin master-seed codex32 lengths are" in output + assert f"This input has {length} characters. A Bitcoin master-seed backup must have" in output def test_prefixed_ms_lengths_at_correction_boundary_explain_length_and_still_search( @@ -666,10 +645,8 @@ def test_complete_paste_keeps_ordinary_threshold_error(monkeypatch, capsys) -> N assert "After the prefilled" not in output -@pytest.mark.parametrize(("difference", "word"), ((-9, "short of"), (9, "too long for"))) -def test_subsequent_prefixed_ms_input_reports_required_set_length( - monkeypatch, capsys, difference, word -) -> None: +@pytest.mark.parametrize("difference", (-9, 9)) +def test_subsequent_prefixed_ms_input_reports_required_set_length(monkeypatch, capsys, difference) -> None: input_module = importlib.import_module("codex32._cli_input") prefix = "MS12NAME" suffix = VECTOR_2["share_C"][len(prefix) :] @@ -683,10 +660,8 @@ def test_subsequent_prefixed_ms_input_reports_required_set_length( assert main(["secret", "--plain"]) == 0 output = capsys.readouterr() assert output.out.strip() == VECTOR_2["secret_S"] - assert ( - f"Rejected: The string is 9 characters {word} the required {len(VECTOR_2['share_C'])}-character " - "length." - ) in output.err + length = len(VECTOR_2["share_C"]) + difference + assert f"Rejected: This input has {length} characters." in output.err @pytest.mark.parametrize( @@ -949,7 +924,7 @@ def search(active: tuple[CorrectionContext, ...], value: str, **kwargs: object): assert captured.out.strip() == VECTOR_2["secret_S"].lower() assert "Possible correction:\n\nMaster fingerprint: FAB6868A\n\n" in captured.err assert input_module._card_text(expected, False) in captured.err - assert contexts == [(CorrectionContext(Profile.MS, len(VECTOR_2["share_A"]), prefix, ("a",)),)] + assert contexts == [(CorrectionContext("ms", len(VECTOR_2["share_A"]), prefix, ("a",)),)] def test_tty_recovery_accepts_complete_uppercase_and_retries( @@ -1478,7 +1453,7 @@ def test_confirmation_alignment_shows_only_entered_text( expected = "abcdefghWXYZijklmnop" if observed == "abcdefghijklmnop" else "abcdefghijklmnop" if observed == "aaaaaaa": expected = "aaaaaaaa" - groups, changed = input_module._entered_groups(observed, expected) + groups, changed = importlib.import_module("codex32.generation")._entered_groups(observed, expected) actual = input_module._render_groups(groups, changed, len(expected)) plain = re.sub(r"\x1b\[[0-9;]*m", "", actual) @@ -1793,7 +1768,7 @@ def test_correction_bytes_rejects_an_unsupported_ms_size() -> None: def test_cli_never_accepts_an_incomplete_structural_search() -> None: original = VECTOR_1["secret_s"] damaged = original[:19] + original[20:] - with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0)): + with patch("codex32._cli_input._correct_complete", return_value=((), False)): result = _invoke(["correct"], damaged) assert result.exit_code == 3 @@ -1804,10 +1779,10 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None: @pytest.mark.parametrize( ("options", "lengths", "bounded"), ( - ([], (48, 54, 61, 67, 74, 127), True), + ([], (48,), True), (["--bytes", "20"], (54,), True), (["--bytes", "64"], (127,), True), - (["--bytes", "?"], (48, 54, 61, 67, 74, 127), True), + (["--bytes", "?"], (48,), True), ), ) def test_correction_options_control_lengths_deadline_and_search_envelope( @@ -1824,18 +1799,6 @@ def test_correction_options_control_lengths_deadline_and_search_envelope( assert observed == damaged assert tuple(context.expected_length for context in contexts) == lengths assert (search.call_args.kwargs["deadline"] is not None) is bounded - assert search.call_args.kwargs["reduced"] == frozenset() - - -def test_automatic_target_selection_covers_midpoints_and_supported_lengths() -> None: - from codex32._cli_input import _correction_plan - - for observed in range(40, 136): - targets = _correction_plan(Profile.MS, None, observed, None)[0] - expected = 48 if observed <= 61 else 74 if observed <= 100 else 127 - - assert targets[0] == expected - assert sorted(targets) == [48, 54, 61, 67, 74, 127] def test_fixed_correction_repairs_legacy_cl_header_and_residue_reverse_positions() -> None: @@ -1867,7 +1830,7 @@ def test_correction_infers_prefix_and_marks_invalid_data_as_erasures() -> None: removed = _invoke(["correct", "--prefix", "ms"], original) damaged_prefix = _invoke(["correct"], "?" + original[1:]) - bip39 = _invoke(["correct"], BIP39_12W_ZERO) + bip39 = _invoke(["correct"], SHARING_VECTORS["bip39_12w"]["S"]) assert removed.exit_code == 2 assert "Remove or correct these arguments: --prefix" in removed.stderr assert damaged_prefix.exit_code == 3 @@ -1887,8 +1850,7 @@ def test_correct_suggests_the_majority_case_for_mixed_case_damage() -> None: assert result.exit_code == 1 assert source in result.stderr assert "No valid correction found" not in result.stderr - assert wrong_length.exit_code == 2 - assert "--bytes does not match" in wrong_length.stderr + assert wrong_length.exit_code == 3 assert source not in wrong_length.stderr @@ -1908,32 +1870,6 @@ def test_correct_grouped_mixed_case_recognizes_case_only_repair() -> None: assert "interactive confirmation required" not in result.stderr -def test_correct_searches_mixed_case_erasures_before_normalized_alignment(monkeypatch) -> None: - source = VECTOR_1["secret_s"] - letter_positions = [ - index - for index, character in enumerate(source[3:], 3) - if index >= 9 and character.lower() != character.upper() - ] - positions = letter_positions[1:26:6] - damaged = "".join( - ("P" if character.lower() != "p" else "Q") if index in positions else character - for index, character in enumerate(source) - ) - searched: list[str] = [] - - def stop_after_first(value, *_args, **_kwargs): - searched.append(value) - return (), False, None - - monkeypatch.setattr("codex32._cli_input._correction_candidates", stop_after_first) - - result = _invoke(["correct"], damaged) - assert result.exit_code != 0 - assert len(searched) == 1 - assert searched[0].count("?") == len(positions) - - def test_correct_reranks_mixed_case_erasure_and_normalized_interpretations() -> None: source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" start = source.index("x") @@ -1962,45 +1898,6 @@ def test_correct_required_work_is_not_starved_by_erasure_alignment() -> None: assert "interactive confirmation required" not in result.stderr -def test_correct_accounts_retry_frontier_after_incomplete_first_search(monkeypatch) -> None: - source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" - start = source.index("x") - positions = range(start, start + 13) - damaged = "".join( - ("P" if index == start + 6 else character.upper()) if index in positions else character - for index, character in enumerate(source) - ) - candidate = CorrectionCandidate( - parse_codex32(source), - (), - 1, - 0, - 0, - None, - search_complete=False, - ) - full_searches: list[str] = [] - - def incomplete_first(value, *_args, **kwargs): - if kwargs.get("required_only"): - return (), True, 0.0 - full_searches.append(value) - kwargs["capture_layers"].append((1, 5)) - if len(full_searches) == 1: - return (candidate,), False, 0.0 - return (), False, 0.0 - - monkeypatch.setattr("codex32._cli_input._correction_candidates", incomplete_first) - - result = _invoke(["correct"], damaged) - - assert len(full_searches) == 2 - assert full_searches[0].count("?") == len(positions) - assert "?" not in full_searches[1] - assert result.exit_code == 3 - assert "interactive confirmation required" in result.stderr - - def test_correction_hides_internal_candidate_reparse_failures() -> None: result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg") @@ -2197,7 +2094,7 @@ def test_production_size_budgets_are_enforced() -> None: def test_confirmation_alignment_assigns_complete_canonical_groups( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32.generation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2238,7 +2135,7 @@ def answer(prompt: str) -> str: def confirm(text: str) -> ConfirmationResult: confirmed.append(text) - return ConfirmationResult("".join(text.split()).lower() == artifact.text.lower()) + return _confirmation(text, artifact.text) with contextlib.redirect_stderr(output): cli_module._confirm_card(artifact, confirm) @@ -2251,8 +2148,7 @@ def confirm(text: str) -> ConfirmationResult: assert artifact.text[16:20] in shown assert len(re.findall(r"\x1b\[(?:1|22);7;31m", shown)) == 1 assert snapshots[1] == snapshots[2] == snapshots[3] - assert len(confirmed) == 1 - assert "".join(confirmed[0].split()).lower() == artifact.text.lower() + assert "".join(confirmed[-1].split()).lower() == artifact.text.lower() if readline: assert editor.inserted == prefills @@ -2354,7 +2250,7 @@ def test_confirmation_placeholders_only_replace_wholly_omitted_groups( def test_confirmation_grouped_alignment_preserves_token_ownership( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32.generation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2393,7 +2289,7 @@ def answer(prompt: str) -> str: def confirm(value: str) -> ConfirmationResult: confirmed.append(value) - return ConfirmationResult("".join(value.split()).upper() == Card.text) + return _confirmation(value, Card.text) monkeypatch.setattr(cli_module, "_text", read) monkeypatch.setattr(input_module.sys, "stdin", _TTYInput()) @@ -2407,9 +2303,8 @@ def confirm(value: str) -> ConfirmationResult: assert "\x1b[1mEFGH\x1b[0m" in snapshots[4] assert "\x1b[1;7;31mABC\x1b[0m" in snapshots[4] assert "Please re-enter only the highlighted region that remains incorrect." in output.getvalue() - assert len(confirmed) == 1 if full_retry: - assert confirmed == [final] + assert confirmed[-1] == final if readline: assert editor.inserted == prefills @@ -2463,7 +2358,7 @@ def answer(prompt: str, **options: object) -> str: with ( patch.object(sys, "stdin", _TTYInput()), patch("codex32.cli._creation_source", return_value=secret), - patch("codex32.cli._generated_secret") as generate, + patch("codex32.cli.generate_master_seed") as generate, patch("codex32.cli.CreationCeremony.from_secret") as split, patch("codex32.cli.BitcoinCore.connect", return_value=core), contextlib.redirect_stdout(output), @@ -2738,10 +2633,10 @@ def answer(prompt, prefill=""): monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", answer) - result = cli._creation_source(Profile.MS, _FakeBitcoinCore().fingerprint) + result = cli._creation_source() assert result == (parse_codex32(expected) if response == "yes" else bytes(16)) output = capsys.readouterr().err - assert "Master fingerprint: 3F3521A6\n\n" in output + assert "Master fingerprint:" not in output assert module._card_text(expected, False) in output assert "31m" not in output assert "Does this entire string exactly match your recovery card? [y/N]: " in prompts @@ -2750,12 +2645,10 @@ def answer(prompt, prefill=""): assert "Rejected:" not in output -@pytest.mark.parametrize("kind", ["none", "ambiguous", "share", "wrong_profile", "fingerprint"]) +@pytest.mark.parametrize("kind", ["none", "ambiguous", "share", "wrong_profile"]) def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind): from types import SimpleNamespace - from codex32.errors import CodexError - cli = importlib.import_module("codex32.cli") module = importlib.import_module("codex32._cli_input") artifact = parse_codex32(VECTOR_1["secret_s"]) @@ -2769,12 +2662,7 @@ def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind): answers = iter(("ms1invalid", "00" * 16)) monkeypatch.setattr(sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers)) - - def fail(seed): - raise CodexError("invalid seed") - - fingerprint = fail if kind == "fingerprint" else _FakeBitcoinCore().fingerprint - assert cli._creation_source(Profile.MS, fingerprint) == bytes(16) + assert cli._creation_source() == bytes(16) output = capsys.readouterr().err assert "Possible correction:" not in output assert "Rejected:" in output @@ -2798,7 +2686,7 @@ def suggestions(*args, **kwargs): monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers)) monkeypatch.setattr(cli, "_suggestions", suggestions) - assert cli._creation_source(Profile.MS) == bytes(16) + assert cli._creation_source() == bytes(16) output = capsys.readouterr().err assert "Search incomplete:" not in output assert "Possible correction:" not in output @@ -2821,17 +2709,14 @@ def answer(*args): monkeypatch.setattr(module, "_editable_input", answer) monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ()) with pytest.raises(exception): - cli._creation_source(Profile.MS) + cli._creation_source() -def test_creation_source_hex_and_noninteractive_never_search(monkeypatch): +def test_creation_source_hex_never_searches(monkeypatch): cli = importlib.import_module("codex32.cli") monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: pytest.fail("unexpected search")) monkeypatch.setattr(sys, "stdin", io.StringIO("00" * 16)) - assert cli._creation_source(Profile.MS) == bytes(16) - monkeypatch.setattr(sys, "stdin", io.StringIO("ms1invalid")) - with pytest.raises(cli._UsageError): - cli._creation_source(Profile.MS) + assert cli._creation_source() == bytes(16) def test_creation_source_is_neutral_and_does_not_infer_ms1(monkeypatch, capsys): @@ -2848,7 +2733,7 @@ def answer(prompt: str, _prefill: str = "") -> str: monkeypatch.setattr(module, "_editable_input", answer) monkeypatch.setattr("codex32.indel._search_many", lambda *args, **kwargs: pytest.fail("searched")) - assert cli._creation_source(Profile.MS) == bytes(16) + assert cli._creation_source() == bytes(16) assert prompts == ["Enter an existing Bitcoin codex32 secret or hexadecimal seed:\n> "] * 2 output = capsys.readouterr().err assert "Rejected:" in output @@ -2897,10 +2782,10 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa from codex32.correction import _correct_fixed source = VECTOR_1["secret_s"] - candidate = _correct_fixed(source, suspected_profile=Profile.MS) + candidate = _correct_fixed(source, hrp="ms") assert candidate is not None candidate = replace(candidate, search_complete=False) - with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0)): + with patch("codex32._cli_input._correct_complete", return_value=((candidate,), False)): result = _invoke(["correct"], source[:-1] + "?") assert result.exit_code == 1 and result.stdout == "" assert "Search incomplete" not in result.stderr @@ -2950,17 +2835,12 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( assert prompts[1] == "Restore without a wallet record? [y/N]" shown = capsys.readouterr().err assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 1 - assert "was not made from this seed" in shown and "nothing can prove" in shown + assert "nothing can prove" in shown prompts = _record_answers(monkeypatch, "", "y") assert _RECORDED_FINGERPRINT(core, secret) is None assert prompts[1] == "Restore without a wallet record? [y/N]" - derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) - _record_answers(monkeypatch, "", "yes") - assert _RECORDED_FINGERPRINT(core, derived) is None - assert "matches this seed (codex32 rule)" in capsys.readouterr().err - def test_wallet_restore_hides_fingerprint_until_the_record_gate(monkeypatch: pytest.MonkeyPatch) -> None: cli = importlib.import_module("codex32.cli") @@ -3013,11 +2893,9 @@ def test_create_existing_checks_the_record_before_import(monkeypatch: pytest.Mon secret = parse_codex32(VECTOR_1["secret_s"]) core = _FakeBitcoinCore() checked = [] - source_fingerprints = [] emitted_fingerprints = [] - def source(_profile, fingerprint=None): - source_fingerprints.append(fingerprint) + def source(): return secret def record(_core, recovered): @@ -3040,7 +2918,6 @@ def emit(_artifact, _plain, **kwargs): monkeypatch.setattr(cli, "_recorded_fingerprint", record) monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) assert ms_main(["create", "--existing", *([header] if header else [])]) == 0 - assert source_fingerprints == [None] assert emitted_fingerprints and all(fingerprint is None for fingerprint in emitted_fingerprints) assert checked == [secret.seed_bytes] assert core.expected == core.fingerprint(secret) diff --git a/tests/test_cli_lengths.py b/tests/test_cli_lengths.py index 6fa10899..af47ef6a 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -2,8 +2,7 @@ import pytest -from codex32 import Profile -from codex32._cli_input import InputError, _parse, _prefixed_input_error +from codex32._cli_input import InputError, _parse from codex32.errors import InvalidChecksum, InvalidLength @@ -28,20 +27,17 @@ def test_generic_length_profile_length_and_checksum_diagnostics(length, message, cause): value = "ms10fauxs" + "x" * (length - 9) with pytest.raises(InputError) as failure: - _parse(value, (Profile.MS,)) + _parse(value, "ms") assert str(failure.value) == message assert isinstance(failure.value.__cause__, cause) - if cause is InvalidLength: - assert _prefixed_input_error(failure.value, value, "MS1", []) == message def test_reported_invalid_length_vector_in_neutral_entry(): value = "ms10fauxs" + "x" * 73 + "r335l5tv88js3" - with pytest.raises(InputError, match="This input has 95 characters") as failure: - _parse(value, (Profile.MS,)) - assert _prefixed_input_error(failure.value, value, "", []) == str(failure.value) + with pytest.raises(InputError, match="This input has 95 characters"): + _parse(value, "ms") def test_unknown_profile_does_not_replace_checksum_failure_with_a_length_guess(): with pytest.raises(InputError, match="The checksum does not match"): - _parse("zz10fauxs" + "x" * 87, (Profile.MS,)) + _parse("zz10fauxs" + "x" * 87, "ms") diff --git a/tests/test_competitors.py b/tests/test_competitors.py index 033cce0e..303fa2e2 100644 --- a/tests/test_competitors.py +++ b/tests/test_competitors.py @@ -6,11 +6,11 @@ import pytest -from codex32 import CorrectionContext, MasterSeed, Profile, Share, indel +from codex32 import CorrectionContext, MasterSeed, Share, indel from codex32 import _competitors as search from codex32.bech32 import CHARSET +from codex32.bip93 import TEXT_LENGTHS from codex32.correction import _correct_fixed -from codex32.profiles.ms32 import TEXT_LENGTHS SOURCE = MasterSeed.from_seed(bytes(range(16)), identifier="test").text REPORTED = "ms12namedll4f8jkh4e5vdvuldlfxu2jhdnlsm97xcenrxeg" @@ -18,7 +18,7 @@ def _state(text, target=48): - result = indel._prepare(CorrectionContext(Profile.MS, target, text[:3]), text, indel._CLASSES) + result = indel._prepare(CorrectionContext("ms", target, text[:3]), text, indel._CLASSES) assert result is not None return result @@ -32,7 +32,7 @@ def _substitute(text, count): def _run(text, *, target=None, **kwargs): lengths = TEXT_LENGTHS if target is None else (target,) - contexts = tuple(CorrectionContext(Profile.MS, length, text[:3]) for length in lengths) + contexts = tuple(CorrectionContext("ms", length, text[:3]) for length in lengths) return indel._search_many(contexts, text, primary=frozenset(lengths), competitors=True, **kwargs) @@ -90,7 +90,7 @@ def test_directed_swap_views_match_exhaustive_alignment_scoring(substitutions, s damaged[position] = CHARSET[CHARSET.index(damaged[position]) ^ 1] text = "".join(damaged) state = replace(_state(text), immutable=len(text) - 6) - candidate = _correct_fixed(artifact.text, suspected_profile=Profile.MS) + candidate = _correct_fixed(artifact.text, hrp="ms") assert candidate is not None target = tuple(CHARSET.index(c) for c in artifact.text[3:]) matches = 0 @@ -115,7 +115,7 @@ def test_minimum_distance_pruning_stops_at_the_proven_boundary(): shape = indel._StructuralClass(0, 0, adjacent=3) for substitutions, covered in ((2, True), (3, False)): text = _substitute(SOURCE, substitutions) - fixed = _correct_fixed(text, suspected_profile=Profile.MS) + fixed = _correct_fixed(text, hrp="ms") assert fixed is not None result = search._covered_candidates(_state(text), shape, 0, 0, fixed, (fixed,)) assert (result == (fixed,)) is covered @@ -134,7 +134,7 @@ def test_fixed_coverage_accounts_for_explicit_erasures_and_residual_substitution def test_cancelled_indel_layer_is_distinct_from_retained_erasure_and_relocation(): state = _state(REPORTED) - fixed = _correct_fixed(REPORTED, suspected_profile=Profile.MS) + fixed = _correct_fixed(REPORTED, hrp="ms") assert fixed is not None adjacent = indel._StructuralClass(1, 1, adjacent=1) distant = indel._StructuralClass(1, 1, distant=1) @@ -223,7 +223,7 @@ def test_ineligible_fixed_candidate_cannot_prune_other_layers(): def layer(state, frontier, results, deadline, *, allowed, views): calls.append(next(iter(frontier.values()))) assert not results - assert not allowed(_correct_fixed(REPORTED, suspected_profile=Profile.MS)) + assert not allowed(_correct_fixed(REPORTED, hrp="ms")) return False with patch.object(search, "_search_target", side_effect=layer): @@ -235,8 +235,8 @@ def test_structural_candidate_filter_is_applied_before_collection(): state = _state(REPORTED) shape = indel._StructuralClass(0, 0, unit=4, adjacent=1) frontier = indel._frontier((state,), frozenset((48,))) - share = _correct_fixed(REPORTED, suspected_profile=Profile.MS) - secret = _correct_fixed(SOURCE, suspected_profile=Profile.MS) + share = _correct_fixed(REPORTED, hrp="ms") + secret = _correct_fixed(SOURCE, hrp="ms") assert share is not None and secret is not None outputs = iter((replace(share, edits=(), capture_volume=1), secret)) views = iter(tuple(indel._views(REPORTED, 48, shape, 3, 3))[:2]) @@ -263,7 +263,7 @@ def test_alignment_discovered_candidate_matches_unpruned_search(target, change): "insert_group": SOURCE[:20] + "qpzr" + SOURCE[20:], }[change] lengths = TEXT_LENGTHS if target is None else (target,) - contexts = tuple(CorrectionContext(Profile.MS, length, damaged[:3]) for length in lengths) + contexts = tuple(CorrectionContext("ms", length, damaged[:3]) for length in lengths) reference, reference_complete = indel._search_many(contexts, damaged, primary=frozenset(lengths)) candidates, complete = _run(damaged, target=target) assert complete and reference_complete @@ -294,10 +294,8 @@ def layer(state, frontier, results, deadline, **kwargs): def test_incomplete_primary_ties_are_suppressed(): - first = _correct_fixed(SOURCE, suspected_profile=Profile.MS) - second = _correct_fixed( - MasterSeed.from_seed(bytes(16), identifier="test").text, suspected_profile=Profile.MS - ) + first = _correct_fixed(SOURCE, hrp="ms") + second = _correct_fixed(MasterSeed.from_seed(bytes(16), identifier="test").text, hrp="ms") assert first is not None and second is not None calls = [] @@ -324,7 +322,7 @@ def test_equal_or_better_competitor_at_another_length_is_not_pruned(later_volume def layer(state, frontier, results, deadline, **kwargs): calls.append(state.target) artifact = MasterSeed.from_seed(bytes(16 if state.target == 48 else 20), identifier="test") - candidate = _correct_fixed(artifact.text, suspected_profile=Profile.MS) + candidate = _correct_fixed(artifact.text, hrp="ms") results[artifact.text] = replace(candidate, capture_volume=next(iter(frontier.values()))) return True diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index 4f7fa740..d0cc2235 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -9,10 +9,9 @@ from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings from hypothesis import strategies as st -from test_profiles import _oracle_encode import codex32 -from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct, indel +from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, correct, indel from codex32.bech32 import CHARSET from codex32.checksums import _CODEX32, _CODEX32_LONG from codex32.correction import ( @@ -44,9 +43,9 @@ def _change( def _success( damaged: str, - profile: Profile = Profile.MS, + hrp: str = "ms", ) -> CorrectionCandidate: - result = _correct_fixed(damaged, suspected_profile=profile) + result = _correct_fixed(damaged, hrp=hrp) assert isinstance(result, CorrectionCandidate) return result @@ -74,7 +73,7 @@ def test_frozen_p70_differential_corpus() -> None: for case in document["cases"]: result = _correct_fixed( case["damaged"], - suspected_profile=Profile.MS, + hrp="ms", ) if case["expected"] is None: assert result is None @@ -146,22 +145,22 @@ def test_every_consecutive_erasure_burst( @pytest.mark.parametrize( - ("profile", "source"), + ("hrp", "source"), ( - (Profile.MS, VECTOR_1["secret_s"]), - (Profile.MS, VECTOR_5["secret_s"]), - (Profile.CL, VECTOR_6["codex32_peev"]), - (Profile.BIP39_12W, SHARING_VECTORS["bip39_12w"]["S"]), - (Profile.BIP39_24W, SHARING_VECTORS["bip39_24w"]["S"]), + ("ms", VECTOR_1["secret_s"]), + ("ms", VECTOR_5["secret_s"]), + ("cl", VECTOR_6["codex32_peev"]), + ("bip39_12w", SHARING_VECTORS["bip39_12w"]["S"]), + ("bip39_24w", SHARING_VECTORS["bip39_24w"]["S"]), ), ) -def test_all_registered_profiles_use_public_correction_api( - profile: Profile, +def test_ms_and_generic_hrps_use_public_correction_api( + hrp: str, source: str, ) -> None: - prefix_length = len(profile.value) + 1 + prefix_length = len(hrp) + 1 positions = [prefix_length + offset for offset in (2, 7, 12, 17)] - result = correct(CorrectionContext(profile), _change(source, positions)) + result = correct(CorrectionContext(hrp, len(source)), _change(source, positions)) assert len(result) == 1 and result[0].artifact.text == source @@ -169,7 +168,7 @@ def test_public_candidate_reports_fixed_edits_and_ranking_inputs() -> None: source = VECTOR_1["secret_s"] positions = [7, 11] damaged = _change(source, positions, erasures=1) - result = correct(CorrectionContext(Profile.MS), damaged) + result = correct(CorrectionContext("ms"), damaged) assert len(result) == 1 candidate = result[0] @@ -195,28 +194,28 @@ def test_public_candidate_reports_fixed_edits_and_ranking_inputs() -> None: def test_public_context_constrains_length_prefix_and_used_indices() -> None: source = VECTOR_2["share_A"] valid = CorrectionContext( - Profile.MS, + "ms", expected_length=len(source), immutable_prefix=source[:8], ) candidate = correct(valid, source) assert len(candidate) == 1 and candidate[0].artifact.text == source - assert correct(CorrectionContext(Profile.MS, expected_length=74), source) == () - assert correct(CorrectionContext(Profile.MS, immutable_prefix="ms12cash"), source) == () - assert correct(CorrectionContext(Profile.MS, excluded_indices=("A",)), source) == () + assert correct(CorrectionContext("ms", expected_length=74), source) == () + assert correct(CorrectionContext("ms", immutable_prefix="ms12cash"), source) == () + assert correct(CorrectionContext("ms", excluded_indices=("A",)), source) == () @pytest.mark.parametrize( "context", ( - CorrectionContext(Profile.MS, expected_length=True), # type: ignore[arg-type] - CorrectionContext(Profile.MS, expected_length=49), - CorrectionContext(Profile.MS, immutable_prefix="ms11test"), - CorrectionContext(Profile.MS, immutable_prefix="ms10tes!"), - CorrectionContext(Profile.MS, excluded_indices=["a"]), # type: ignore[arg-type] - CorrectionContext(Profile.MS, excluded_indices=("s",)), - CorrectionContext(Profile.MS, excluded_indices=("a", "A")), + CorrectionContext("ms", expected_length=True), # type: ignore[arg-type] + CorrectionContext("ms", expected_length=49), + CorrectionContext("ms", immutable_prefix="ms11test"), + CorrectionContext("ms", immutable_prefix="ms10tes!"), + CorrectionContext("ms", excluded_indices=["a"]), # type: ignore[arg-type] + CorrectionContext("ms", excluded_indices=("s",)), + CorrectionContext("ms", excluded_indices=("a", "A")), ), ) def test_malformed_public_context_is_rejected(context: CorrectionContext) -> None: @@ -225,7 +224,7 @@ def test_malformed_public_context_is_rejected(context: CorrectionContext) -> Non def test_public_records_are_frozen_slotted_and_unchanged_input_is_a_candidate() -> None: - context = CorrectionContext(Profile.MS) + context = CorrectionContext("ms") candidate = correct(context, VECTOR_1["secret_s"])[0] assert not hasattr(context, "__dict__") @@ -238,12 +237,12 @@ def test_public_records_are_frozen_slotted_and_unchanged_input_is_a_candidate() def test_public_correction_keeps_prefix_immutable_and_types_strict() -> None: source = VECTOR_1["secret_s"] - assert correct(CorrectionContext(Profile.MS), "cl1" + source[3:]) == () - assert correct(CorrectionContext(Profile.MS), source[:2] + "x" + source[3:]) == () + assert correct(CorrectionContext("ms"), "cl1" + source[3:]) == () + assert correct(CorrectionContext("ms"), source[:2] + "x" + source[3:]) == () with pytest.raises(TypeError): - correct(Profile.MS, source) # type: ignore[arg-type] + correct("ms", source) # type: ignore[arg-type] with pytest.raises(TypeError): - correct(CorrectionContext(Profile.MS), b"backup") # type: ignore[arg-type] + correct(CorrectionContext("ms"), b"backup") # type: ignore[arg-type] def test_uppercase_input_preserves_case_and_reverse_addends() -> None: @@ -272,7 +271,7 @@ def test_public_correction_interprets_mixed_case_by_majority(uppercase: bool) -> ) mixed = source[:position] + source[position].swapcase() + source[position + 1 :] - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), mixed) + result = correct(CorrectionContext("ms", expected_length=len(source)), mixed) assert len(result) == 1 assert result[0].artifact.text == source @@ -286,7 +285,7 @@ def test_grouped_mixed_case_keeps_the_recorded_header_immutable() -> None: for index, character in enumerate(source) ) grouped = " ".join(damaged[index : index + 4] for index in range(0, len(damaged), 4)) - context = CorrectionContext(Profile.MS, expected_length=len(source), immutable_prefix=source[:8].lower()) + context = CorrectionContext("ms", expected_length=len(source), immutable_prefix=source[:8].lower()) result = correct(context, grouped) @@ -299,7 +298,7 @@ def test_public_correction_does_not_casefold_non_ascii() -> None: source = VECTOR_1["secret_s"].upper() damaged = source[:10] + "ß" + source[11:] - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert result == () @@ -308,7 +307,7 @@ def test_public_correction_searches_identical_case_interpretation_once() -> None source = VECTOR_1["secret_s"] damaged = source[0].upper() + source[1:] - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 assert result[0].artifact.text == source @@ -335,7 +334,7 @@ def stop_after_first(_contexts, value, **_kwargs): monkeypatch.setattr("codex32.indel._search_many", stop_after_first) - assert correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) == () + assert correct(CorrectionContext("ms", expected_length=len(source)), damaged) == () assert len(searched) == 1 assert searched[0].count("?") == len(positions) @@ -358,7 +357,7 @@ def search(_contexts, value, **kwargs): # type: ignore[no-untyped-def] monkeypatch.setattr("codex32.indel._search_many", search) - assert correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) == (candidate,) + assert correct(CorrectionContext("ms", expected_length=len(source)), damaged) == (candidate,) assert len(searched) == 4 assert ["?" in value for value, _seeded, _required, _optional in searched] == [True, False, True, False] assert [required for _value, _seeded, required, _optional in searched] == [True, True, False, False] @@ -370,7 +369,7 @@ def test_mixed_case_required_work_is_not_starved_by_erasure_alignment() -> None: source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" damaged = "ms10TpstsxXxxxxxXxxxxxXxxxxXxxxxxXx4nzvcA9cmczlW" - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 assert result[0].artifact.text == source @@ -416,7 +415,7 @@ def test_mixed_case_correction_edits_preserve_the_entered_character( observed = entered.lower() if uppercase else entered damaged = source[:position] + observed + source[position + 1 :] - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 assert result[0].artifact.text == source @@ -430,8 +429,8 @@ def test_mixed_case_does_not_reclassify_unrelated_structural_erasures() -> None: damaged = source[:8] + "qqqq" + source[12:] mixed = damaged[:12] + damaged[12].upper() + damaged[13:] - plain = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), mixed) + plain = correct(CorrectionContext("ms", expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), mixed) assert len(plain) == len(result) == 1 assert result[0].artifact.text == source @@ -446,7 +445,7 @@ def test_mixed_case_structural_edits_preserve_the_entered_character() -> None: source = VECTOR_1["secret_s"] damaged = source[:3] + "P" + source[4:20] + source[21:] - result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) + result = correct(CorrectionContext("ms", expected_length=len(source)), damaged) assert len(result) == 1 assert result[0].artifact.text == source @@ -460,55 +459,44 @@ def test_fixed_failures_are_fail_closed() -> None: damaged[8:22] = "?" * 14 body_failure = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx8ueney9awjglu" cases = ( - (mixed, Profile.MS), - ("cl1" + VECTOR_1["secret_s"][3:], Profile.MS), - ("cl1" + "q" * 40, Profile.CL), - ("".join(damaged), Profile.MS), - (body_failure, Profile.MS), + (mixed, "ms"), + ("cl1" + VECTOR_1["secret_s"][3:], "ms"), + ("".join(damaged), "ms"), + (body_failure, "ms"), ) - assert all(_correct_fixed(value, suspected_profile=profile) is None for value, profile in cases) + assert all(_correct_fixed(value, hrp=profile) is None for value, profile in cases) def test_bch_and_linear_failures_return_no_candidate() -> None: source = VECTOR_1["secret_s"] five_errors = _change(source, [5, 12, 19, 26, 33]) mixed = _change(source, [5, 8, 11, 14, 17, 20, 23, 26, 29, 32], erasures=9) - assert _correct_fixed(five_errors, suspected_profile=Profile.MS) is None - assert _correct_fixed(mixed, suspected_profile=Profile.MS) is None - - -def test_bip39_outer_correction_must_reparse_embedded_checksum() -> None: - invalid = _oracle_encode("bip39_12w", "0tests" + "q" * 27) - damaged = _change(invalid, [invalid.rfind("1") + 8]) - result = _correct_fixed( - damaged, - suspected_profile=Profile.BIP39_12W, - ) - assert result is None + assert _correct_fixed(five_errors, hrp="ms") is None + assert _correct_fixed(mixed, hrp="ms") is None @pytest.mark.parametrize( ("value", "profile"), ( - ("ms1" + "q" * 1022, Profile.MS), - ("ms10test\n" + "q" * 40, Profile.MS), + ("ms1" + "q" * 1022, "ms"), + ("ms10test\n" + "q" * 40, "ms"), ), ) def test_fixed_input_is_bounded_before_algebra( value: str, - profile: Profile, + profile: str, ) -> None: - result = _correct_fixed(value, suspected_profile=profile) + result = _correct_fixed(value, hrp=profile) assert result is None -def test_suspected_profile_is_not_inferred() -> None: +def test_hrp_is_not_inferred() -> None: result = _correct_fixed( VECTOR_6["codex32_peev"], - suspected_profile=Profile.MS, + hrp="ms", ) assert result is None - assert _correct_fixed(VECTOR_1["secret_s"], suspected_profile="ms") is not None + assert _correct_fixed(VECTOR_1["secret_s"], hrp="ms") is not None def test_private_book_residue_uses_reverse_index() -> None: @@ -522,7 +510,7 @@ def test_unmapped_short_locator_roots_are_not_silently_dropped() -> None: damaged = ( "ms10testsqqqsyquyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8varg0jzgfzyvjz2f389q5j52ev9cmlrfhvw53es26" ) - result = _correct_fixed(damaged, suspected_profile=Profile.MS) + result = _correct_fixed(damaged, hrp="ms") assert result is None diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index 08592eed..c8cdb9f5 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -8,7 +8,7 @@ import pytest from data.bip93_vectors import VECTOR_1, VECTOR_2 -from data.sharing_vectors import INVALID_BIP39_IMPLIED_SECRET, SHARING_VECTORS +from data.sharing_vectors import SHARING_VECTORS from test_cli import _FakeBitcoinCore, _TTYInput, _TTYOutput from test_generic_hrp import UNKNOWN @@ -102,7 +102,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain): patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), - patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None)), + patch.object(_cli_input, "_correct_complete", return_value=((_candidate(),), True)), ): status = entrypoint(["correct", *(["--plain"] if plain else [])]) prog = "codex32" if entrypoint is cli.main else "ms32" @@ -129,9 +129,7 @@ def respond(prompt, prefill=""): monkeypatch.setattr(_cli_input, "_editable_input", respond) monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,)) monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidate,)) - monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) - ) + monkeypatch.setattr(_cli_input, "_correct_complete", lambda *args, **kwargs: ((candidate,), True)) core = _FakeBitcoinCore() monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core) stdout, stderr = _TTYOutput(), _TTYOutput() @@ -165,9 +163,7 @@ def respond(prompt, prefill=""): return next(responses) monkeypatch.setattr(_cli_input, "_editable_input", respond) - monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) - ) + monkeypatch.setattr(_cli_input, "_correct_complete", lambda *args, **kwargs: ((candidate,), True)) with ( patch.object(sys, "stdin", _TTYInput()), contextlib.redirect_stdout(stdout), @@ -183,9 +179,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch): candidate = _candidate(source) responses = iter((source[:-1] + "?",)) monkeypatch.setattr(_cli_input, "_editable_input", lambda *args, **kwargs: next(responses)) - monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) - ) + monkeypatch.setattr(_cli_input, "_correct_complete", lambda *args, **kwargs: ((candidate,), True)) stdout, stderr = io.StringIO(), io.StringIO() with ( patch.object(sys, "stdin", _TTYInput()), @@ -220,9 +214,7 @@ def confirm(prompt): return "YES" monkeypatch.setattr(_cli_input, "_confirmation_input", confirm) - monkeypatch.setattr( - _cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None) - ) + monkeypatch.setattr(_cli_input, "_correct_complete", lambda *args, **kwargs: ((candidate,), True)) stdout, stderr = io.StringIO(), _TTYOutput() with ( patch.object(sys, "stdin", io.StringIO(source[:-1] + "?")), @@ -342,7 +334,7 @@ def test_seed_candidate_is_reannotated_after_later_search_admission(monkeypatch) @pytest.mark.parametrize("profile", ("bip39_12w", "bip39_24w")) -def test_cli_derives_hand_produced_bip39_set_but_rejects_invalid_implied_secret(profile): +def test_cli_derives_hand_produced_generic_set(profile): from test_generic_hrp import _invoke vector = SHARING_VECTORS[profile] @@ -351,6 +343,9 @@ def test_cli_derives_hand_produced_bip39_set_but_rejects_invalid_implied_secret( vector["D"] + "\n", "", ) - invalid = INVALID_BIP39_IMPLIED_SECRET - status, output, error = _invoke(cli.main, ["share", "d"], invalid["A"] + "\n" + invalid["C"]) - assert status == 1 and output == "" and "embedded BIP39 entropy checksum is invalid" in error + + +def test_confirmation_never_reads_answers_from_redirected_input(monkeypatch): + monkeypatch.setattr(_cli_input.sys, "stdin", io.StringIO("YES\n")) + with pytest.raises(_cli_input.InteractiveConfirmationRequired): + _cli_input._confirmation_input("Type YES: ") diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index e9c88e4a..2d9bfea5 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -7,10 +7,10 @@ import pytest 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 -from codex32._cli_input import _correction_plan, _fingerprint_matcher +from codex32 import CorrectionContext, MasterSeed, correct +from codex32._cli_input import _fingerprint_matcher +from codex32.bip93 import TEXT_LENGTHS from codex32.correction import CorrectionCandidate, _best, _capture_volume, _erasure_state, _primary from codex32.generation import _fingerprint_identifier from codex32.indel import ( @@ -27,13 +27,12 @@ _search_many, _search_target, ) -from codex32.profiles.ms32 import TEXT_LENGTHS from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint from tools.correction_capture import cross_length_classes from tools.correction_reference import _REDUCED_CLASSES, _alignment_count, _reductions SOURCE = VECTOR_1["secret_s"] -CONTEXT = CorrectionContext(Profile.MS, expected_length=len(SOURCE)) +CONTEXT = CorrectionContext("ms", expected_length=len(SOURCE)) def _groups(text: str) -> list[str]: @@ -130,7 +129,7 @@ def test_optional_three_character_indels_recover_intermediate_lengths( states = tuple( state for target in TEXT_LENGTHS - if (state := _prepare(CorrectionContext(Profile.MS, target, "ms1"), damaged, _CLASSES)) is not None + if (state := _prepare(CorrectionContext("ms", target, "ms1"), damaged, _CLASSES)) is not None ) frontier = _frontier(states, frozenset(TEXT_LENGTHS)) state = next(s for s in states if s.target == len(source)) @@ -155,10 +154,7 @@ def test_automatic_secondary_search_recovers_two_group_indels( ) -> None: source = MasterSeed.from_seed(bytes(range(byte_length)), identifier="test").text damaged = _group_damage(source, inserted, omitted) - contexts = tuple( - CorrectionContext(Profile.MS, target, "ms1") - for target in _correction_plan(Profile.MS, None, len(damaged), None)[0] - ) + contexts = tuple(CorrectionContext("ms", target, "ms1") for target in TEXT_LENGTHS) candidates, complete = _search_many( contexts, @@ -216,7 +212,7 @@ def test_group_search_does_not_depend_on_spaces() -> None: def test_immutable_confirmed_header_reduces_domains_and_cannot_be_repaired() -> None: prefix = SOURCE[:8] - context = CorrectionContext(Profile.MS, 48, prefix) + context = CorrectionContext("ms", 48, prefix) character = next(shape for shape in _CHARACTER_CLASSES if (shape.inserted, shape.omitted) == (2, 2)) two_groups = next(shape for shape in _GROUP_CLASSES if (shape.inserted, shape.omitted) == (0, 2)) @@ -327,23 +323,18 @@ def test_fixed_volume_retains_every_bch_substitution_layer() -> None: def test_unknown_target_search_recovers_reachable_valid_length() -> None: damaged = SOURCE[:19] + SOURCE[20:] - assert correct(CorrectionContext(Profile.MS), damaged)[0].artifact.text == SOURCE + assert correct(CorrectionContext("ms"), damaged)[0].artifact.text == SOURCE assert correct(CONTEXT, damaged)[0].artifact.text == SOURCE @pytest.mark.parametrize( - ("profile", "source"), - ( - (Profile.MS, VECTOR_5["secret_s"]), - (Profile.CL, VECTOR_6["codex32_peev"]), - (Profile.BIP39_12W, BIP39_12W_ZERO), - (Profile.BIP39_24W, BIP39_24W_ZERO), - ), + ("hrp", "source"), + (("ms", VECTOR_5["secret_s"]), ("cl", VECTOR_6["codex32_peev"])), ) -def test_structural_search_supports_every_profile(profile: Profile, source: str) -> None: +def test_structural_search_supports_ms_and_generic_hrps(hrp: str, source: str) -> None: damaged = source[:17] + source[18:] - result = correct(CorrectionContext(profile, expected_length=len(source)), damaged) + result = correct(CorrectionContext(hrp, expected_length=len(source)), damaged) assert [candidate.artifact.text for candidate in result] == [source] @@ -358,7 +349,7 @@ def test_two_each_with_two_substitutions_is_admitted_by_shared_bound() -> None: damaged[3] = "2" damaged[41] = "q" if damaged[41] != "q" else "p" - state = _prepare(CorrectionContext(Profile.MS, 48), "".join(damaged), _CLASSES) + state = _prepare(CorrectionContext("ms", 48), "".join(damaged), _CLASSES) assert state is not None shape = next(s for s in state.counts if s.inserted == s.omitted == 2) frontier = _frontier((state,), frozenset((48,))) @@ -383,7 +374,7 @@ def test_two_each_with_one_substitution_uses_the_generic_fixed_core() -> None: view = _View(symbols, ((0, len(symbols)),), len(symbols)) view = view.splice(28 - 3, 1, 0).splice(14 - 3, 1, 0) view = view.splice(18 - 3, 0, 1).splice(32 - 3, 0, 1) - state = _prepare(CorrectionContext(Profile.MS, 48), text, _CLASSES) + state = _prepare(CorrectionContext("ms", 48), text, _CLASSES) assert state is not None shape = next(s for s in state.counts if s.inserted == s.omitted == 2) frontier = _frontier((state,), frozenset((48,))) @@ -455,7 +446,7 @@ def test_production_cross_length_frontier_matches_independent_arithmetic() -> No for target in TEXT_LENGTHS if ( state := _prepare( - CorrectionContext(Profile.MS, target, "ms1"), + CorrectionContext("ms", target, "ms1"), text, _CLASSES, ) @@ -488,7 +479,7 @@ def test_production_cross_length_frontier_matches_independent_arithmetic() -> No def test_primary_target_runs_first_and_secondary_search_is_proof_driven() -> None: - contexts = tuple(CorrectionContext(Profile.MS, target, "ms1") for target in TEXT_LENGTHS) + contexts = tuple(CorrectionContext("ms", target, "ms1") for target in TEXT_LENGTHS) damaged = SOURCE + "qq" artifact = MasterSeed.from_seed(bytes(16), identifier="test") @@ -515,7 +506,7 @@ def search(state, _frontier, results, _deadline): # type: ignore[no-untyped-def def test_cross_target_paths_deduplicate_the_same_final_string_globally() -> None: - contexts = tuple(CorrectionContext(Profile.MS, target, "ms1") for target in (48, 54)) + contexts = tuple(CorrectionContext("ms", target, "ms1") for target in (48, 54)) artifact = MasterSeed.from_seed(bytes(16), identifier="test") candidate = CorrectionCandidate(artifact, (), 1 << 200, 0, 0, None) @@ -561,5 +552,5 @@ def test_full_checksum_burst_is_admitted_at_the_shared_mass_ceiling(): from codex32.correction import _correct_fixed damaged = SOURCE[:8] + "?" * 13 + SOURCE[21:] - assert _correct_fixed(damaged, suspected_profile=Profile.MS).artifact.text == SOURCE + assert _correct_fixed(damaged, hrp="ms").artifact.text == SOURCE assert correct(CONTEXT, damaged)[0].artifact.text == SOURCE diff --git a/tests/test_crc.py b/tests/test_crc.py index cd0ace8b..84f590be 100644 --- a/tests/test_crc.py +++ b/tests/test_crc.py @@ -7,9 +7,9 @@ import codex32 from codex32 import MasterSeed, Share, parse_codex32 +from codex32.bip93 import SEED_BYTE_LENGTHS, _has_generation_padding from codex32.checksums import _CRC, _crc_pad from codex32.gf32 import _multiply as _gf32_multiply -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding, _payload_padding @pytest.mark.parametrize( @@ -45,7 +45,6 @@ def test_master_seed_factory_uses_crc_for_every_supported_size() -> None: seed = bytes((position * 73 + byte_length) % 256 for position in range(byte_length)) secret = MasterSeed.from_seed(seed, identifier="test") assert _has_generation_padding(secret) - assert _payload_padding(secret) == _crc_pad(seed) def test_parsed_master_seed_validity_is_independent_of_crc() -> None: diff --git a/tests/test_generation.py b/tests/test_generation.py index 9d20ff4d..2e33b9e1 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -1,7 +1,5 @@ """Security invariants for incremental ``ms`` creation ceremonies.""" -import copy -import pickle from itertools import combinations import pytest @@ -19,6 +17,7 @@ parse_codex32, recover_secret, ) +from codex32.bip93 import SEED_BYTE_LENGTHS, _has_generation_padding from codex32.errors import ( CeremonyStateError, CodexError, @@ -28,7 +27,6 @@ InvalidThreshold, ) from codex32.generation import ORDINARY_INDICES, _fingerprint_identifier -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint @@ -229,6 +227,10 @@ def recorded(length: int) -> bytes: damaged = first.text[:4] + ("q" if first.text[4] != "q" else "p") + first.text[5:] result = ceremony.confirm(damaged) assert not result.accepted and result.mismatched_groups == (2,) + dropped = ceremony.confirm(first.text[:9] + first.text[10:]) + # A dropped character marks only its own group, not every later group. + assert not dropped.accepted and dropped.mismatched_groups == (3,) + assert "".join(dropped.entered_groups) == first.text[:9] + first.text[10:] assert calls == [26] assert ceremony.confirm(" \n".join(first.text.upper())).accepted second = ceremony.next_share() @@ -313,20 +315,6 @@ def test_ceremony_state_is_fail_closed() -> None: ceremony.finish() -def test_ceremony_cannot_be_copied_or_serialized() -> None: - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") - pending = ceremony.next_share() - assert ceremony.confirm(pending.text).accepted - - for operation in ( - lambda: copy.copy(ceremony), - lambda: copy.deepcopy(ceremony), - lambda: pickle.dumps(ceremony), - ): - with pytest.raises(TypeError, match="cannot be copied or serialized"): - operation() - - def test_resharing_preserves_padding_and_requires_a_new_header() -> None: source = parse_codex32(VECTOR_4["secret_s_alternate_0"]) assert isinstance(source, MasterSeed) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d1..9529f4de 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -10,6 +10,7 @@ from codex32 import ( CorrectionContext, + MasterSeed, Secret, Share, correct, @@ -18,7 +19,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import MismatchedHrp, MismatchedProfile +from codex32.errors import MismatchedHrp from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -45,14 +46,13 @@ def test_opaque_hrp_parse_complete_recover_and_derive(vector: dict[str, str]) -> assert isinstance(secret, Secret) and type(secret) is Secret assert isinstance(a, Share) and isinstance(c, Share) assert secret.hrp == a.hrp == c.hrp == "zz" - assert secret.profile is a.profile is c.profile is None + assert not isinstance(secret, MasterSeed) assert secret.text.isupper() assert recover_secret([a, c]).text == vector["S"].upper() assert derive_share([secret, a], "d").text == vector["D"].upper() -def test_sharing_compares_normalized_hrp_and_keeps_compatibility_error_name() -> None: - assert MismatchedProfile is MismatchedHrp +def test_sharing_compares_normalized_hrp() -> None: zz = parse_codex32(UNKNOWN["short"]["S"]) yy = parse_codex32(oracle_encode("yy", "2testa" + "p" * 26)) with pytest.raises(MismatchedHrp): diff --git a/tests/test_profiles.py b/tests/test_lengths.py similarity index 82% rename from tests/test_profiles.py rename to tests/test_lengths.py index c70174d7..802c0ebb 100644 --- a/tests/test_profiles.py +++ b/tests/test_lengths.py @@ -1,4 +1,4 @@ -"""Independent profile-length, checksum-boundary, and CL tests.""" +"""Independent master-seed length and checksum-boundary tests.""" import pytest from _codex32_oracle import oracle_encode as _oracle_encode @@ -10,23 +10,17 @@ VALID_CODEX32, VALID_CODEX32_LONG, VECTOR_2, - VECTOR_6, ) -from codex32 import ( - CoreLightningSecret, - MasterSeed, - parse_codex32, -) +from codex32 import MasterSeed, parse_codex32 from codex32.bech32 import ( CHARSET, _chars_to_u5, bech32_decode, bech32_hrp_expand, ) -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import SEED_BYTE_LENGTHS, TEXT_LENGTHS, _checksum_for_encoded_length from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum -from codex32.correction import _correct_fixed from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -35,7 +29,6 @@ InvalidThreshold, MissingSeparator, ) -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, TEXT_LENGTHS def _payload(data: bytes, padding: int) -> str: @@ -123,7 +116,7 @@ def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: valid_generic = _oracle_encode("zz", "0tests" + "q" * 26) artifact = parse_codex32(valid_generic) assert artifact.hrp == "zz" - assert artifact.profile is None + assert not isinstance(artifact, MasterSeed) with pytest.raises(InvalidChecksum): parse_codex32("zz10tests" + "q" * 39) @@ -138,7 +131,7 @@ def test_common_header_is_validated_before_checksum() -> None: ("hrp", "payload_length"), (("ms", 26), ("cl", 52), ("bip39_12w", 27), ("bip39_24w", 53)), ) -def test_checksum_precedes_profile_length(hrp: str, payload_length: int) -> None: +def test_checksum_precedes_length(hrp: str, payload_length: int) -> None: valid = _oracle_encode(hrp, "0tests" + "q" * payload_length) with pytest.raises(InvalidChecksum): parse_codex32(valid[:-1]) @@ -151,34 +144,13 @@ def test_generic_length_and_checksum_precede_ms_length() -> None: parse_codex32("ms10tests" + "q" * 40) -def test_valid_profile_length_still_reports_checksum_failure() -> None: +def test_valid_ms_length_still_reports_checksum_failure() -> None: valid = _oracle_encode("ms", "0tests" + "q" * 26) damaged = valid[:-1] + ("q" if valid[-1] != "q" else "p") with pytest.raises(InvalidChecksum): parse_codex32(damaged) -def test_core_lightning_constructor_and_parsed_padding() -> None: - original = parse_codex32(VECTOR_6["codex32_peev"]) - assert isinstance(original, CoreLightningSecret) - assert original.payload_symbols[-1] & 0xF == 0 - payload = list(original.payload_symbols) - payload[-1] |= 0xF - nonzero = _oracle_encode("cl", "0peevs" + "".join(CHARSET[value] for value in payload)) - parsed = parse_codex32(nonzero) - assert isinstance(parsed, CoreLightningSecret) - 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_malformed_corpus.py b/tests/test_malformed_corpus.py index 9fcbf4ac..828c3aa1 100644 --- a/tests/test_malformed_corpus.py +++ b/tests/test_malformed_corpus.py @@ -5,7 +5,7 @@ import pytest -from codex32 import CorrectionContext, Profile, correct, parse_codex32, recover_secret +from codex32 import CorrectionContext, correct, parse_codex32, recover_secret from codex32._cli_parser import parser from codex32.errors import CodexError @@ -28,7 +28,7 @@ def test_malformed_interpolation_corpus(case: dict[str, object]) -> None: @pytest.mark.parametrize("case", _DOCUMENT["correction"], ids=lambda case: case["id"]) def test_malformed_correction_corpus(case: dict[str, str]) -> None: - assert correct(CorrectionContext(Profile(case["profile"])), case["text"]) == () + assert correct(CorrectionContext(case["profile"]), case["text"]) == () @pytest.mark.parametrize("case", _DOCUMENT["cli_tokens"], ids=lambda case: case["id"]) diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py deleted file mode 100644 index f812096f..00000000 --- a/tests/test_mixed_case_deadline.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Deadline regression for mixed-case embedded recovery.""" - -from dataclasses import replace -from time import monotonic - -import pytest - -from codex32 import Profile -from codex32._cli_input import _case_interpretation, _correction_candidates, _scheduled_candidates - -SOURCE = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" - - -@pytest.mark.parametrize( - ("damaged", "erasure_fixes", "normalized_fixes"), - [ - # Five minority-case P: five erasures are correctable, five substitutions are not. - ("ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw", True, False), - # Fifteen minority-case X, one mistyped: fifteen erasures are not correctable, - # one substitution after case normalization is. - ("ms10testsXXXXXXXPXXXXXXXxxxxxxxxxxx4nzvca9cmczlw", False, True), - ], -) -def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline( - damaged: str, erasure_fixes: bool, normalized_fixes: bool -) -> None: - interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) - - assert interpretation is not None - direct, normalized, erased, _prefix = interpretation - assert direct is None - immutable = normalized[: normalized.rfind("1") + 1] - for value, fixes in ((erased, erasure_fixes), (normalized, normalized_fixes)): - alone, alone_complete, _deadline = _correction_candidates( - value, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True - ) - assert alone_complete - assert [candidate.artifact.text for candidate in alone] == ([SOURCE] if fixes else []) - - candidates, complete = _scheduled_candidates( - normalized, - erased, - Profile.MS, - None, - immutable, - deadline=monotonic() + 10, - ) - - # The exhaustive optional search may truncate at the deadline; the - # candidate must still be found and must report that truncation. - assert [candidate.artifact.text for candidate in candidates] == [SOURCE] - assert candidates[0].search_complete is complete - - -def test_scheduled_truncation_survives_a_complete_later_pass(monkeypatch: pytest.MonkeyPatch) -> None: - damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" - interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) - assert interpretation is not None - _direct, normalized, erased, _prefix = interpretation - immutable = normalized[: normalized.rfind("1") + 1] - found, _complete, _deadline = _correction_candidates( - erased, Profile.MS, None, immutable, deadline=monotonic() + 10, required_only=True - ) - full_searches: list[str] = [] - - def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[object, bool, float]: - if kwargs.get("required_only"): - return (), True, 0.0 - assert kwargs.get("optional_only") is True - full_searches.append(value) - if len(full_searches) == 1: - return (replace(found[0], search_complete=False),), False, 0.0 - return (), True, 0.0 - - monkeypatch.setattr("codex32._cli_input._correction_candidates", truncated_first) - - candidates, complete = _scheduled_candidates(normalized, erased, Profile.MS, None, immutable) - - assert full_searches == [erased, normalized] - assert not complete - assert [(candidate.artifact.text, candidate.search_complete) for candidate in candidates] == [ - (SOURCE, False) - ] diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 70106c53..954b47f9 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -16,7 +16,6 @@ CorrectionContext, Header, MasterSeed, - Profile, Share, correct, correct_worksheet_residue, @@ -27,7 +26,7 @@ def test_backup_workflows_need_only_the_standard_library() -> None: source = """ -from codex32 import CreationCeremony, CorrectionContext, Profile, correct, derive_share, parse_codex32, recover_secret +from codex32 import CreationCeremony, CorrectionContext, correct, derive_share, parse_codex32, recover_secret ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") shares = [] @@ -40,7 +39,7 @@ def test_backup_workflows_need_only_the_standard_library() -> None: assert recover_secret(shares) == secret assert derive_share(shares, "d").header.index == "d" damaged = shares[0].text[:-1] + "?" -assert correct(CorrectionContext(Profile.MS), damaged) +assert correct(CorrectionContext("ms"), damaged) """ environment = os.environ.copy() environment["PYTHONPATH"] = str(Path(__file__).parents[1] / "src") @@ -84,9 +83,7 @@ def test_share_has_symbols_but_no_byte_or_padding_api() -> None: assert not hasattr(share, name) -def test_artifacts_cannot_be_directly_constructed_or_mutated() -> None: - with pytest.raises(TypeError): - Share("ms", Header(2, "test", "a"), (), ()) # type: ignore[arg-type] +def test_artifacts_cannot_be_mutated() -> None: share = parse_codex32(VECTOR_2["share_A"]) with pytest.raises((FrozenInstanceError, AttributeError)): share.text = "changed" # type: ignore[misc] @@ -107,7 +104,7 @@ def test_artifact_default_rendering_does_not_disclose_recovery_text() -> None: def test_correction_edits_do_not_render_recovery_characters() -> None: text = VECTOR_2["share_A"] - candidate = correct(CorrectionContext(Profile.MS), text[:9] + "?" * 8 + text[17:])[0] + candidate = correct(CorrectionContext("ms"), text[:9] + "?" * 8 + text[17:])[0] rendered = repr(candidate) assert len(candidate.edits) == 8 diff --git a/tests/test_sharing.py b/tests/test_sharing.py index ab6e7742..7fcb70cd 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -5,7 +5,7 @@ import pytest from _codex32_oracle import oracle_encode from data.bip93_vectors import VECTOR_2, VECTOR_3 -from data.sharing_vectors import INVALID_BIP39_IMPLIED_SECRET, SHARING_VECTORS +from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings from hypothesis import strategies as st @@ -17,22 +17,19 @@ recover_secret, ) from codex32.bech32 import CHARSET, _u5_to_chars -from codex32.bip93 import IDX_SORT +from codex32.bip93 import IDX_SORT, SEED_BYTE_LENGTHS from codex32.checksums import _Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, - InvalidBip39Checksum, - InvalidPadding, InvalidTargetIndex, + MismatchedHrp, MismatchedIdentifier, MismatchedPayloadLength, - MismatchedProfile, MismatchedThreshold, SecretInRecoverySet, WrongShareCount, ) -from codex32.profiles.ms32 import SEED_BYTE_LENGTHS def _payload_text(artifact: Share | MasterSeed) -> str: @@ -112,7 +109,7 @@ 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() opaque = parse_codex32(oracle_encode("xy", "2testa" + "q" * len(ms_secret.payload_symbols))) - with pytest.raises(MismatchedProfile): + with pytest.raises(MismatchedHrp): derive_share([ms_secret, opaque], "c") # type: ignore[list-item] threshold_three = parse_codex32(oracle_encode("ms", "3testc" + "q" * len(ms_secret.payload_symbols))) @@ -140,18 +137,6 @@ def test_share_set_mismatches_have_distinct_errors() -> None: recover_secret(VECTOR_2["share_A"]) # type: ignore[arg-type] -def test_oversized_sequence_is_rejected_before_items_are_read() -> None: - class OversizedSequence: - def __len__(self) -> int: - return 10 - - def __getitem__(self, _position: int) -> Share: - raise AssertionError("oversized input must not be materialized") - - with pytest.raises(WrongShareCount): - recover_secret(OversizedSequence()) # type: ignore[arg-type] - - @pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", 3)) def test_invalid_targets_are_rejected(target: object) -> None: secret, masks = _ms_basis() @@ -197,18 +182,6 @@ def test_frozen_profile_sharing_vectors(profile: str) -> None: assert derive_share([a, c], "d").text == vector["D"] -def test_invalid_implied_bip39_secret_rejects_recovery_and_derivation() -> None: - a = parse_codex32(INVALID_BIP39_IMPLIED_SECRET["A"]) - c = parse_codex32(INVALID_BIP39_IMPLIED_SECRET["C"]) - assert isinstance(a, Share) and isinstance(c, Share) - for operation in ( - lambda: recover_secret([a, c]), - lambda: derive_share([a, c], "d"), - ): - with pytest.raises((InvalidPadding, InvalidBip39Checksum)): - operation() - - @given(st.sampled_from(SEED_BYTE_LENGTHS), st.booleans()) @settings(max_examples=35, deadline=None) def test_order_and_case_properties(byte_length: int, uppercase: bool) -> None: diff --git a/tools/alignment_benchmark.py b/tools/alignment_benchmark.py index 4004c9a8..ab4618ef 100644 --- a/tools/alignment_benchmark.py +++ b/tools/alignment_benchmark.py @@ -30,20 +30,14 @@ _word_roots, ) from codex32.indel import _CLASSES, _FIXED, _alignment_counts, _frontier, _prepare, _search_many, _views -from codex32.profiles import Profile BODY_LENGTHS = (40, 66, 93, 127, 1015, 1023) -PUBLIC_LENGTHS = { - Profile.MS: (48, 54, 61, 67, 74, 127), - Profile.CL: (74,), - Profile.BIP39_12W: (56,), - Profile.BIP39_24W: (82,), -} +PUBLIC_LENGTHS = {"ms": (48, 54, 61, 67, 74, 127)} DELTAS = (-8, -4, -3, -2, -1, 0, 1, 2, 3, 4, 8) def public_case( - profile: Profile, + profile: str, displayed: int, depth: int, unknown: bool, @@ -53,7 +47,7 @@ def public_case( seconds: float, memory: bool = False, ) -> dict[str, object]: - hrp = profile.value + hrp = profile prefix = hrp + "1" + ("2test" if frozen else "") random = Random(9001) text = hrp + "1" + "".join(random.choice(CHARSET) for _ in range(displayed + delta - len(hrp + "1"))) diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py deleted file mode 100644 index 63147209..00000000 --- a/tools/bitcoin_core_main_smoke.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Exercise codex32 wallet integration against an isolated Bitcoin Core 32 main chain.""" - -from __future__ import annotations - -import argparse -import json -import os -import shlex -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Any - -from codex32._bitcoin_core import BitcoinCore -from codex32.bip93 import parse_codex32 -from codex32.profiles.ms32 import MasterSeed - -# Frozen public BIP93 vector material; it has never controlled a funded wallet. -_SEED = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" - - -def _run(command: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, input=stdin, text=True, capture_output=True, check=False) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--bitcoind", default="bitcoind") - parser.add_argument("--bitcoin-cli", default="bitcoin-cli") - arguments = parser.parse_args() - - with tempfile.TemporaryDirectory(prefix="codex32-core-main-") as temporary: - datadir = Path(temporary) - real_cli = shutil.which(arguments.bitcoin_cli) - if real_cli is None: - raise RuntimeError("bitcoin-cli was not found") - daemon = subprocess.Popen( - [ - arguments.bitcoind, - "-chain=main", - f"-datadir={datadir}", - "-server=1", - "-listen=0", - "-discover=0", - "-dnsseed=0", - "-fixedseeds=0", - "-connect=0", - "-printtoconsole=0", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - ) - base = [real_cli, "-chain=main", f"-datadir={datadir}", "-rpcconnect=127.0.0.1"] - wrapper_directory = datadir / "wrapper" - wrapper_directory.mkdir() - wrapper = wrapper_directory / "bitcoin-cli" - wrapper.write_text( - f'#!/bin/sh\nexec {shlex.quote(real_cli)} {shlex.quote(f"-datadir={datadir}")} "$@"\n' - ) - wrapper.chmod(0o700) - old_path = os.environ.get("PATH", "") - os.environ["PATH"] = str(wrapper_directory) + os.pathsep + old_path - - def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None) -> Any: - command = [*base, "-rpcwait", "-rpcwaittimeout=30"] - if wallet is not None: - command.append(f"-rpcwallet={wallet}") - if stdin is not None: - command.append("-stdin") - result = _run([*command, *rpc_arguments], stdin=stdin) - if result.returncode: - raise RuntimeError(result.stderr.strip()) - output = result.stdout.strip() - if not output: - return None - try: - return json.loads(output) - except json.JSONDecodeError: - return output - - try: - blockchain = rpc("getblockchaininfo") - network = rpc("getnetworkinfo") - if not isinstance(blockchain, dict) or blockchain.get("chain") != "main": - raise RuntimeError("Bitcoin Core did not start on mainnet") - if not isinstance(network, dict) or network.get("version", 0) < 320000: - raise RuntimeError("Bitcoin Core 32 or newer is required") - - rpc( - "-named", - "createwallet", - "wallet_name=signer", - "disable_private_keys=false", - "blank=true", - "descriptors=true", - ) - passphrase = "main-smoke-only-passphrase" - rpc("encryptwallet", wallet="signer", stdin=passphrase + "\n") - unlocked = _run( - [ - *base, - "-rpcwallet=signer", - "-stdinwalletpassphrase", - "walletpassphrase", - "120", - ], - stdin=passphrase + "\n", - ) - if unlocked.returncode: - raise RuntimeError(unlocked.stderr.strip()) - - secret = parse_codex32(_SEED) - if not isinstance(secret, MasterSeed): - raise TypeError("synthetic fixture was not a master seed") - client = BitcoinCore.connect() - expected_fingerprint = client.fingerprint(secret) - answers = iter(("yes",)) - if ( - client.initialize( - secret, - lambda _prompt: next(answers), - lambda _message: None, - expected_fingerprint=expected_fingerprint, - account=0, - timestamp=0, - ) - != "signer" - ): - raise RuntimeError("automatic initialization selected the wrong wallet") - if rpc("getwalletinfo", wallet="signer")["unlocked_until"] != 0: - raise RuntimeError("automatic initialization did not relock the wallet") - _verify_origins(rpc("listdescriptors", wallet="signer"), account=0) - - print( - json.dumps( - { - "bitcoin_core": network["subversion"], - "chain": blockchain["chain"], - "account": 0, - "status": "pass", - } - ) - ) - finally: - os.environ["PATH"] = old_path - _run([*base, "stop"]) - try: - daemon.wait(timeout=30) - except subprocess.TimeoutExpired: - daemon.terminate() - daemon.wait(timeout=10) - - -def _verify_origins(listing: Any, *, account: int) -> None: - if not isinstance(listing, dict): - raise TypeError("Core did not return a descriptor listing") - active = [item for item in listing.get("descriptors", ()) if item.get("active")] - if len(active) != 8 or any("xprv" in item.get("desc", "") for item in active): - raise RuntimeError("Core did not create eight public active descriptors") - for purpose in (44, 49, 84, 86): - if sum(f"/{purpose}h/0h/{account}h]" in item["desc"] for item in active) != 2: - raise RuntimeError(f"Core did not create the expected BIP{purpose} account-{account} origins") - - -if __name__ == "__main__": - main() diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 9e125c87..746422c7 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -16,8 +16,7 @@ from _wallet_test_vectors import CORE_FINGERPRINTS from codex32._bitcoin_core import BitcoinCore -from codex32.bip93 import parse_codex32 -from codex32.profiles.ms32 import MasterSeed +from codex32.bip93 import MasterSeed, parse_codex32 from codex32.wallet import master_xprv # Frozen public BIP93 vector material; it has never controlled a funded wallet. @@ -59,7 +58,7 @@ def main() -> None: wrapper_directory.mkdir() wrapper = wrapper_directory / "bitcoin-cli" wrapper.write_text( - f'#!/bin/sh\nexec {shlex.quote(real_cli)} {shlex.quote(f"-datadir={datadir}")} "$@"\n' + f'#!/bin/sh\nexec {shlex.quote(real_cli)} -regtest {shlex.quote(f"-datadir={datadir}")} "$@"\n' ) wrapper.chmod(0o700) os.environ["PATH"] = str(wrapper_directory) + os.pathsep + os.environ.get("PATH", "") diff --git a/tools/correction_benchmark.py b/tools/correction_benchmark.py index 922632d1..509ce0b7 100644 --- a/tools/correction_benchmark.py +++ b/tools/correction_benchmark.py @@ -12,14 +12,13 @@ from codex32._cli_input import _automatic_targets from codex32.bech32 import CHARSET +from codex32.bip93 import TEXT_LENGTHS from codex32.correction import CorrectionContext from codex32.indel import ( _CHARACTER_CLASSES, _required_header_substitutions, _search_many, ) -from codex32.profiles import Profile -from codex32.profiles.ms32 import TEXT_LENGTHS from tools.correction_reference import _alignment_count, _variants DELTAS = (-8, -4, -3, -2, -1, 0, 1, 2, 3, 4, 8) @@ -65,7 +64,7 @@ def benchmark( delta: int, memory: bool, ) -> dict[str, object]: - context = CorrectionContext(Profile.MS, length, immutable_prefix) + context = CorrectionContext("ms", length, immutable_prefix) damaged = _damaged_text(length, immutable_prefix, delta) counts = _mixed_counts(context, damaged) if delta == 0 else {} if memory: @@ -101,7 +100,7 @@ def benchmark( def benchmark_cross_length(observed_length: int, *, unknown: bool = False) -> dict[str, object]: targets = TEXT_LENGTHS if unknown else _automatic_targets(observed_length) - contexts = tuple(CorrectionContext(Profile.MS, target, "ms1") for target in targets) + contexts = tuple(CorrectionContext("ms", target, "ms1") for target in targets) damaged = _damaged_text(observed_length, "ms1", 0) started = perf_counter() candidates, complete = _search_many( diff --git a/tools/differential_correction.py b/tools/differential_correction.py index 5c7fff02..8c0dd263 100644 --- a/tools/differential_correction.py +++ b/tools/differential_correction.py @@ -9,7 +9,6 @@ CorrectionCandidate, _correct_fixed, ) -from codex32.profiles import Profile _CORPUS_SHA256 = "6aa552b34c0bb2878d45dee2655c331d52e40e41e61cef523415d314ad9948e5" @@ -32,7 +31,7 @@ def main() -> None: for case in document["cases"]: result = _correct_fixed( case["damaged"], - suspected_profile=Profile.MS, + hrp="ms", ) expected = case["expected"] if expected is None: diff --git a/tools/fuzz_correction_context.py b/tools/fuzz_correction_context.py deleted file mode 100644 index 2fda4c8e..00000000 --- a/tools/fuzz_correction_context.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Dependency-free structured fuzz target for full-string correction context.""" - -from __future__ import annotations - -import sys - -from codex32 import CorrectionContext, Profile, correct -from codex32.errors import CodexError - -MAX_INPUT = 4096 -_PROFILES = tuple(Profile) -_TARGETS = { - Profile.MS: (48, 74, 127), - Profile.CL: (74,), - Profile.BIP39_12W: (56,), - Profile.BIP39_24W: (82,), -} - - -def LLVMFuzzerTestOneInput(data: bytes) -> int: - if not data or len(data) > MAX_INPUT: - return 0 - selector, payload = data[0], data[1:] - profile = _PROFILES[selector % len(_PROFILES)] - text = payload.decode("utf-8", "surrogateescape") - targets = _TARGETS[profile] - expected_length = None if selector & 4 else targets[selector % len(targets)] - base = f"{profile.value}1" - immutable_prefix = None if selector & 8 else text[: len(base) + 5] - excluded_indices = () if selector & 16 else tuple(text[5:37]) - try: - correct( - CorrectionContext( - profile, - expected_length, - immutable_prefix, - excluded_indices, - ), - text, - ) - except CodexError: - pass - return 0 - - -if __name__ == "__main__": - raise SystemExit(LLVMFuzzerTestOneInput(sys.stdin.buffer.read(MAX_INPUT + 1))) diff --git a/tools/fuzz_untrusted_boundaries.py b/tools/fuzz_untrusted_boundaries.py deleted file mode 100644 index 9f642765..00000000 --- a/tools/fuzz_untrusted_boundaries.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Dependency-free structured fuzz target for bounded untrusted inputs.""" - -from __future__ import annotations - -import contextlib -import io -import shlex -import sys - -from codex32 import CorrectionContext, Profile, correct, parse_codex32, recover_secret -from codex32._cli_parser import parser -from codex32.errors import CodexError - -MAX_INPUT = 4096 -_PROFILES = tuple(Profile) - - -def LLVMFuzzerTestOneInput(data: bytes) -> int: - """Exercise one selected boundary; validation failures are expected.""" - if not data or len(data) > MAX_INPUT: - return 0 - mode, payload = data[0] % 4, data[1:] - text = payload.decode("utf-8", "surrogateescape") - try: - if mode == 0: - parse_codex32(text) - elif mode == 1: - profile = _PROFILES[payload[0] % len(_PROFILES)] if payload else Profile.MS - target = {Profile.MS: 48, Profile.CL: 74, Profile.BIP39_12W: 56, Profile.BIP39_24W: 82} - correct(CorrectionContext(profile, target[profile]), text) - elif mode == 2: - artifacts = [parse_codex32(token) for token in text.split()[:10]] - recover_secret(artifacts) - else: - tokens = shlex.split(text) - if len(tokens) <= 16 and all(len(token) <= 128 for token in tokens): - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - parser().parse_args(tokens) - except (CodexError, SystemExit, ValueError): - pass - return 0 - - -if __name__ == "__main__": - raise SystemExit(LLVMFuzzerTestOneInput(sys.stdin.buffer.read(MAX_INPUT + 1))) diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py deleted file mode 100644 index 0a4fbef7..00000000 --- a/tools/verify_installed_wheel.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Smoke-test backup/recovery and root-wallet primitives from an installed wheel.""" - -from __future__ import annotations - -import importlib.util -import sys - -from codex32 import ( - CorrectionContext, - CreationCeremony, - MasterSeed, - Profile, - correct, - derive_share, - master_xprv, - parse_codex32, - recover_secret, -) - -_SECRET = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" -_XPRV = ( - "xprv9s21ZrQH143K3taPNekMd9oV5K6szJ8ND7vVh6fxicRUMDcChr3bFFzuxY8qP3" - "xFFBL6DWc2uEYCfBFZ2nFWbAqKPhtCLRjgv78EZJDEfpL" -) - - -def main() -> None: - assert importlib.util.find_spec("bip32") is None - assert importlib.util.find_spec("coincurve") is None - - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") - shares = [] - for _ in range(2): - share = ceremony.next_share() - assert ceremony.confirm(share.text).accepted - shares.append(share) - generated = ceremony.finish() - assert parse_codex32(generated.text) == generated - assert recover_secret(shares) == generated - assert derive_share(shares, "d").header.index == "d" - damaged = shares[0].text[:-1] + "?" - assert correct(CorrectionContext(Profile.MS), damaged) - - secret = parse_codex32(_SECRET) - assert isinstance(secret, MasterSeed) - assert master_xprv(secret) == _XPRV - assert "bip32" not in sys.modules - assert "coincurve" not in sys.modules - - -if __name__ == "__main__": - main() diff --git a/tools/verify_wheel_environment.py b/tools/verify_wheel_environment.py deleted file mode 100644 index 689e1ee0..00000000 --- a/tools/verify_wheel_environment.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Install the built wheel without dependencies and run the installed-wheel verifier.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import tempfile -import venv -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--wheel", type=Path) - arguments = parser.parse_args() - root = Path(__file__).resolve().parents[1] - if arguments.wheel is None: - wheels = tuple((root / "dist").glob("codex32-*.whl")) - if len(wheels) != 1: - raise RuntimeError(f"expected exactly one codex32 wheel, found {len(wheels)}") - wheel = wheels[0] - else: - wheel = arguments.wheel.resolve() - if not wheel.is_file(): - raise FileNotFoundError(wheel) - - with tempfile.TemporaryDirectory(prefix="codex32-wheel-") as temporary: - environment = Path(temporary) - venv.EnvBuilder(with_pip=True).create(environment) - python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - subprocess.run( - [ - str(python), - "-m", - "pip", - "install", - "--disable-pip-version-check", - "--no-deps", - str(wheel), - ], - check=True, - ) - subprocess.run( - [str(python), str(root / "tools" / "verify_installed_wheel.py")], - check=True, - cwd=temporary, - ) - - -if __name__ == "__main__": - main()