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 d6b72f90..49d0ef55 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,18 +77,15 @@ 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. -Before acting on a pull request, read `CONTRIBUTING.md` and -`docs/developer/AI_POLICY.md` from its current target branch. Follow that merged -contribution policy when the topic branch contains an older version. AI may -write PR descriptions and review replies under the tool-neutral policy. +Before committing, opening a pull request, or writing an issue, pull request, +or review comment, read `CONTRIBUTING.md` and `docs/developer/AI_POLICY.md` +from the pull request's current target branch; they decide authorship, commit +messages, and who publishes. Follow that merged policy when the topic branch +contains an older version. AI may write PR descriptions and review replies under +the tool-neutral policy. Follow `SECURITY.md` for private vulnerability reporting. Local edits, checks, and atomic commits are allowed. Publish only within the user's authorized scope; permission to use AI does not itself authorize contacting others. Never claim independent human authorship, 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. diff --git a/README.md b/README.md index fbc128c9..c80d3ec8 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,10 @@ 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. diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..f4e745ba 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,15 @@ generic parse-length failure. ### Boundaries -- Only parsing and profile-specific factories construct artifacts. +- Only parsing and `MasterSeed.from_seed` construct artifacts. - `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 +98,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 +108,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 +217,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 +239,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 +274,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 +288,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 +314,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 +340,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 +399,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 +424,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 +450,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 +462,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 +472,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 +539,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 +626,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 +634,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 +675,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 +684,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..616e42b7 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: @@ -114,7 +111,8 @@ Choose one command: copies, but any copy can recover the seed. - **Custom:** thresholds 4 through 9 require `--shares` or `--indices`. For example, `ms32 create 4cash --shares 7` produces seven `cash` shares; any - four recover the seed. + four recover the seed. The identifier tells backups apart, so choose one + that no other backup you keep uses. More required shares make theft harder; fewer required shares make recovery easier. @@ -298,13 +296,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 +317,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..45031a7a 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -12,8 +12,8 @@ from codex32._bip32 import _master_xprv_from_seed from codex32.bech32 import _u5_to_chars, convertbits +from codex32.bip93 import MasterSeed from codex32.generation import _fingerprint_identifier -from codex32.profiles.ms32 import MasterSeed class BitcoinCoreError(Exception): diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 53ba5b9c..245244c1 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -14,11 +14,16 @@ from codex32.bech32 import interpret_mixed_case from codex32.bip93 import ( + MS, + TEXT_LENGTHS, + 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, ) @@ -30,18 +35,12 @@ InvalidChecksum, InvalidLength, InvalidThreshold, + MismatchedHrp, 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 @@ -67,22 +66,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 +199,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) ): @@ -313,25 +299,22 @@ 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 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. + # Explain an unsupported master-seed length without validating input + # or changing the parser's checksum-before-length boundary. try: - _profile_rules(value[: value.rfind("1")]).validate_text_length(len(value)) + _validate_ms_length(value[: value.rfind("1")].lower(), 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.") + if hrp is not None and artifact.hrp != hrp: + raise InputError("This command accepts only Bitcoin master seed input.") return artifact @@ -377,7 +360,7 @@ def _display_prefix(prefix: str, grouped: bool) -> str: def _case_interpretation( value: str, prefix: str, - profiles: tuple[Profile, ...] | None, + hrp: str | None, allowed: Callable[[CorrectionCandidate], bool] | None, ) -> tuple[CorrectionCandidate | None, str, str, str] | None: # Normalize likely casing and mark contrary-case data as erasures. @@ -390,7 +373,7 @@ def _case_interpretation( corrected, erased, uppercase = interpretation corrected_prefix = prefix.upper() if uppercase else prefix.lower() try: - artifact = _parse(corrected, profiles) + artifact = _parse(corrected, hrp) except InputError: candidate = None else: @@ -406,33 +389,26 @@ def _case_interpretation( def _correction_plan( - hrp: str | Profile, + hrp: str, byte_length: int | Literal["?"] | None, count: int, target: int | None, ) -> tuple[int, ...]: if target is not None: return (target,) - normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() - if normalized_hrp == Profile.CL.value: - return (74,) if isinstance(byte_length, int): return (_text_length(byte_length),) if byte_length == "?": return TEXT_LENGTHS - if normalized_hrp == Profile.MS.value: + if hrp.lower() == MS: nearest = min(_PRIMARY_MS, key=lambda length: abs(count - length)) - targets = (nearest, *(length for length in TEXT_LENGTHS if length != nearest)) - return targets - rules = _optional_profile_rules(normalized_hrp) - if rules is not None and hasattr(rules, "text_length"): - return (rules.text_length,) + return (nearest, *(length for length in TEXT_LENGTHS if length != nearest)) return tuple(sorted({count + delta for delta in (*range(-4, 5), -8, 8)})) def _correction_candidates( value: str, - profile: str | Profile, + hrp: str, byte_length: int | Literal["?"] | None, immutable: str, excluded: tuple[str, ...] = (), @@ -447,9 +423,9 @@ def _correction_candidates( optional_only: bool = False, ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) - targets = _correction_plan(profile, byte_length, count, target) + targets = _correction_plan(hrp, byte_length, count, target) deadline = monotonic() + 10 if deadline is None else deadline - contexts = tuple(CorrectionContext(profile, length, immutable, excluded) for length in targets) + contexts = tuple(CorrectionContext(hrp, length, immutable, excluded) for length in targets) from codex32.indel import _search_many candidates, complete = _search_many( @@ -479,7 +455,7 @@ def _correction_candidates( def _scheduled_candidates( value: str, erased: str, - profile: str | Profile, + hrp: str, byte_length: int | Literal["?"] | None, immutable: str, excluded: tuple[str, ...] = (), @@ -492,7 +468,7 @@ def _scheduled_candidates( """Search both case interpretations under one deadline and capture ledger.""" search = partial( _correction_candidates, - profile=profile, + hrp=hrp, byte_length=byte_length, immutable=immutable, excluded=excluded, @@ -557,7 +533,7 @@ def matches(candidate: CorrectionCandidate) -> bool | None: def _suggestions( value: str, prefix: str, - profiles: tuple[Profile, ...] | None, + required_hrp: str | None, accepted: list[Artifact], *, allowed: Callable[[CorrectionCandidate], bool] | None = None, @@ -565,7 +541,7 @@ def _suggestions( ) -> tuple[CorrectionCandidate, ...]: fingerprint_match = _fingerprint_matcher(fingerprint) erased = value - if interpretation := _case_interpretation(value, prefix, profiles, allowed): + if interpretation := _case_interpretation(value, prefix, required_hrp, allowed): candidate, value, erased, prefix = interpretation if candidate is not None: return (candidate,) @@ -573,9 +549,7 @@ def _suggestions( 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: + if required_hrp is not None and hrp != required_hrp: return () excluded = tuple(artifact.header.index for artifact in accepted) target = len(accepted[0].text) if accepted else None @@ -610,12 +584,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, @@ -630,7 +606,7 @@ def _redirected( accepted: list[Artifact] = [] for token in tokens: try: - artifact = _parse(token, profiles) + artifact = _parse(token, hrp) except InputError: if one: raise @@ -651,7 +627,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: candidates = _suggestions( token, "", - profiles, + hrp, accepted, allowed=allowed, fingerprint=fingerprint, @@ -674,7 +650,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: _FRIENDLY_SET_ERRORS: dict[type[Exception], str] = { InvalidChecksum: "The checksum does not match.", - MismatchedProfile: "These strings are for different applications.", + MismatchedHrp: "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.", @@ -697,7 +673,7 @@ def _prefixed_input_error( if not isinstance(cause, InvalidChecksum): return str(error) if accepted: - if accepted[0].profile is not Profile.MS: + if accepted[0].hrp != MS: return str(error) target = len(accepted[0].text) difference = len(value) - target @@ -723,7 +699,7 @@ 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]: @@ -758,7 +734,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 = ( () @@ -766,7 +742,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: else _suggestions( complete_value, prefix, - profiles, + hrp, accepted, allowed=allowed, fingerprint=fingerprint, @@ -821,13 +797,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, @@ -837,7 +813,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..6542684b 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): @@ -160,7 +160,7 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu "header", nargs="?", metavar="HEADER", - help="backup header or sharing threshold, such as 3cash or 3; omit for a single recovery card", + help="sharing threshold, such as 3, or threshold and identifier, such as 3cash, using an identifier no other backup you keep uses; omit for a single recovery card", ) create.add_argument( "--bytes", diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 238f7dd2..e08d7c3b 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,11 +33,33 @@ ) 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) class Header: @@ -95,7 +118,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") @@ -104,11 +127,8 @@ def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, .. 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 + _validate_ms_length(hrp, len(text)) + return hrp, tuple(encoded), checksum @dataclass(frozen=True, slots=True, init=False) @@ -116,7 +136,6 @@ class _Artifact: text: str header: Header hrp: str - profile: Profile | None payload_symbols: tuple[int, ...] def __init__( @@ -124,7 +143,6 @@ def __init__( text: str, header: Header, hrp: str, - profile: Profile | None, payload_symbols: tuple[int, ...], *, _token: object, @@ -134,7 +152,6 @@ def __init__( 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: @@ -167,47 +184,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:], _token=_CONSTRUCTION_TOKEN) 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 +252,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, ...], ...] @@ -266,9 +283,8 @@ def _bounded_artifacts( 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: + hrp, encoded, checksum = _decode_codex32(artifact.text) + if hrp != artifact.hrp: raise InvalidShareSet("artifact text and validated application prefix disagree") return tuple(encoded[6:]), checksum.length, len(encoded) @@ -306,7 +322,6 @@ def _validate_share_set(artifacts: Sequence[Share | Secret], *, require_exact: b return _ShareSet( copied, first.hrp, - first.profile, threshold, first.header.identifier, tuple(tails), @@ -320,10 +335,6 @@ def _validate_recovery_prefix(shares: Sequence[Share | Secret]) -> None: raise SecretInRecoverySet("recovery accepts ordinary shares only") -def _validate_basis_prefix(basis: Sequence[Share | Secret]) -> None: - _validate_share_set(basis, require_exact=False) - - def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: points = tuple(CHARSET.index(index) for index in share_set.indices) weights = _lagrange_weights(points, CHARSET.index(target)) @@ -364,13 +375,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 35b1441f..147de766 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -36,10 +36,13 @@ 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, @@ -56,11 +59,6 @@ CreationCeremony, 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 +67,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 +107,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 +116,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 +126,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 +179,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 +198,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 +214,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 +240,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: @@ -333,20 +317,6 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul 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, - ) - - def _show_fingerprint(core: BitcoinCore, secret: MasterSeed, action: str) -> None: _print(f"\nMaster fingerprint: {core.fingerprint(secret).hex().upper()}", err=True) _text(f"{action}, then press Enter", optional=True, prompt_end=". ") @@ -467,10 +437,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 +451,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 +463,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 +480,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 +509,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,25 +561,25 @@ 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) + interpreted = _case_interpretation(normalized, immutable, context.hrp, 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) + and len(candidate.artifact.text) != _text_length(byte_length) ): raise _UsageError("--bytes does not match the corrected master-seed backup length.") else: @@ -687,7 +648,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 +657,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 39fc2627..c751038b 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]: @@ -513,17 +513,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) @@ -653,13 +651,13 @@ def _linear_error_corrections( 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 CodexError: @@ -708,9 +706,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): 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..3c4cec5e 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -11,11 +11,17 @@ from codex32._bip32 import _valid_root from codex32.bech32 import CHARSET, _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, ) @@ -28,16 +34,6 @@ 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:]) @@ -118,7 +114,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 @@ -205,7 +201,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, @@ -270,7 +266,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.""" diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a63698..a28f982d 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..4d1b3bf1 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -18,9 +18,8 @@ identifier_origin, parse_fingerprint, ) -from codex32.bip93 import parse_codex32 +from codex32.bip93 import MasterSeed, parse_codex32 from codex32.generation import _fingerprint_identifier -from codex32.profiles.ms32 import MasterSeed _parsed = parse_codex32("ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw") assert isinstance(_parsed, MasterSeed) diff --git a/tests/test_cli.py b/tests/test_cli.py index 395f4fe4..09851437 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 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 @@ -949,7 +928,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( @@ -1830,7 +1809,7 @@ def test_automatic_target_selection_covers_midpoints_and_supported_lengths() -> from codex32._cli_input import _correction_plan for observed in range(40, 136): - targets = _correction_plan(Profile.MS, None, observed, None) + targets = _correction_plan("ms", None, observed, None) expected = 48 if observed <= 61 else 74 if observed <= 100 else 127 assert targets[0] == expected @@ -1866,7 +1845,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 @@ -2462,7 +2441,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), @@ -2737,10 +2716,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 @@ -2749,12 +2728,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"]) @@ -2768,12 +2745,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 @@ -2797,7 +2769,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 @@ -2820,17 +2792,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): @@ -2847,7 +2816,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 @@ -2896,7 +2865,7 @@ 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)): @@ -3012,11 +2981,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): @@ -3039,7 +3006,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..896fb4c8 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -2,7 +2,6 @@ import pytest -from codex32 import Profile from codex32._cli_input import InputError, _parse, _prefixed_input_error from codex32.errors import InvalidChecksum, InvalidLength @@ -28,7 +27,7 @@ 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: @@ -38,10 +37,10 @@ def test_generic_length_profile_length_and_checksum_diagnostics(length, 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,)) + _parse(value, "ms") assert _prefixed_input_error(failure.value, value, "", []) == str(failure.value) 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..ced1bb72 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 @@ -342,7 +342,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 +351,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 d15c6225..e5ee5eca 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 import CorrectionContext, MasterSeed, correct from codex32._cli_input import _correction_plan, _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)) @@ -156,8 +155,7 @@ def test_automatic_secondary_search_recovers_two_group_indels( 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) + CorrectionContext("ms", target, "ms1") for target in _correction_plan("ms", None, len(damaged), None) ) candidates, complete = _search_many( @@ -216,7 +214,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 +325,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 +351,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 +376,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 +448,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 +481,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") @@ -514,7 +507,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) @@ -560,5 +553,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..de1ca7c5 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -19,6 +19,7 @@ parse_codex32, recover_secret, ) +from codex32.bip93 import SEED_BYTE_LENGTHS, _has_generation_padding from codex32.errors import ( CeremonyStateError, CodexError, @@ -28,7 +29,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 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 index f812096f..2523329e 100644 --- a/tests/test_mixed_case_deadline.py +++ b/tests/test_mixed_case_deadline.py @@ -5,7 +5,6 @@ import pytest -from codex32 import Profile from codex32._cli_input import _case_interpretation, _correction_candidates, _scheduled_candidates SOURCE = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" @@ -24,7 +23,7 @@ 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) + interpretation = _case_interpretation(damaged, "", "ms", None) assert interpretation is not None direct, normalized, erased, _prefix = interpretation @@ -32,7 +31,7 @@ def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline 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 + value, "ms", None, immutable, deadline=monotonic() + 10, required_only=True ) assert alone_complete assert [candidate.artifact.text for candidate in alone] == ([SOURCE] if fixes else []) @@ -40,7 +39,7 @@ def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline candidates, complete = _scheduled_candidates( normalized, erased, - Profile.MS, + "ms", None, immutable, deadline=monotonic() + 10, @@ -54,12 +53,12 @@ def test_embedded_mixed_case_recovers_either_sole_interpretation_within_deadline def test_scheduled_truncation_survives_a_complete_later_pass(monkeypatch: pytest.MonkeyPatch) -> None: damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw" - interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) + interpretation = _case_interpretation(damaged, "", "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 + erased, "ms", None, immutable, deadline=monotonic() + 10, required_only=True ) full_searches: list[str] = [] @@ -74,7 +73,7 @@ def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[objec monkeypatch.setattr("codex32._cli_input._correction_candidates", truncated_first) - candidates, complete = _scheduled_candidates(normalized, erased, Profile.MS, None, immutable) + candidates, complete = _scheduled_candidates(normalized, erased, "ms", None, immutable) assert full_searches == [erased, normalized] assert not complete diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 70106c53..75a90e34 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") @@ -107,7 +106,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..570ce52a 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))) @@ -197,18 +194,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..bb5bc6da 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. diff --git a/tools/correction_benchmark.py b/tools/correction_benchmark.py index c8d48421..2b4dcf32 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()