diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7edcac9f..675aa936 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,26 +1,57 @@ name: Publish release on: - release: - types: [published] + push: + tags: + - "v*" env: SOURCE_DATE_EPOCH: "1763060600" + BITCOIN_CORE_ARCHIVE: bitcoin-32.0rc2-x86_64-linux-gnu.tar.gz + BITCOIN_CORE_SHA256: 0255103718033e6aee15fa944717fc277e047b845bff1e7408af0ea732d8d0c1 + BITCOIN_CORE_URL: https://bitcoincore.org/bin/bitcoin-core-32.0/test.rc2 + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest permissions: - contents: write + contents: read + outputs: + python_versions: ${{ steps.metadata.outputs.python_versions }} + version: ${{ steps.metadata.outputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - - run: >- - python -c "import os,tomllib; - version=tomllib.load(open('pyproject.toml','rb'))['project']['version']; - assert os.environ['GITHUB_REF_NAME'] == f'v{version}'" + - id: metadata + name: Check release metadata + run: | + python - <<'PY' + import json + import os + import tomllib + + with open("pyproject.toml", "rb") as file: + project = tomllib.load(file)["project"] + version = project["version"] + if os.environ["GITHUB_REF_NAME"] != f"v{version}": + raise SystemExit("release tag does not match package version") + prefix = "Programming Language :: Python :: " + versions = sorted( + classifier.removeprefix(prefix) + for classifier in project["classifiers"] + if classifier.startswith(prefix) and classifier.removeprefix(prefix).count(".") == 1 + ) + if not versions: + raise SystemExit("no supported Python versions are classified") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={version}\n") + output.write(f"python_versions={json.dumps(versions, separators=(',', ':'))}\n") + PY - run: >- python -m pip install --require-hashes -r requirements/release-build-dependencies.txt @@ -33,28 +64,213 @@ jobs: tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \ -C normalized-sdist -cf - "codex32-${GITHUB_REF_NAME#v}" | gzip -n > "$archive.new" mv "$archive.new" "$archive" - - name: Attach distributions to the GitHub release - run: gh release upload "$GITHUB_REF_NAME" dist/* - env: - GH_TOKEN: ${{ github.token }} + - name: Record built artifact provenance + run: | + mkdir provenance + git rev-parse HEAD > provenance/source-commit.txt + sha256sum dist/* > provenance/artifact-sha256.txt + python -m pip freeze --all > provenance/build-environment.txt - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: distributions path: dist/ + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: build-provenance + path: provenance/ - publish: + supported-matrix: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ${{ fromJSON(needs.build.outputs.python_versions) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - name: Install legacy wallet-test oracle when present + shell: bash + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -m pip check + - run: python -m pytest -q + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install and test the exact wheel and sdist + shell: bash + run: | + for artifact in dist/codex32-*.whl dist/codex32-*.tar.gz; do + python tools/verify_wheel_environment.py --artifact "$artifact" + done + - name: Record qualification dependencies + shell: bash + run: >- + python -m pip freeze --all > + "qualification-${RUNNER_OS}-${{ matrix.python-version }}.txt" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualification-${{ runner.os }}-${{ matrix.python-version }} + path: qualification-*.txt + + quality: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - name: Install legacy wallet-test oracle when present + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -O -m pytest -q + - run: python -m mypy src/codex32 + - run: python -m ruff check . + - run: python -m ruff format --check . + - run: python tools/differential_correction.py --verify + - name: Run legacy wallet differential when present + run: | + if [[ -f tools/differential_wallet.py ]]; then + python tools/differential_wallet.py --verify + fi + + bitcoin-core: needs: build runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install qualified wheel + run: python -m pip install --no-deps dist/codex32-*.whl + - name: Download pinned Bitcoin Core + run: | + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" \ + "$BITCOIN_CORE_URL/$BITCOIN_CORE_ARCHIVE" + echo "$BITCOIN_CORE_SHA256 $RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" -C "$RUNNER_TEMP" + - name: Run real-Core release integration + env: + PYTHONNOUSERSITE: "1" + run: | + core="$RUNNER_TEMP/bitcoin-32.0rc2/bin" + "$core/bitcoind" --version | head -1 | tee bitcoin-core-version.txt + python tools/bitcoin_core_regtest.py \ + --bitcoind "$core/bitcoind" \ + --bitcoin-cli "$core/bitcoin-cli" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bitcoin-core-provenance + path: bitcoin-core-version.txt + + publish: + needs: [build, supported-matrix, quality, bitcoin-core] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/codex32 permissions: - contents: read + contents: write id-token: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: distributions path: dist/ - - name: Publish distributions to PyPI + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: build-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: bitcoin-core-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: qualification-* + path: provenance/dependencies/ + merge-multiple: true + - name: Verify qualified artifact bytes + run: sha256sum --check provenance/artifact-sha256.txt + - name: Record release qualification + env: + RELEASE_VERSION: ${{ needs.build.outputs.version }} + MATRIX_RESULT: ${{ needs.supported-matrix.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + CORE_RESULT: ${{ needs.bitcoin-core.result }} + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + hashes = {} + for line in Path("provenance/artifact-sha256.txt").read_text().splitlines(): + digest, path = line.split(maxsplit=1) + hashes[Path(path).name] = digest + record = { + "source_commit": Path("provenance/source-commit.txt").read_text().strip(), + "tag": os.environ["GITHUB_REF_NAME"], + "version": os.environ["RELEASE_VERSION"], + "bitcoin_core": Path("provenance/bitcoin-core-version.txt").read_text().strip(), + "artifact_sha256": hashes, + "qualification": { + "supported_matrix": os.environ["MATRIX_RESULT"], + "quality": os.environ["QUALITY_RESULT"], + "bitcoin_core": os.environ["CORE_RESULT"], + }, + } + Path("provenance/release-qualification.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Bundle release provenance + run: >- + tar -czf "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + -C provenance . + - name: Stage draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact="codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + if draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft 2>/dev/null)"; then + [ "$draft" = true ] || { echo "release is already public" >&2; exit 1; } + gh release upload "$GITHUB_REF_NAME" dist/* "$artifact" --clobber + else + gh release create "$GITHUB_REF_NAME" --draft --verify-tag \ + --title "$GITHUB_REF_NAME" --generate-notes dist/* "$artifact" + fi + - name: Publish qualified distributions to PyPI uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + with: + packages-dir: dist/ + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/SECURITY.md b/SECURITY.md index 7514e1b7..c540b668 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,15 @@ # Security -codex32 handles wallet recovery material. Use a trusted computer and have the +codex32 handles wallet recovery material. A **trusted computer** is under your +exclusive control, is not known or suspected to be compromised, and runs an +operating system and other software you trust for the operation. For wallet +work, that trusted software includes Python, codex32, the terminal, +`bitcoin-cli`, Bitcoin Core, and their relevant configuration. Wallet encryption, +application permissions, and RPC authentication do not make a compromised +computer trusted. Have the software and recovery plan reviewed before relying on it with funds. For -stronger isolation, use codex32 and the Bitcoin Core signing wallet only on a -computer kept permanently offline. +stronger isolation, keep the trusted computer disconnected from every network +before, while, and after it handles private recovery or signing material. A valid checksum detects many copying mistakes. It does not prove that a backup belongs to your wallet. A correction is only a suggestion; compare it with the @@ -11,10 +17,16 @@ physical backup and wallet information kept elsewhere. Python, your terminal, and your operating system may retain secret text in memory or scrollback. codex32 does not intentionally save secrets and keeps -them out of command arguments and normal machine output, but it cannot -guarantee that every copy is erased from swap, hibernation data, or crash -dumps. Using Tails and shutting down when finished helps mitigate this Python -limitation. +them out of command arguments, logs, and unrelated output. Commands that create, +recover, derive, correct, or explicitly export recovery material intentionally +display it when that is their purpose. codex32 cannot guarantee that every copy +is erased from swap, hibernation data, or crash dumps. Using Tails and shutting +down when finished helps mitigate this Python limitation. + +Run a command first and enter recovery material at its prompt, or redirect its +standard input from a separately protected source. Do not embed recovery text in +shell command text: shell history, terminal logging, wrappers, and process +tooling may retain it even though codex32 never receives it as an argument. ## Report a security problem diff --git a/docs/audits/security-2026-09-22.md b/docs/audits/security-2026-09-22.md new file mode 100644 index 00000000..2b48cdf9 --- /dev/null +++ b/docs/audits/security-2026-09-22.md @@ -0,0 +1,213 @@ +# Security audit verdict — 2026-09-22 + +Scope: python-codex32 at +[`c118a834fc9cce95ead3c05b3a2532e1320980c5`](https://github.com/BenWestgate/python-codex32/commit/c118a834fc9cce95ead3c05b3a2532e1320980c5), +the graphical wallet flow at +[`78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2`](https://github.com/BenWestgate/python-codex32/commit/78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2) +and its current descendant +[`8f1001f61983cf60023120af4b01bbc99d6f3a84`](https://github.com/BenWestgate/python-codex32/commit/8f1001f61983cf60023120af4b01bbc99d6f3a84), +and the BIP93/Codex32 assumptions those paths rely on. + +This document records the security-review result. It is not a replacement for +the normative [security model](../security/model.md) or +[security invariants](../security/invariants.md). + +## Verdict + +The core implementation is careful about parsing, checksum validation, +reconstruction, Bitcoin Core process boundaries, and secret transfer. No break +of the Codex32 checksum, finite-field reconstruction, OS-CSPRNG use, or BIP32 +root validation was found. + +The audit did find two medium library implementation defects, one low +interoperability/specification-divergence defect, and a wallet recovery ordering +defect. It also confirmed two properties of BIP93 that applications must not +mistake for cryptographic authentication: the 20-bit set identifier does not +prevent cross-set mixing, and checksum-valid shares do not authenticate the +intended reconstructed secret against active substitution. + +The release-gate rule for callers is therefore stricter than “valid Codex32 +shares reconstruct a valid seed”: before recovered key material mutates a live +wallet, the operator must establish that the recovered seed is the wallet they +intend using the strongest independent evidence they actually have. For the +current single-sig restore flow this is an accident-safety check: normally a +typed wallet-record fingerprint; when no independent record exists, the product +instead makes that absence explicit and requires a last-resort visual +confirmation before the operator authorizes the restore. That no-record path is +not independent authentication of the recovered seed. Stronger resistance to +malicious share/seed replacement is a separate problem, tracked as an +authenticated encrypted descriptor backup in issue #55. + +## Confirmed implementation findings + +### Medium — unbounded correction alignment cache + +Tracking: [#21](https://github.com/BenWestgate/python-codex32/issues/21). + +- Affected component: `src/codex32/correction.py`, `_syndrome_alignment`. +- Failure scenario: caller-controlled HRP/length combinations create distinct + entries in a process-global unbounded cache. A long-lived GUI or service can + retain alignment tables monotonically. +- Property violated: bounded resource use for untrusted correction input. +- Exploitability: practical for long-lived processes. A local reproduction with + 50 attacker-distinct HRP keys retained about 5.66 MiB, approximately 113 KiB + per key in that run. +- Remediation: remove attacker-specific global caching or use a small bounded + LRU with lifecycle clearing; add churn tests that assert a hard bound. +- Confidence: high. + +### Medium — secret-bearing default string representations + +Tracking: [#22](https://github.com/BenWestgate/python-codex32/issues/22). + +- Affected component: artifact string/repr paths and nested correction + candidates. +- Failure scenario: `_Artifact.__str__` returns the complete Codex32 + secret/share text, while default object representations can retain or expose + secret-bearing fields. Generic logging, notebooks, REPLs, assertion output, + or exception context can persist recovery material unexpectedly. +- Property violated: secrets stay out of ordinary output and logs unless an + explicit reveal/export operation is requested. +- Exploitability: practical accidental disclosure; no attacker is required once + a secret-bearing object reaches generic diagnostics. +- Remediation: make normal `str`/`repr` redacted and require an explicit + reveal/export operation for the full Codex32 string; test nested containers + and correction candidates. +- Confidence: high. + +### Low — overlong BIP173 human-readable parts are accepted + +Tracking: [#32](https://github.com/BenWestgate/python-codex32/issues/32). + +- Affected component: `src/codex32/bip93.py` decoder and public correction + contexts. +- Failure scenario: an otherwise checksum-valid Codex32 string with an HRP of + 84 or more characters is accepted even though BIP173 limits the HRP to 83 + characters. The same invalid namespace can be admitted to correction setup. +- Property violated: accepted encodings must remain inside the normative + BIP173/BIP93 grammar so independent implementations agree on validity. +- Exploitability: practical interoperability failure, not a secret compromise. + A concrete 84-character-HRP string was accepted during the audit. +- Remediation: reject HRPs longer than 83 characters in decoding and correction + context validation; cover the 83/84-character boundary in tests. +- Confidence: high. + +### Medium — wallet restore verifies recovered identity after mutation + +Tracking: GUI issue #26; CLI issue #30. + +- Affected component: GUI recovery in `src/codex32_gui/pages.py`, CLI recovery + in `src/codex32/cli.py`, and the shared mutating wallet initialization path. +- Failure scenario: both supported restore front ends mutate Core before the + recovered wallet identity is checked. The GUI calls `wallet_setup.fill(...)` + before it computes and displays the identity. `ms32 wallet` calls + `core.initialize(...)` before displaying the recovered fingerprint. A wrong + but checksum-valid recovered seed can therefore import private descriptors + into the selected empty Bitcoin Core wallet before the operator can detect + the mismatch. +- Property violated: the operator must identify the intended wallet before + recovered key material changes durable wallet state. +- Exploitability: practical for accidental wrong/mixed cards, miscorrection, or + another valid-but-unintended recovery. Deliberate threshold-share replacement + is a separate threat because an attacker who can replace a threshold can + already learn the seed; issue #55 tracks authenticated descriptor evidence for + that stronger threat model. +- Remediation: on every restore path, derive wallet identity without importing + descriptors. Prefer a verified encrypted descriptor backup when available; + otherwise require a matching wallet-record fingerprint (and later optional + single-sig descriptor checksum), or make the no-record visual confirmation an + explicit operator choice. Only then call the mutating wallet initialization + path. +- Confidence: high. Reconfirmed against the Bails-pinned GUI commit, the current + GUI descendant, and the supported `ms32 wallet` CLI path. + +### Threat-model disposition — 32-bit fingerprint is not a malicious-tampering defense + +Tracking: closed issue #27; stronger descriptor authentication is issue #55. + +- Affected component: wallet record/recovery identity design across restore + front ends. +- Observation: the four-byte BIP32 master fingerprint is intentionally a + human-scale identifier. A party choosing substitute seeds can grind a matching + value; the 20-bit backup identifier is weaker still. +- Release-gate disposition: this does not invalidate the fingerprint as an + accident-safety gate. Its job in #28/#57 is to catch wrong/mixed cards, + miscorrection and transcription mistakes before Core is changed. It is not + represented as authorization against a malicious party able to replace a + threshold of shares. +- Stronger design: issue #55 authenticates the full single-sig descriptor backup + with material derived from the recovered seed, verifies that the recovered + seed reproduces those descriptors before wallet mutation, and relies on + independently stored copies of that backup for tamper resistance. +- Human record follow-up: #43 adds checksummed/type-back record fields and may + accept a canonical single-sig descriptor checksum as another second-class + accident check. +- Confidence: high on the distinction between the two threat models; the exact + encrypted-backup format remains design work in #55. + +## Confirmed BIP93/Codex32 application hazards + +These are protocol properties, not evidence that python-codex32 implemented the +specified arithmetic incorrectly. + +The current BIP93 text already states that identifiers are for disambiguation +and that the checksum does not protect against maliciously constructed errors. +These findings therefore require application-side enforcement and documentation, +not an upstream BIP93 vulnerability report. + +### Medium — 20-bit set identifier does not prevent cross-set mixing + +- Affected specification: BIP93 set identifier and threshold recovery model. +- Failure scenario: shares from two valid sets with the same compatible header + can be combined into the required threshold and interpolate a third seed that + is checksum-valid. The audit reproduced this behavior. +- Property violated: if an application assumes the identifier authenticates + set membership, that assumption fails. +- Exploitability: practical once shares from compatible colliding/matched sets + are available. The 20-bit identifier is a typo/mix-up aid, not a + cryptographic set commitment. +- Remediation: for accidental mix-ups, require the operator to identify the + intended wallet before import using the strongest evidence they actually + possess. Do not describe the identifier itself as authentication. +- Confidence: high. + +### Medium — valid-share substitution can force an attacker-chosen reconstruction + +- Affected specification: unauthenticated threshold-share model. +- Failure scenario: an attacker who can replace enough shares can construct + checksum-valid replacement shares that make the victim reconstruct a chosen + target secret. The audit reproduced this for a 2-of-2 set. +- Property violated: share integrity/provenance and intended-secret + authentication when active substitution is in scope. +- Exploitability: practical when the attacker can replace the required share + material. This is not a secrecy break; such an attacker can already recover + the original seed from the threshold material they control. +- Remediation: keep the release-gate fingerprint/no-record UX scoped to accident + safety. Where malicious substitution is in scope, authenticate the complete + descriptor backup independently as designed in #55 and keep copies apart from + the recovery cards. +- Confidence: high. + +## Falsified candidate + +The earlier “opaque-HRP deadline bypass” candidate was falsified during review +and is not a security finding. It must not be cited or tracked as one. + +## Required recovery invariant + +For every wallet restore path built on python-codex32: + +> Before any recovered key material mutates a wallet, the operator must establish +> the intended wallet using the strongest independent evidence they actually +> have: a verified seed-keyed descriptor backup when available; otherwise a +> matching wallet-record fingerprint and/or canonical single-sig descriptor +> checksum. If no independent wallet record exists, the restore must state that +> identity cannot be authenticated and require an explicit last-resort visual +> confirmation of the recovered fingerprint and codex32/Bails identifier result +> before the operator authorizes mutation. + +The fingerprint and descriptor-checksum paths are human-scale accident-safety +checks. The no-record path is an explicit accident-safety waiver/authorization, +not independent authentication evidence. Malicious share/seed replacement is a +separate threat model and uses the authenticated encrypted descriptor backup +tracked in #55. \ No newline at end of file diff --git a/docs/developer/api.md b/docs/developer/api.md index 9dbdc32a..d3940247 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -77,8 +77,9 @@ generic parse-length failure. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. - Canonical text removes whitespace for comparison; presentation state retains - entered spacing and case. Grouped alignment preserves entered ownership; + Canonical text removes whitespace and folds ASCII case for comparison; + non-ASCII lookalikes remain mismatches. Presentation state retains entered + spacing and case. Grouped alignment preserves entered ownership; unspaced alignment minimizes character edits before disturbed groups. Codex32 entry uses a separate `> ` line; correction candidates use ordinary card formatting without a prompt marker; fixed prefixes follow that marker. The `xprv` and wallet recovery paths begin @@ -100,16 +101,72 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 23-name package `__all__`; direct use of private helpers is -unsupported but remains in the review scope. +top-level surface is the 23-name package `__all__`; direct use of private helpers +is unsupported but remains in the review scope. + +### Reference-vector construction API + +Reference-vector authors may use the following module-level interfaces without +depending on implementation-private names: + +- `codex32.bech32`: `CHARSET`, `bech32_decode`, `bech32_encode`, + `bech32_hrp_expand`, `chars_to_u5`, and `u5_to_chars`; +- `codex32.checksums`: `Checksum`, `CODEX32`, and `CODEX32_LONG`; +- `codex32.bip93`: `checksum_for_body_length` and + `checksum_for_encoded_length`. + +For example, a generic codex32 vector can be constructed without importing an +underscore-prefixed name: + +```python +from codex32.bech32 import bech32_encode, chars_to_u5 +from codex32.bip93 import checksum_for_body_length + +hrp = "zz" +body = chars_to_u5("0tests" + "q" * 26) +checksum = checksum_for_body_length(hrp, len(body)) +text = bech32_encode(hrp, body, checksum) +``` + +These are supported module interfaces for codec and vector work; they are not +added to the package-level `codex32.__all__`, whose backup/recovery surface stays +deliberately narrow. + +The remaining production cross-module private imports are intentional internal +couplings rather than user-facing APIs: + +- `_alignment.py`, `_competitors.py`, `correction.py`, and `indel.py` form one + bounded correction engine. Their underscored search state, views, and pruning + helpers are exchanged only inside that engine. +- `bip93.py` and `correction.py` use the shared private GF(32) arithmetic in + `gf32.py`; profile factories use `_from_parts` and profile-rule helpers to + preserve one artifact-validation path without publishing construction hooks. +- `generation.py`, `wallet.py`, and `_bitcoin_core.py` share the private BIP32 + root primitives. `_bitcoin_core.py` also consumes wallet descriptor records + while remaining the sole process/state adapter. +- `_cli_input.py` and `cli.py` share private correction/profile orchestration, + input state, and parser plumbing. Those names exist to compose the CLI, not as + a second domain API. + +Internal benchmark and verification tools may import those implementation names +when they are explicitly testing the implementation itself. Tools or examples +that model external vector construction use the supported module interfaces +above. ### Size budget V1 keeps the installed package below 5,200 logical review lines, excluding -blank and comment-only lines while counting subpackages recursively. Changing +blank and comment-only lines while counting subpackages recursively, including +docstrings. The enforcement is +`tests/test_cli.py::test_production_size_budgets_are_enforced`. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. +Public input checks use explicit exceptions. Remaining production `assert` +statements narrow internal types or check state already established by validation; +they are not input or wallet-identity gates. CI runs the suite under `python -O` +as well as normally, so input rejection must continue to work with asserts removed. + ## Profile and opaque-HRP capabilities There is no runtime registration. An unknown HRP uses generic codex32 rules and @@ -682,6 +739,7 @@ These choices are not presented as BIP93 requirements. | Decision | Reason | |---|---| +| reject threshold digit `1`, including for S | follows the container's `0` or `2`–`9` header grammar; deliberately stricter than the reference decoder's ignored-threshold rule for unshared secrets | | `ms` accepts only six seed sizes | follows the frozen PR #2258 profile with no legacy decoder | | random electronic output indices | reduces canonical index disclosure; explicit indices preserve requested order | | generation-only CRC padding | small recovery hint; not validity or share semantics | diff --git a/docs/security/adversarial-2026-10-04.md b/docs/security/adversarial-2026-10-04.md new file mode 100644 index 00000000..9a958aa0 --- /dev/null +++ b/docs/security/adversarial-2026-10-04.md @@ -0,0 +1,169 @@ +# Supplied adversarial reviews: finding disposition + +This is a traceability snapshot, not a release approval. The supplied reviews +examined `reviewability-v1` at `c118a83`; the branch now points to +`5325eb7543e8545b321b0512b38c896b1e269ece`. Unmerged fixes are identified by PR, +not by similarly named sibling branches. Issue +[#20](https://github.com/BenWestgate/python-codex32/issues/20) tracks audit closure; +[#38](https://github.com/BenWestgate/python-codex32/issues/38) owns integration and +the final reviewer handoff. + +## Inputs and qualifications + +The four supplied files are identified by their SHA-256 hashes. D means DeepSeek, +G means GLM, K means Kimi, and C means the consolidated report. + +| File | SHA-256 | +|---|---| +| `codex32-review-DeepSeek_V4.1_Flash.md` | `94398728d93643e609987930c7fb38ea37b828fab9071d61aa1c255e5316450a` | +| `codex32-review-GLM5.3_Flash.md` | `01569d9c3a6fbd1aae6f3a92d523ce9cafb50a66d16c56e91b1ff87030f5af11` | +| `codex32-review-KimiK3_High.md` | `139ac9366b6f4c72f33b3d17e649e88d68e266c19345102084883aa850ab2977` | +| `consolidated-review.md` | `458e647b7cd14f27f71809fdc1b240c8fd4b3cfbfb06e2005b0eb0a4d6767ea9` | + +The originals qualify several claims more narrowly than the consolidation: + +- Kimi calls the Core requirement consistent with the threat model and asks for + a documentation hint. GLM recommends a warning-only fallback. The maintainer + chose Core-backed fingerprint checks for `ms32`; generic `codex32` is the + offline fallback. This is an intentional boundary with a UX/documentation gap. +- Kimi reviewed live Core and GUI paths statically. Its restore recommendation + exceeded the then-current operator-comparison contract. The accepted release + requirement is now verification before mutation for accident safety, using + an independent record or the explicit no-record confirmation. Malicious share + replacement and encrypted descriptors remain separate human-planned work in + [#55](https://github.com/BenWestgate/python-codex32/issues/55). +- Kimi distinguishes accidental `repr()` disclosure from deliberate `str()` + export. The accepted project contract redacts both; `.text` is explicit export. +- The consolidation's shared-baseline table does not prove each original ran + every listed command. In particular, only Kimi explicitly records the BCH + constant verifier. Historical reviewer claims are not new validation evidence. +- The consolidation calls the setup defect independently found by two reviews + in its summary, although all three originals contain it. +- GLM's secondary dependency-install ordering claim did not reproduce with pip's + normal build isolation. The Astra-placeholder claim is stale. Neither justifies + a new defect issue; the unwanted repository configuration was removed anyway. + +## Numbered findings + +“Merged” below means present on the branch identified above. “PR” means a focused +patch exists but still needs human integration. Duplicate IDs share one row. + +| Consolidated ID | Original sources | Assessment and current coverage | +|---|---|---| +| BL1 | D-B1, G-1, K-F4 | Confirmed developer-install defect; #3/#6 and merged #7 remove the test-only `bip32`/secp256k1 dependency. #115 corrects contributor setup. | +| BL2 | D-B2 | Confirmed missing BSD notice; merged #15 includes `LICENSES` in wheel and sdist. | +| H1 | K-F2 | Confirmed artifact default-rendering leak; #22 and merged #25 redact artifact `str()` and `repr()`, closing K-F2's complete-secret disclosure. A later, narrower correction-edit rendering issue is tracked separately below. | +| H2 | K-F3 | Accepted accident-safety gate; #26/#30, CLI #57/#80/#81, GUI #118. #81 verifies existing seeds before replacement cards; #80 distinguishes an unavailable Bails check. #55 is a separate planning issue, not a missing v1 PR. | +| H3 | K-F1 | Confirmed HRP-boundary defect; #32/#33 enforce 83 characters in parsing and correction contexts. Generalized-HRP draft vectors at `BenWestgate/bips` PR #2, commit `01374bf`, explicitly allow 83 and reject 84; 1023 is the expanded-codeword bound. | +| M1 | G-2 | Confirmed standalone mixed-case regression; #37 and merged #42, including the required-preflight seed preservation follow-up. Interactive recovery already interpreted case. | +| M2 | G-3, K-F9 | Intentional Core boundary; #99 fixes misleading Core diagnostics/preflight. This change documents the generic offline fallback. Do not introduce Python secp256k1 or silently discard fingerprints. | +| M3 | K-F6 | Intended switch to a supplied complete S, with missing notice; #95 announces it after partial share entry. Rejecting S would change the chosen behavior. | +| M4 | D-D4, G-4 | Confirmed review-process gap; #38 requires the final `docs/developer/reviewing.md`, pinned candidate, reading order, reproducible evidence, GUI scope, and human integration order. This snapshot supplies finding provenance but does not replace that frozen-tip handoff. | +| M5 | D-D3 | Confirmed inherited contributor boilerplate; focused #115 replaces it and restores licensing/attribution terms. | +| M6 | D-D1 | Confirmed historical measurements presented as current; focused #116 labels the `6802d86` snapshot and raw evidence. Re-running a benchmark cannot reproduce that host's old measurements. | +| M7 | D process notes, K-F5 | Optimized tests and constant verification are in merged #47/#40. Dormant fuzz harnesses remain deferred until an engine, corpus, and budget are selected; no claimed fuzzing result is inferred from their presence. | +| M8 | K-F7 | Partially fixed by merged #16 for supplied raw bytes. The existing-secret ceremony still bypassed root validation; #125 and focused PR #126 cover the remaining API path. Parsing remains format validation; CL secrets have no BIP32 requirement. | +| M9 | K-F8 | Intentional stricter threshold grammar: `1` is rejected even for S. The API guide now records this reference-decoder divergence. | +| L1 | D-C3 | Confirmed exit-status collision; #39 and merged #45 distinguish valid `0`, emitted suggestion `1`, usage/input syntax `2`, and no usable suggestion `3`. Suggestions remain nonzero. | +| L2 | D-C1/C2, G-6 | Confirmed unreachable guards/always-false field; merged #46 removes the original findings. #105 removes the later redundant recovery/search paths. | +| L3 | D-C5 | No demonstrated input-validation bypass: current asserts narrow internal types/state after explicit validation. The API guide states the boundary; optimized CI guards it. No blanket assert replacement is warranted. | +| L4 | D-D6 | Intentional historical benchmark evidence, retained by merged #14; #116 labels its age and limits. No fresh timing guarantee follows from it. | +| L5 | D process notes, K-F9 | Maintainer-authorized library cap is `<5200`; GUI is separately `<2250` in #118. The API guide defines recursive nonblank/non-comment counting, including docstrings, and names the enforcement test. | +| L6 | D-D5 | Human integration/authorship work in #38. Preserve dependent PR history until the maintainer settles the stack and performs any required rewrite/squash. | + +## Nits and secondary claims + +| Finding | Sources | Disposition | +|---|---|---| +| Package export count says 25 instead of 24 | D-D2 | #130 supersedes #64's API cleanup by removing obsolete `core_descriptors` plus two unused CL-generation exports; if integrated, package-level `__all__` contains 22 names. #53's supported reference-vector helpers remain module-level rather than package exports. | +| Test fake accepts obsolete `private=` | D-C4 | One-line #117 makes `_FakeBitcoinCore.initialize` match the production call shape. | +| Stale `MANIFEST.in` provenance exclusion | D notes, K-F9 | Already removed on the current branch (`d6a9f99`); keep `MANIFEST.in` because it still selects license, test, tool, and documentation files for the sdist. | +| Ignored provenance/plans | D notes | Intentional local-only unfinished work stays under the ignored `docs/planning/` path. This finished security audit ledger lives in `docs/security/`; #23 records the earlier audit verdict. #38 links the local cleanup checklist for agents. | +| Beta classifier with an rc version | G-6 | A maturity classifier and prerelease version are separate metadata; no demonstrated defect. Final stable metadata belongs to artifact qualification #5/#52. | +| Extra blank line in `pyproject.toml` | G-6 | No longer present on the current branch. | +| Repository Codex overrides | D notes | #41 and merged #48 remove the configuration. Whether the old named model exists is no longer relevant to the fix. | +| Core RC documentation links | D notes | #93 points to the maintained offline-signing tutorial, as chosen by the maintainer. Stable/signed-RC monitoring is separate from audit closure. | +| Budget-test discoverability | K-F9 | Direct test pointer added to the API guide; #38's final handoff must also name both library and GUI checks. No test-file move is needed. | +| User-facing vector helpers require underscores | G-5 | #49/#53 expose the required codec/checksum helpers at their owning modules and document internal couplings. Further architecture work is explicitly separate. | +| Stale/overlapping branch names | D notes | Use PR numbers and commit IDs as provenance. Pruning branches is housekeeping and does not establish or invalidate a fix. | +| Dependency-install ordering | G-1 secondary note, C rejected claims | Not reproduced under ordinary pip build isolation; no new issue. | + +## Post-audit follow-ups + +These were found after the four supplied reports and are not retroactively +attributed to those reviewers. They remain release-gate work because they touch +the same reviewed security/API surface: + +- #128 / focused #130: the unused public `core_descriptors(..., private=False)` + path derived descriptors from the named Core wallet rather than authenticating + them to the supplied seed. Removing the unused pre-1.0 API also supersedes the + narrower #64 cleanup. +- #129 / focused #132: `CorrectionEdit`'s generated dataclass representation + disclosed observed/replacement characters when a correction candidate was + logged. This is narrower than K-F2's complete-artifact disclosure, but it + violates the same accidental-rendering boundary. #132 excludes only those two + fields from default rendering while preserving explicit field access and + correction behavior. + +## Verification and remaining release work + +On 2026-10-05, GitHub reported successful exact-head checks and current-head +Codex or disclosed AI-assisted reviews for #12/#13/#33, +#52/#53/#57/#59, #93/#97/#99/#105/#115/#116/#117/#126, and the focused GUI +#66/#77/#78/#118/#119 changes. These are not human approvals or proof of an +independent Claude review. The PR #65 parent has a real open refresh-mode +finding fixed by stacked #66; review and integrate them together. GitHub's +“blocked” status can also reflect signature/authorship or branch rules even when +checks pass. + +The post-audit fixes are also mechanically ready for human review. #130 head +`2cbd2793ae` has a green Python matrix and Bitcoin Core fixture; its one Codex +documentation finding was corrected and resolved, and Codex then reported no +major issue on that exact head. #132 head `f64da6f9d9` has a green Python matrix +and an exact-head Codex no-major-issue review. Its focused local verification +also passed 903 tests normally and under `python -O`, Ruff check/format, strict +mypy, correction-constant re-derivation, and all 57 frozen differential cases. +These reviews do not replace responsible-human authorship or integration. + +The restore/Core line is now mechanically refreshed in release order as +#57 → #105 → #99 → #80 (`3a35463`) → #81 (`b2aafde`) → #95 (`5e44dcb`). The +#80/#81/#95 focused behavior was replayed without design changes. GitHub's +current-head matrices are green for all three, the #80 Core fixture is green, +and Codex reported no major issue on each exact refreshed head. A disposable +Python 3.14 environment installed `.[dev]` at the refreshed #95 tip with no +Python `bip32` dependency; all 941 tests passed normally and all 941 passed under +`python -O`. Ruff check/format, strict mypy, correction-constant verification, +and `git diff --check` also pass on that composed tip. + +#115 head `2e947b12` corrects the final shell-specific Windows activation nit; +its exact-head GitHub checks are green and Codex found no further content issue. +Its remaining unresolved review item is the repository's responsible-human +authorship requirement, which automation cannot satisfy. + +Focused local checks at #103 commit `2924f5f` passed for default rendering, +record mismatch before wallet calls, hidden fingerprint, recordless decline and +acceptance, fresh creation, existing creation, and supplied-byte root rejection. +The complete PR #103 security diff scan at behavioral parent `d9ce204`, against +`115f2c2`, found no reportable finding; its later commit changes documentation +only. These checks do not prove the later existing-secret API root path fixed. + +#93 and #97 were updated after new reviews: the offline boot USB is distinct +from the PSBT transfer medium; the FAQ distinguishes unshared fingerprint-derived +identifiers and carries #96's identical 256-bit card so its link resolves. Their +current heads now have green Python-package CI and current-head Codex ACKs; #93's +inline finding is resolved and #97's two follow-up findings are resolved. + +The remaining order is the live #38 stack: restore and Core UX, foundation/API, +audit/release support, user documentation, then the GUI replay. The new #125 root +fix in #126 must be included before freeze. #115/#116/#117 supply the mechanical +cleanup; this patch supplies the remaining contract clarifications. The local +ignored `docs/planning/v1-pre-review-cleanup.md` is a working checklist, not a +shipped report. + +Human review/integration and required signatures remain. The final candidate +must then receive the pinned reviewer handoff, artifact qualification, Tails +rendering/manual GUI-to-Core checks, and a fresh adversarial pass covering the +library, CLI, GUI, and recovery documentation. Until those succeed, “all +material validated findings resolved in the release candidate” is unproven. +The final exact-head CI/review evidence for this ledger-only update belongs in +PR #127 so recording it does not require another self-referential ledger edit. diff --git a/docs/security/invariants.md b/docs/security/invariants.md index be5ed482..51aa9c34 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -31,8 +31,12 @@ and evidence. warning and literal `YES` confirmation for every HRP. The bound includes all admitted classes ranked equal to or better than the candidate, independently of execution order. -6. Secrets stay out of arguments, logs, ordinary output, and public transfers. - During wallet setup, codex32 transfers the master xprv only through child stdin. +6. Secrets stay out of command arguments, logs, unrelated output, and public + transfers. Commands whose purpose is to create, recover, derive, correct, or + explicitly export recovery material may intentionally display that material + to the operator. During wallet setup, codex32 transfers the master xprv only + through child stdin. Private descriptors exist only in Python memory and child + stdin. 7. Bitcoin Core chains are discovered before entropy or recovery input. The operator confirms an eligible descriptor wallet by exact name. 8. Wallet state is revalidated before handing Core the master key. Core must @@ -41,8 +45,8 @@ and evidence. and verified on every exit path. 10. External text, Core output, public wallet data, and PSBTs are untrusted. 11. Only Bitcoin Core descriptor wallets sign with codex32-derived keys. - Sensitive operations use only codex32 or Core on malware-free computers - with trusted software. + Sensitive operations use only codex32 or Core on a trusted computer as + defined by the security model. 12. Offline hosts disable every network path, including Ethernet, internet, Tor, Wi-Fi, Bluetooth, and cellular. Online Core nodes synchronize before their balances or history are trusted. diff --git a/docs/security/model.md b/docs/security/model.md index e025fb38..8a07c3d5 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -31,13 +31,21 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive ## Operator assumptions +A **trusted computer** is under the operator's exclusive control, is not known +or suspected to be compromised, and runs an operating system and other software +the operator trusts for the operation. For codex32 wallet work, that trusted +software includes the Python environment, codex32, the terminal, `bitcoin-cli`, +Bitcoin Core, and their relevant configuration. An offline trusted computer +remains disconnected from every network before, while, and after it handles +private recovery or signing material. Wallet encryption, application +permissions, and RPC authentication do not make a compromised computer trusted. + The operator must: - use only Bitcoin Core descriptor wallets to sign with keys derived from a codex32 master seed; -- use computers believed malware-free and whose other software is trusted for - all codex32 operations and for wallet initialization and signing with Bitcoin - Core; +- use only trusted computers as defined above for codex32 operations, wallet + initialization, and signing with Bitcoin Core; - disconnect every computer used for offline codex32 or signing work from all network paths, including Ethernet, internet, Tor, Wi-Fi, Bluetooth, and cellular; @@ -53,6 +61,14 @@ The operator must: - never put recovery text in command arguments or transfer a master seed, share, xprv, or private descriptor through QR or a network service. +`create`, `secret`, `share`, `correct`, and `xprv` can intentionally display +secret-bearing recovery material because producing or exporting that material is +their purpose. This is distinct from accidental disclosure: unrelated status, +diagnostic, logging, and wallet-integration output must not reveal secrets. +Prompted input and standard-input redirection keep recovery text out of process +arguments, but the operator must also keep the text out of shell command text; +shell history, terminal logging, wrappers, or process tooling may retain it. + ## Security limitations - Python cannot guarantee zeroization, constant-time execution, locked memory, @@ -126,7 +142,7 @@ available through `codex32`. 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). -Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete case/whitespace-normalized equality confirms, with no expected characters, error classifications, prescribed edits, or repairs. +Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete ASCII-case/whitespace-normalized equality confirms; non-ASCII lookalikes remain mismatches and must be re-entered. No expected characters, error classifications, prescribed edits, or repairs are supplied. Progressive group-level correctness feedback is explicitly accepted and does not change the confirmation boundary. Confirmation shows that the operator can produce the correct recovery string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. diff --git a/docs/user/guide.md b/docs/user/guide.md index 422ec998..46d33a8d 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -3,6 +3,10 @@ `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. +`ms32 secret` and `ms32 share` require local Bitcoin Core for fingerprint-aware +output; `ms32 correct` requires it when repairing Bitcoin recovery material. +`ms32 check` is Core-independent. For recovery, share derivation, or correction +without Core, use the corresponding generic `codex32` command. BIP39 worksheet profiles are [not recommended for creation](https://secretcodex32.com/docs/index.html), but existing backups can be recovered and corrected. @@ -87,16 +91,24 @@ codex32 detects and reports the local Bitcoin Core network. You do not need to change `bitcoin.conf` when using the standard local data directory and RPC port. -Use a computer you believe is malware-free and whose other software you trust. -Only codex32 and Bitcoin Core should perform recovery, derivation, wallet -initialization, or signing. The QR tools below transport only public -descriptors or PSBTs. +Use a trusted computer: one under your exclusive control, not known or suspected +to be compromised, with its operating system, Python environment, terminal, +codex32, `bitcoin-cli`, Bitcoin Core, and relevant configuration trusted for the +operation. Wallet encryption or RPC authentication does not make a compromised +computer safe. An offline trusted computer stays disconnected from every network +before, while, and after it handles private recovery or signing material. Only +codex32 and Bitcoin Core should perform recovery, derivation, wallet +initialization, or signing. The QR tools below transport only public descriptors +or PSBTs. Bitcoin Core wallet encryption is strongly recommended. Bitcoin Core owns the passphrase and its prompts; codex32 never asks for, reads, or forwards it. -Do not type recovery text on the same line as a command. Run the command first, -then enter a master seed or shares on the separate `>` line when prompted. +Do not type recovery text on the same line as a command: shell history, terminal +logging, wrappers, or process tooling may retain the command text. Run the +command first, then enter a master seed or shares on the separate `>` line when +prompted. Standard-input redirection also keeps recovery text out of process +arguments, but protect the redirected source separately. This keeps a 48-character string grouped in fours within an 80-column terminal. Later share prompts may show a fixed common header after `>`. Never photograph recovery text or put it in a website, chat, cloud clipboard, or online QR diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 51712e46..dba61edf 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -12,7 +12,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars, convertbits +from codex32.bech32 import convertbits, u5_to_chars from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _descriptor_records @@ -92,7 +92,7 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) - if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + if identifier[:3] == u5_to_chars(tuple(derived[:3])): return name return "Bails check unavailable" if ripemd_unavailable else None @@ -304,19 +304,27 @@ def _target(self, name: str) -> tuple[bool, bool] | None: listing, info = self._rpc("listdescriptors", wallet=name), self._rpc("getwalletinfo", wallet=name) if not isinstance(info, dict) or not isinstance(listing, dict): raise BitcoinCoreError("Unexpected Bitcoin Core wallet information.") + txcount = info.get("txcount") + keypoolsize = info.get("keypoolsize") + internal_keypool = info.get("keypoolsize_hd_internal", 0) + has_unlock_state = "unlocked_until" in info + unlocked = info.get("unlocked_until") eligible = ( info.get("descriptors") is True and info.get("private_keys_enabled") is True and info.get("external_signer", False) is False - and info.get("txcount") == 0 - and info.get("keypoolsize") == 0 - and info.get("keypoolsize_hd_internal", 0) == 0 + and type(txcount) is int + and txcount == 0 + and type(keypoolsize) is int + and keypoolsize == 0 + and type(internal_keypool) is int + and internal_keypool == 0 and info.get("scanning") is False and listing.get("descriptors") == [] and name.isprintable() + and (not has_unlock_state or type(unlocked) is int and unlocked >= 0) ) - unlocked = info.get("unlocked_until") - return (unlocked is not None, unlocked == 0) if eligible else None + return (has_unlock_state, unlocked == 0) if eligible else None def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None: root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main") @@ -510,7 +518,8 @@ def initialize( continue except BitcoinCoreError as error: raise BitcoinCoreError(warning) from error - if not isinstance(info, dict) or info.get("unlocked_until") != 0: + unlocked_until = info.get("unlocked_until") if isinstance(info, dict) else None + if type(unlocked_until) is not int or unlocked_until != 0: raise BitcoinCoreError(warning) relock = False if waited: diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index eb13b4f5..72fbbb2a 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -12,13 +12,13 @@ from time import monotonic from typing import Any, Literal, cast -from codex32.bech32 import interpret_mixed_case +from codex32.bech32 import _ascii_lower, interpret_mixed_case from codex32.bip93 import ( Secret, Share, - _checksum_for_encoded_length, _validate_basis_prefix, _validate_recovery_prefix, + checksum_for_encoded_length, parse_codex32, recover_secret, ) @@ -239,13 +239,13 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: compact = "".join(observed.split()) expected = "".join(expected.split()).lower() tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected + grouped = len(tokens) > 1 and _ascii_lower(compact) != expected boundaries = {0} splits: set[tuple[int, int]] = set() position = 0 for token in tokens: for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == token.lower() and ( + if expected[offset : offset + len(token)] == _ascii_lower(token) and ( len(token) % 4 == 0 or offset + len(token) == len(expected) ): splits.update((position + i, offset + i) for i in range(4, len(token), 4)) @@ -261,7 +261,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: if end > start: current = [(end - start, b"\x02" * (end - start))] for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char + edit = _ascii_lower(compact[end - 1]) != char current.append( min( (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), @@ -274,7 +274,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: continue score = ( edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), + disturbed + (not grouped and _ascii_lower(compact[start:end]) != canonical), trace + row[-1][1], (*ends, end), ) @@ -288,7 +288,9 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: groups.append(observed[start:stop]) start = stop changed = { - i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] + i + for i, value in enumerate(groups) + if _ascii_lower("".join(value.split())) != expected[i * 4 : i * 4 + 4] } return groups, changed @@ -395,7 +397,7 @@ def _case_interpretation( candidate = None else: bits = ( - 5 * _checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length + 5 * checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length ) proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits) candidate = proposed if allowed is None or allowed(proposed) else None diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c2472109..12178945 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -86,8 +86,9 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu if master_seed else "Check, correct, recover, and derive shares from codex32 backups." ), - epilog="Never include a secret or share in command arguments.\n" - "Enter it when prompted. Some commands also accept piped input.", + epilog="Never put a secret or share in command arguments or shell command text.\n" + "Enter it when prompted; some commands also accept redirected standard input.\n" + "Protect redirected files; shells and terminals may keep text.", formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False, ) diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 59befc70..9ee83bac 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -1,6 +1,6 @@ """Bech32 character, container, and bit-conversion helpers.""" -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -19,16 +19,24 @@ def bech32_hrp_expand(hrp: str) -> list[int]: return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] -def _u5_to_chars(values: list[int] | tuple[int, ...]) -> str: +def _ascii_lower(value: str) -> str: + return "".join(character.lower() if character.isascii() else character for character in value) + + +def u5_to_chars(values: list[int] | tuple[int, ...]) -> str: + """Convert 5-bit values to Bech32 characters.""" + for index, value in enumerate(values): if not 0 <= value < 32: raise InvalidCharacter(f"u5 value {value} at index {index} is outside 0..31") return "".join(CHARSET[value] for value in values) -def _chars_to_u5(value: str, first_position: int = 1) -> list[int]: +def chars_to_u5(value: str, first_position: int = 1) -> list[int]: + """Convert Bech32 characters to 5-bit values.""" + result: list[int] = [] - for index, character in enumerate(value.lower()): + for index, character in enumerate(_ascii_lower(value)): position = CHARSET.find(character) if position < 0: label = "Apostrophe (')" if character == "'" else f"The character {character!r}" @@ -69,18 +77,18 @@ def interpret_mixed_case(value: str, immutable_length: int) -> tuple[str, str, b return normalized, erased, uppercase -def bech32_encode(hrp: str, data: list[int], spec: _Checksum) -> str: +def bech32_encode(hrp: str, data: list[int], spec: Checksum) -> str: """Compute a Bech32 string given HRP and data values.""" checksum = spec.create(bech32_hrp_expand(hrp) + list(data)) - return f"{hrp}1{_u5_to_chars([*data, *checksum])}" + return f"{hrp}1{u5_to_chars([*data, *checksum])}" -def bech32_verify_checksum(hrp: str, data: list[int], spec: _Checksum) -> bool: +def bech32_verify_checksum(hrp: str, data: list[int], spec: Checksum) -> bool: """Verify the checksum selected by the calling application.""" return spec.verify(bech32_hrp_expand(hrp) + list(data)) -def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[int]]: +def bech32_decode(value: str, spec: Checksum | None = None) -> tuple[str, list[int]]: """Validate a Bech32 string, optionally including its checksum.""" _validate_single_case_ascii(value) separator = value.rfind("1") @@ -88,9 +96,11 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ raise MissingSeparator("No separator (1) was found.") if separator == 0: raise MissingSeparator("The application prefix before 1 is missing.") + if separator > 83: + raise InvalidLength(f"human-readable part exceeds 83 characters ({separator})") lowered = value.lower() hrp = lowered[:separator] - data = _chars_to_u5(lowered[separator + 1 :], separator + 2) + data = chars_to_u5(lowered[separator + 1 :], separator + 2) if spec is None: return hrp, data if len(data) < spec.length or not bech32_verify_checksum(hrp, data, spec): diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 9e3757c3..bd743eb7 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -6,13 +6,13 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_verify_checksum, + chars_to_u5, + u5_to_chars, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -55,11 +55,15 @@ def __post_init__(self) -> None: raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9") if not isinstance(self.identifier, str): raise InvalidIdentifier("identifier must be str") + if not self.identifier.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") identifier = self.identifier.lower() if len(identifier) != 4 or any(character not in CHARSET for character in identifier): raise InvalidIdentifier("identifier must be exactly four Bech32 symbols") if not isinstance(self.index, str): raise InvalidShareIndex("share index must be str") + if not self.index.isascii(): + raise InvalidShareIndex("share index must be an ASCII Bech32 symbol") index = self.index.lower() if len(index) != 1 or index not in CHARSET: raise InvalidShareIndex("share index must be one Bech32 symbol") @@ -72,7 +76,7 @@ def __post_init__(self) -> None: def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": if len(symbols) != 6: raise InvalidLength("codex32 header must contain six symbols") - text = _u5_to_chars(symbols) + text = u5_to_chars(symbols) if text[0] not in "023456789": raise InvalidThreshold( f"The threshold must be 0 or a number from 2 through 9; found {text[0]!r}." @@ -81,25 +85,33 @@ def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": @property def _symbols(self) -> tuple[int, ...]: - return tuple(_chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) + return tuple(chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) -def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: +def checksum_for_encoded_length(hrp: str, encoded_length: int) -> Checksum: + """Return the codex32 checksum required by an encoded HRP/data length.""" + expanded_length = 2 * len(hrp) + 1 + encoded_length - if expanded_length <= 93: - return _CODEX32 - if expanded_length < 96: + if 94 <= expanded_length <= 95: raise InvalidLength("expanded codex32 lengths 94 and 95 are invalid") - if expanded_length <= 1023: - return _CODEX32_LONG - raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + if expanded_length > 1023: + raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + return CODEX32 if expanded_length <= 93 else CODEX32_LONG + + +def checksum_for_body_length(hrp: str, body_length: int) -> Checksum: + """Return the checksum required when constructing a codex32 body.""" + + checksum = CODEX32 if 2 * len(hrp) + 1 + body_length <= 80 else CODEX32_LONG + checksum_for_encoded_length(hrp, body_length + checksum.length) + return checksum -def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]: +def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, 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") - checksum = _checksum_for_encoded_length(hrp, len(encoded)) + checksum = checksum_for_encoded_length(hrp, len(encoded)) body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) if not bech32_verify_checksum(hrp, encoded, checksum): @@ -209,10 +221,7 @@ def _from_parts( if rules is not None: _validate_payload(rules.profile, header, payload) body = [*header._symbols, *payload] - expanded_body_length = 2 * len(normalized_hrp) + 1 + len(body) - checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG - # Validate the completed generic codeword, including the 94/95 gap. - _checksum_for_encoded_length(normalized_hrp, len(body) + checksum.length) + checksum = checksum_for_body_length(normalized_hrp, len(body)) text = bech32_encode(normalized_hrp, body, checksum) return parse_codex32(text.upper() if uppercase else text) @@ -334,7 +343,7 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: value ^= _gf32_multiply(weight, row[column]) result.append(value) header = Header(share_set.threshold, share_set.identifier, target) - text = f"{share_set.hrp}1{_u5_to_chars((*header._symbols, *result))}" + text = f"{share_set.hrp}1{u5_to_chars((*header._symbols, *result))}" return parse_codex32(text.upper() if share_set.uppercase else text) @@ -352,6 +361,8 @@ def recover_secret(shares: Sequence[Share]) -> Secret: def _normalize_target(value: object, *, label: str) -> str: if not isinstance(value, str): raise InvalidTargetIndex(f"{label} must be one Bech32 symbol") + if not value.isascii(): + raise InvalidTargetIndex(f"{label} must be an ASCII Bech32 symbol") normalized = value.lower() if len(normalized) != 1 or normalized not in CHARSET or normalized == "s": raise InvalidTargetIndex(f"{label} must be one of {IDX_SORT[1:].upper()}") diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 19262d1a..75c0452b 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -20,7 +20,9 @@ @dataclass(frozen=True, slots=True) -class _Checksum: +class Checksum: + """Immutable checksum specification for reference-vector construction.""" + kind: str generators: tuple[int, ...] length: int @@ -49,21 +51,21 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]: return [(residue >> (width * (self.length - 1 - index))) & mask for index in range(self.length)] -_CODEX32 = _Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) -_CODEX32_LONG = _Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) +CODEX32 = Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) +CODEX32_LONG = Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) # Descriptor checksum remains an independently specified, non-codex32 helper. -DESCSUM = _Checksum("Descriptor", _DESCSUM_GEN, 8, 1) +DESCSUM = Checksum("Descriptor", _DESCSUM_GEN, 8, 1) _CRC = ( None, - # ``_Checksum`` consumes input bits most-significant bit first. With its + # ``Checksum`` consumes input bits most-significant bit first. With its # implicit leading term, these generator values spell x+1, x^2+x+1, # x^3+x+1, and x^4+x+1 respectively. - _Checksum("CRC1", (1,), 1, 0), - _Checksum("CRC2", (3,), 2, 0), - _Checksum("CRC3", (3,), 3, 0), - _Checksum("CRC4", (3,), 4, 0), + Checksum("CRC1", (1,), 1, 0), + Checksum("CRC2", (3,), 2, 0), + Checksum("CRC3", (3,), 3, 0), + Checksum("CRC4", (3,), 4, 0), ) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index dc6d3be1..38b030d8 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -34,6 +34,7 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32.bech32 import _ascii_lower from codex32.bip93 import ( IDX_SORT, Header, @@ -203,6 +204,8 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None]: if value is None: return Profile.MS, None, None + if not value.isascii(): + raise _UsageError("The set header must contain only ASCII characters.") lowered = value.lower() if lowered != value and value.upper() != value: raise _UsageError("The set header must use either uppercase or lowercase.") @@ -310,10 +313,10 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prefill=prefill, ) compact = "".join(replacement.split()) - if compact.lower() == expected.lower(): + if _ascii_lower(compact) == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and _ascii_lower(compact).startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -327,7 +330,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): + if _ascii_lower("".join(entered.split())) != expected.lower(): raise RuntimeError("Confirmation mismatch.") if confirm is not None and not confirm(entered).accepted: raise RuntimeError("Confirmation rejected.") diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 9abd345b..9a902e76 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -30,21 +30,21 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, _validate_single_case_ascii, bech32_hrp_expand, + chars_to_u5, interpret_mixed_case, + u5_to_chars, ) from codex32.bip93 import ( IDX_SORT, Header, Secret, Share, - _checksum_for_encoded_length, + checksum_for_encoded_length, parse_codex32, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +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 @@ -256,10 +256,10 @@ class _Spec: _ALIGNMENT_CACHE_SIZE = 11 -def _spec_for_checksum(checksum: _Checksum) -> _Spec: - if checksum is _CODEX32: +def _spec_for_checksum(checksum: Checksum) -> _Spec: + if checksum is CODEX32: return _SHORT_SPEC - if checksum is _CODEX32_LONG: + if checksum is CODEX32_LONG: return _LONG_SPEC raise AssertionError("registered profile selected a non-codex32 checksum") @@ -545,7 +545,7 @@ def __init__( ) -> None: normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() profile_rules = _optional_profile_rules(normalized_hrp) - checksum = _checksum_for_encoded_length(normalized_hrp, body_length) + 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) self.hrp = normalized_hrp @@ -599,7 +599,7 @@ def correct( return None for index, addend in result: corrected_reversed[index] ^= addend - corrected = self.prefix + _u5_to_chars(list(reversed(corrected_reversed))) + corrected = self.prefix + u5_to_chars(list(reversed(corrected_reversed))) corrected = corrected.upper() if self.uppercase else corrected try: artifact = parse_codex32(corrected) @@ -768,6 +768,8 @@ def _validate_context(context: CorrectionContext) -> None: _validate_single_case_ascii(context.hrp) if context.hrp.lower() != context.hrp: raise ValueError("hrp must be a normalized application prefix") + if len(context.hrp) > 83: + raise ValueError(f"human-readable part exceeds 83 characters ({len(context.hrp)})") length = context.expected_length if length is not None: if isinstance(length, bool) or not isinstance(length, int): @@ -775,7 +777,7 @@ def _validate_context(context: CorrectionContext) -> None: if length < 21: raise ValueError("expected_length must permit the generic codex32 minimum length") body_length = length - len(context.hrp) - 1 - checksum = _checksum_for_encoded_length(context.hrp, body_length) + 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) @@ -1005,7 +1007,7 @@ def correct_worksheet_residue( raise InvalidCorrectionInput("codex32 input exceeds 15 characters") try: _validate_single_case_ascii(residue) - values = list(reversed(_chars_to_u5(residue))) + values = list(reversed(chars_to_u5(residue))) except TypeError: raise except CodexError as error: diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d191..4b898f90 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -9,7 +9,7 @@ from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root -from codex32.bech32 import CHARSET, _u5_to_chars, convertbits +from codex32.bech32 import CHARSET, convertbits, u5_to_chars from codex32.bip93 import ( IDX_SORT, Header, @@ -69,6 +69,8 @@ def _threshold(value: object, *, allow_zero: bool = True) -> int: def _identifier(value: object) -> str: if not isinstance(value, str): raise InvalidIdentifier("identifier must be str") + if not value.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") value = value.lower() if len(value) != 4 or any(character not in CHARSET for character in value): raise InvalidIdentifier("identifier must be four Bech32 symbols") @@ -78,6 +80,8 @@ def _identifier(value: object) -> str: def _index(value: object) -> str: if not isinstance(value, str) or len(value) != 1: raise InvalidShareSelection("each output index must be one Bech32 symbol") + if not value.isascii(): + raise InvalidShareSelection("each output index must be an ASCII Bech32 symbol") value = value.lower() if value not in ORDINARY_INDICES: raise InvalidShareSelection("output indices must be ordinary non-S symbols") @@ -113,7 +117,7 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str def _random_identifier() -> str: - return _u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) + return u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) def _fingerprint_identifier(fingerprint: bytes) -> str: @@ -121,7 +125,7 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: raise TypeError("fingerprint must be bytes") if len(fingerprint) != 4: raise ValueError("fingerprint must contain four bytes") - return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) + return u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share: @@ -225,11 +229,7 @@ def _start( else: reheadered = _from_parts(profile, Header(threshold, identifier, "s"), secret.payload_symbols) assert isinstance(reheadered, (MasterSeed, CoreLightningSecret)) - self._secret, self._basis, self._direct_count = ( - reheadered, - [reheadered], - threshold - 1, - ) + self._secret, self._basis, self._direct_count = reheadered, [reheadered], threshold - 1 return self @classmethod @@ -355,7 +355,8 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()).lower() + observed = "".join(text.split()) + observed = "".join(char.lower() if char.isascii() else char for char in observed) expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a63698..72d3459d 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -8,7 +8,7 @@ from codex32._alignment import _IncrementalSyndromes, _View from codex32.bech32 import CHARSET, _validate_single_case_ascii -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( CorrectionCandidate, CorrectionContext, @@ -277,7 +277,7 @@ def _prepare( for shape in shapes } base = len(context.hrp) + 1 - degree = _checksum_for_encoded_length(context.hrp, target - base).length + degree = checksum_for_encoded_length(context.hrp, target - base).length return _Target(context, text, observed, immutable, target, base, degree, counts) diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 91cdb1f2..7eec10ee 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -3,7 +3,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.checksums import DESCSUM from codex32.profiles.ms32 import MasterSeed @@ -48,7 +48,7 @@ def _descriptor_symbols(text: str) -> list[int]: def _with_checksum(descriptor: str) -> str: - return descriptor + "#" + _u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) + return descriptor + "#" + u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) def _descriptor_records( diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index d27d9b06..0ce348f4 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -79,6 +79,31 @@ "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } +# BIP-93 generalized-HRP draft, BenWestgate/bips PR #2 @ 01374bf. +BIP93_GENERAL_HRP = { + "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", + "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul", + "derived_d": "test_vector12spandll4f8jlh4e5vdvuldlfxu2jhdnll4f8jlh4e5vdvuldlfxu2jhdnx3pe3yqzdl7p30y", + "secret_s": "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kdf37v5qm2l7l3az", + "valid_83": ( + "a83characterlongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9mfjjk25y05e3nq" + ), + "invalid_gap_regular": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kp0f0tlr2cxh0t" + ), + "invalid_gap_long": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5c2m7mxmk8s5qecp" + ), + "invalid_uppercase_hrp_checksum": ( + "CL10PEEVST6CQH0WU7P5SSJYF4Z4EZ42KS9JLT3ZNEJU9UUYPR2HDDAK6TLQS3J5AYX4Y08079" + ), + "invalid_84": ( + "a84characterslongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9hqxu3pxsnkdkth" + ), +} + VALID_CODEX32 = [ "A12UEL5LLGCHJ4UJCQVHG", @@ -93,7 +118,6 @@ VALID_CODEX32_LONG = [ "A12UEL5LQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQV3RR8ZLCK96GTC3", "a12uel5lqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqv3rr8zlck96gtc3", - "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", "abcdef12l7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpqp9evrmhc52umqew", "1177777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777fn0jxg9gc35xwa8", "split13checkupstagehandshakeupstreamerranterredcaperredscatteredsusurrantplunderedqsp5ws8r2klm66l", @@ -117,8 +141,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # overall max length exceeded - "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character "in12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvgegljrsvs5w9q", # Too short checksum diff --git a/tests/test_bech32.py b/tests/test_bech32.py index 6ac28b4b..7087348f 100644 --- a/tests/test_bech32.py +++ b/tests/test_bech32.py @@ -4,14 +4,14 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_hrp_expand, + chars_to_u5, convertbits, + u5_to_chars, ) -from codex32.checksums import _CODEX32 +from codex32.checksums import CODEX32 from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -24,33 +24,38 @@ def test_u5_character_round_trip() -> None: values = list(range(32)) - assert _u5_to_chars(values) == CHARSET - assert _chars_to_u5(CHARSET.upper()) == values + assert u5_to_chars(values) == CHARSET + assert chars_to_u5(CHARSET.upper()) == values + + +def test_u5_character_conversion_does_not_fold_unicode_lookalikes() -> None: + with pytest.raises(InvalidCharacter, match="K"): + chars_to_u5("K") @pytest.mark.parametrize("value", (-1, 32)) def test_u5_rejects_out_of_range_values(value: int) -> None: with pytest.raises(InvalidCharacter): - _u5_to_chars([value]) + u5_to_chars([value]) def test_lexical_parser_preserves_no_semantics() -> None: - assert bech32_decode("MS10TESTS") == ("ms", _chars_to_u5("0tests")) + assert bech32_decode("MS10TESTS") == ("ms", chars_to_u5("0tests")) def test_decoder_optionally_verifies_and_removes_checksum() -> None: - data = _chars_to_u5("0tests") - encoded = bech32_encode("ms", data, _CODEX32) + data = chars_to_u5("0tests") + encoded = bech32_encode("ms", data, CODEX32) - assert bech32_decode(encoded) == ("ms", data + _CODEX32.create(bech32_hrp_expand("ms") + data)) - assert bech32_decode(encoded, _CODEX32) == ("ms", data) + assert bech32_decode(encoded) == ("ms", data + CODEX32.create(bech32_hrp_expand("ms") + data)) + assert bech32_decode(encoded, CODEX32) == ("ms", data) damaged = encoded[:-1] + ("q" if encoded[-1] != "q" else "p") with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode(damaged, _CODEX32) + bech32_decode(damaged, CODEX32) with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode("ms1q", _CODEX32) + bech32_decode("ms1q", CODEX32) def test_invalid_data_character_uses_complete_one_based_position() -> None: diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 9737203b..3eaf3228 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -214,6 +214,11 @@ def test_candidate_filter_rejects_every_unsafe_wallet_property( "transactions": (_empty_info(txcount=1), []), "keys": (_empty_info(keypoolsize=1), []), "change": (_empty_info(keypoolsize_hd_internal=1), []), + "boolean transactions": (_empty_info(txcount=False), []), + "boolean keys": (_empty_info(keypoolsize=False), []), + "boolean change": (_empty_info(keypoolsize_hd_internal=False), []), + "invalid unlock": (_empty_info(unlocked_until="unlocked"), []), + "null unlock": (_empty_info(unlocked_until=None), []), "scanning": (_empty_info(scanning={"duration": 1}), []), "descriptors": (_empty_info(), [{"desc": "public"}]), "bad\x1bname": (_empty_info(), []), @@ -721,6 +726,60 @@ def interrupt_once( assert rpc.locked and lock_calls == 2 +def test_walletlock_failure_requires_manual_lock_confirmation(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False) + original = rpc.__call__ + + def fail_lock( + client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None + ) -> object: + if arguments == ("walletlock",): + raise BitcoinCoreError("suppressed lock failure") + return original(client, *arguments, wallet=wallet, stdin=stdin) + + monkeypatch.setattr(BitcoinCore, "_rpc", fail_lock) + with pytest.raises( + BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked" + ): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + ) + + +@pytest.mark.parametrize("unlocked_until", (100, False, "locked")) +def test_failed_lock_verification_requires_manual_confirmation( + monkeypatch: pytest.MonkeyPatch, unlocked_until: object +) -> None: + rpc = _ImportRPC(locked=False) + original = rpc.__call__ + lock_requested = False + + def remain_unlocked( + client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None + ) -> object: + nonlocal lock_requested + if arguments == ("walletlock",): + lock_requested = True + return None + if lock_requested and arguments == ("getwalletinfo",): + return _empty_info(unlocked_until=unlocked_until) + return original(client, *arguments, wallet=wallet, stdin=stdin) + + monkeypatch.setattr(BitcoinCore, "_rpc", remain_unlocked) + with pytest.raises( + BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked" + ): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + ) + + def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.MonkeyPatch) -> None: rpc = _ImportRPC(encrypted=False, locked=False) monkeypatch.setattr( diff --git a/tests/test_cli.py b/tests/test_cli.py index b1ba660a..6377ab16 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,8 +33,8 @@ recover_secret, ) from codex32._bitcoin_core import BitcoinCore -from codex32.bech32 import _chars_to_u5, bech32_encode -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.bech32 import bech32_encode, chars_to_u5 +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 @@ -336,9 +336,9 @@ def forbidden(_seed: bytes) -> bytes: ), ) def test_check_reports_profile_lengths_for_people(hrp: str, payload_length: int, message: str) -> None: - body = _chars_to_u5("0tests" + "q" * payload_length) + 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 + checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG result = _invoke(["check"], bech32_encode(hrp, body, checksum)) assert result.exit_code == 2 @@ -1467,6 +1467,34 @@ def answer(prompt: str, **options: object) -> str: assert "\x1b[3J\x1b[2J\x1b[H" in prompts[1][0] +def test_creation_confirmation_retries_unicode_aliases_before_accepting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli_module = importlib.import_module("codex32.cli") + + class Card: + text = "MS10TESK" + + answers = iter(("", "MS10TESK", "TESK")) + prefills: list[str] = [] + confirmed: list[str] = [] + + def answer(_prompt: str, **options: object) -> str: + if "prefill" in options: + prefills.append(str(options["prefill"])) + return next(answers) + + def confirm(value: str) -> ConfirmationResult: + confirmed.append(value) + return ConfirmationResult(True) + + monkeypatch.setattr(cli_module, "_text", answer) + cli_module._confirm_card(Card(), confirm) + + assert prefills == ["TESK"] + assert confirmed == [Card.text] + + @pytest.mark.parametrize( ("observed", "shown", "red"), ( @@ -1596,6 +1624,13 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: assert recover_secret(basis).header.identifier == "cash" +def test_create_rejects_unicode_header_aliases_before_normalizing() -> None: + result = _invoke(["create", "MS12TESK"]) + + assert result.exit_code == 2 + assert "ASCII" in result.stderr + + @pytest.mark.parametrize(("threshold", "count"), ((2, 3), (3, 5))) def test_create_has_reviewed_share_count_presets(threshold: int, count: int) -> None: result = _invoke_confirmed_create(["create", f"{threshold}test"]) diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index a8edc801..61a76eba 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -14,7 +14,7 @@ import codex32 from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct, indel from codex32.bech32 import CHARSET -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.checksums import CODEX32, CODEX32_LONG from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -59,8 +59,8 @@ def _pack(values: tuple[int, ...]) -> int: def test_p70_target_constants_match_checksum_layer() -> None: - assert _pack(_SHORT_SPEC.target) == _CODEX32.constant - assert _pack(_LONG_SPEC.target) == _CODEX32_LONG.constant + assert _pack(_SHORT_SPEC.target) == CODEX32.constant + assert _pack(_LONG_SPEC.target) == CODEX32_LONG.constant def test_frozen_bch_constants_are_reproducible() -> None: @@ -217,6 +217,7 @@ def test_public_context_constrains_length_prefix_and_used_indices() -> None: CorrectionContext(Profile.MS, excluded_indices=["a"]), # type: ignore[arg-type] CorrectionContext(Profile.MS, excluded_indices=("s",)), CorrectionContext(Profile.MS, excluded_indices=("a", "A")), + CorrectionContext("z" * 84, expected_length=106), ), ) def test_malformed_public_context_is_rejected(context: CorrectionContext) -> None: diff --git a/tests/test_generation.py b/tests/test_generation.py index 878e795b..2ce8ab35 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -22,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.errors import ( CeremonyStateError, CodexError, @@ -147,6 +147,20 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: secret = generate_master_seed(raw, identifier="TEST") assert len(random_secret.header.identifier) == 4 assert secret.header.identifier == "test" + with pytest.raises(InvalidIdentifier): + generate_master_seed(raw, identifier="tesK") + + +def test_confirmation_rejects_unicode_that_lowercases_to_bech32( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = bytes([generation_module.CHARSET.index("k")]) * 26 + monkeypatch.setattr(generation_module.secrets, "token_bytes", lambda _length: value) + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + pending = ceremony.next_share() + invalid = pending.text.replace("k", "K", 1) + assert invalid != pending.text + assert not ceremony.confirm(invalid).accepted def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -178,6 +192,7 @@ def test_explicit_and_random_output_order_contracts() -> None: {"threshold": 2, "share_count": 32}, {"threshold": 2, "indices": "a"}, {"threshold": 2, "indices": "aa"}, + {"threshold": 2, "indices": "aK"}, {"threshold": 2, "indices": "sa"}, {"threshold": 2, "indices": "ia"}, ), @@ -377,7 +392,7 @@ def test_resharing_preserves_nonzero_core_lightning_padding() -> None: source = parse_codex32(VECTOR_6["codex32_peev"]) assert isinstance(source, CoreLightningSecret) payload = (*source.payload_symbols[:-1], source.payload_symbols[-1] | 15) - nonzero = parse_codex32(oracle_encode("cl", "0peevs" + _u5_to_chars(payload))) + nonzero = parse_codex32(oracle_encode("cl", "0peevs" + u5_to_chars(payload))) assert isinstance(nonzero, CoreLightningSecret) secret, shares = _complete( CreationCeremony.from_secret(nonzero, threshold=2, indices="ac", identifier="name") diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d1..41c5b2f6 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -7,6 +7,7 @@ import pytest from _codex32_oracle import oracle_encode +from data.bip93_vectors import BIP93_GENERAL_HRP from codex32 import ( CorrectionContext, @@ -18,7 +19,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import MismatchedHrp, MismatchedProfile +from codex32.errors import InvalidChecksum, InvalidLength, MismatchedHrp, MismatchedProfile from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -124,8 +125,9 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " create create or confirm a backup, or split an existing secret\n" " wallet restore a Bitcoin Core wallet\n" " xprv export the root extended private key\n\n" - "Never include a secret or share in command arguments.\n" - "Enter it when prompted. Some commands also accept piped input.\n" + "Never put a secret or share in command arguments or shell command text.\n" + "Enter it when prompted; some commands also accept redirected standard input.\n" + "Protect redirected files; shells and terminals may keep text.\n" ) for command in ("check", "correct", "secret", "share"): assert command in generic_help and command in ms_help @@ -178,3 +180,33 @@ def fingerprint(_secret: object) -> bytes: status, output, error = _invoke(main, ["share", "d", "--plain"], basis) assert (status, output.strip(), error) == (0, UNKNOWN["short"]["D"], "") assert _invoke(ms_main, ["share", "d", "--plain"], basis)[0] == 2 + + +def test_bip93_generalized_hrp_share_vectors() -> None: + share_a = parse_codex32(BIP93_GENERAL_HRP["share_a"]) + share_c = parse_codex32(BIP93_GENERAL_HRP["share_c"]) + assert share_a.hrp == share_c.hrp == "test_vector" + assert recover_secret([share_a, share_c]).text == BIP93_GENERAL_HRP["secret_s"] + assert derive_share([share_a, share_c], "d").text == BIP93_GENERAL_HRP["derived_d"] + + +def test_bip93_generalized_hrp_boundary_vectors() -> None: + valid = BIP93_GENERAL_HRP["valid_83"] + invalid = BIP93_GENERAL_HRP["invalid_84"] + artifact = parse_codex32(valid) + + assert len(artifact.hrp) == 83 + assert artifact.hrp == valid.rsplit("1", 1)[0] + with pytest.raises(InvalidLength): + parse_codex32(invalid) + + +@pytest.mark.parametrize("key", ("invalid_gap_regular", "invalid_gap_long")) +def test_bip93_generalized_hrp_gap_vectors_are_invalid(key: str) -> None: + with pytest.raises(InvalidLength): + parse_codex32(BIP93_GENERAL_HRP[key]) + + +def test_bip93_generalized_hrp_checksum_uses_lowercase_hrp() -> None: + with pytest.raises(InvalidChecksum): + parse_codex32(BIP93_GENERAL_HRP["invalid_uppercase_hrp_checksum"]) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca94..e59474a8 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -20,12 +20,13 @@ ) from codex32.bech32 import ( CHARSET, - _chars_to_u5, bech32_decode, + bech32_encode, bech32_hrp_expand, + chars_to_u5, ) -from codex32.bip93 import _checksum_for_encoded_length -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.bip93 import checksum_for_body_length, checksum_for_encoded_length +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -45,6 +46,17 @@ def _payload(data: bytes, padding: int) -> str: return "".join(CHARSET[(combined >> (5 * (count - 1 - index))) & 31] for index in range(count)) +def test_supported_reference_vector_api_uses_public_module_names() -> None: + hrp = "zz" + body = chars_to_u5("0tests" + "q" * 26) + checksum = checksum_for_body_length(hrp, len(body)) + text = bech32_encode(hrp, body, checksum) + + artifact = parse_codex32(text) + assert artifact.hrp == hrp + assert artifact.text == text + + def test_every_supported_ms_size_and_legal_parsed_padding() -> None: for byte_length in SEED_BYTE_LENGTHS: data = bytes((index * 29 + byte_length) % 256 for index in range(byte_length)) @@ -60,10 +72,10 @@ def test_supported_factories_use_exact_bip93_lengths() -> None: artifacts = tuple(MasterSeed.from_seed(bytes(size), identifier="test") for size in SEED_BYTE_LENGTHS) assert tuple(len(artifact.text) for artifact in artifacts) == TEXT_LENGTHS assert all( - _checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is _CODEX32 + checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is CODEX32 for artifact in artifacts[:-1] ) - assert _checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is CODEX32_LONG @pytest.mark.parametrize(("seed_hex", "text"), BIP93_ADDITIONAL_MASTER_SEEDS) @@ -95,27 +107,27 @@ def test_expanded_codeword_boundaries() -> None: first_long = _oracle_encode("ms", header + "q" * 70) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_regular)[1]) == 93 assert len(bech32_hrp_expand("ms")) + len(bech32_decode(first_long)[1]) == 96 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is _CODEX32 - assert _checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is CODEX32 + assert checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is CODEX32_LONG for body_length in (74, 75): - body = _chars_to_u5(header + "q" * (body_length - len(header))) - checksum = _CODEX32_LONG.create(bech32_hrp_expand("ms") + body) + body = chars_to_u5(header + "q" * (body_length - len(header))) + checksum = CODEX32_LONG.create(bech32_hrp_expand("ms") + body) invalid = "ms1" + "".join(CHARSET[value] for value in body + checksum) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) def test_expanded_codeword_upper_bound() -> None: - max_body = _chars_to_u5("0tests" + "q" * 997) + max_body = chars_to_u5("0tests" + "q" * 997) max_text = _oracle_encode("ms", "".join(CHARSET[value] for value in max_body)) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_text)[1]) == 1023 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is CODEX32_LONG - oversized_body = _chars_to_u5("0tests" + "q" * 998) + oversized_body = chars_to_u5("0tests" + "q" * 998) oversized = _oracle_encode("ms", "".join(CHARSET[value] for value in oversized_body)) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: @@ -172,12 +184,12 @@ def test_core_lightning_constructor_and_parsed_padding() -> None: @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in VALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in VALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), ], ) def test_official_generic_checksum_vectors_at_codec_level( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: hrp, encoded = bech32_decode(value) expanded_length = len(bech32_hrp_expand(hrp)) + len(encoded) @@ -190,12 +202,12 @@ def test_official_generic_checksum_vectors_at_codec_level( @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in INVALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in INVALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), ], ) def test_official_invalid_generic_checksum_vectors( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: try: hrp, encoded = bech32_decode(value) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 91fcd892..6bf14a1b 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -122,7 +122,9 @@ def test_master_seed_factory_can_only_construct_index_s() -> None: ((1, "test", "s"), InvalidThreshold), ((2.0, "test", "a"), InvalidThreshold), ((2, "bad", "a"), InvalidIdentifier), + ((2, "tesK", "a"), InvalidIdentifier), ((0, "test", "a"), InvalidShareIndex), + ((2, "test", "K"), InvalidShareIndex), ), ) def test_header_invariants(arguments: tuple[object, ...], error: type[Exception]) -> None: diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 6d54c9ba..73ec737d 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -17,9 +17,9 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import CHARSET, _u5_to_chars +from codex32.bech32 import CHARSET, u5_to_chars from codex32.bip93 import IDX_SORT -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -37,7 +37,7 @@ def _payload_text(artifact: Share | MasterSeed | CoreLightningSecret) -> str: - return _u5_to_chars(artifact.payload_symbols) + return u5_to_chars(artifact.payload_symbols) def _ms_basis(byte_length: int = 16, threshold: int = 2): @@ -89,7 +89,7 @@ def test_interpolation_does_not_create_a_checksum( def fail_create(*_args: object, **_kwargs: object) -> tuple[int, ...]: raise AssertionError("sharing must interpolate the existing checksum") - monkeypatch.setattr(_Checksum, "create", fail_create) + monkeypatch.setattr(Checksum, "create", fail_create) recovered = recover_secret([a, c]) # type: ignore[list-item] derived = derive_share([a, c], "d") # type: ignore[list-item] assert parse_codex32(recovered.text) == recovered @@ -153,7 +153,7 @@ def __getitem__(self, _position: int) -> Share: recover_secret(OversizedSequence()) # type: ignore[arg-type] -@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", 3)) +@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", "K", 3)) def test_invalid_targets_are_rejected(target: object) -> None: secret, masks = _ms_basis() with pytest.raises(InvalidTargetIndex): diff --git a/tools/alignment_benchmark.py b/tools/alignment_benchmark.py index 4004c9a8..002569e9 100644 --- a/tools/alignment_benchmark.py +++ b/tools/alignment_benchmark.py @@ -17,7 +17,7 @@ from codex32._alignment import _IncrementalSyndromes from codex32.bech32 import CHARSET, bech32_hrp_expand -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -98,7 +98,7 @@ def public_case( "observed_body_length": len(text) - len(hrp + "1"), "target_body_lengths": {str(c.expected_length): c.expected_length - len(hrp + "1") for c in contexts}, "expanded_length": len(bech32_hrp_expand(hrp)) + body, - "checksum_symbols": _checksum_for_encoded_length(hrp, body).length, + "checksum_symbols": checksum_for_encoded_length(hrp, body).length, "unknown": unknown, "delta": delta, "explicit_erasures": erasures, diff --git a/tools/verify_correction_constants.py b/tools/verify_correction_constants.py index c1665ea4..44bd679f 100644 --- a/tools/verify_correction_constants.py +++ b/tools/verify_correction_constants.py @@ -2,7 +2,7 @@ from functools import reduce -from codex32.bech32 import CHARSET, _chars_to_u5 +from codex32.bech32 import CHARSET, chars_to_u5 from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -42,7 +42,7 @@ def _derive(base: int, first_root: int, target: str) -> _Spec: return _Spec( base, first_root, - tuple(_chars_to_u5(target)), + tuple(chars_to_u5(target)), roots, reduce(_monic_mul, map(_minimal_poly, roots)), _order(base), diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 373b532d..b456759b 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -4,6 +4,7 @@ import importlib.util import sys +from pathlib import Path import codex32 from codex32 import ( @@ -26,6 +27,9 @@ def main() -> None: + source_package = Path(__file__).resolve().parents[1] / "src" / "codex32" + imported_package = Path(codex32.__file__).resolve().parent + assert imported_package != source_package assert importlib.util.find_spec("bip32") is None assert importlib.util.find_spec("coincurve") is None diff --git a/tools/verify_wheel_environment.py b/tools/verify_wheel_environment.py index 689e1ee0..50cc0f85 100644 --- a/tools/verify_wheel_environment.py +++ b/tools/verify_wheel_environment.py @@ -1,4 +1,4 @@ -"""Install the built wheel without dependencies and run the installed-wheel verifier.""" +"""Install a built distribution in isolation and run the installed-package verifier.""" from __future__ import annotations @@ -13,22 +13,46 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--wheel", type=Path) + parser.add_argument("--artifact", type=Path) arguments = parser.parse_args() root = Path(__file__).resolve().parents[1] - if arguments.wheel is None: + if arguments.wheel is not None and arguments.artifact is not None: + parser.error("choose only one of --wheel or --artifact") + requested = arguments.artifact or arguments.wheel + if requested 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] + artifact = wheels[0] else: - wheel = arguments.wheel.resolve() - if not wheel.is_file(): - raise FileNotFoundError(wheel) + artifact = requested.resolve() + if not artifact.is_file(): + raise FileNotFoundError(artifact) + if artifact.suffix != ".whl" and not artifact.name.endswith(".tar.gz"): + raise ValueError("artifact must be a wheel or .tar.gz source distribution") 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") + clean_env = os.environ.copy() + clean_env.pop("PYTHONPATH", None) + clean_env["PYTHONNOUSERSITE"] = "1" + if artifact.name.endswith(".tar.gz"): + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--require-hashes", + "-r", + str(root / "requirements" / "cli-build-dependencies.txt"), + ], + check=True, + env=clean_env, + ) subprocess.run( [ str(python), @@ -37,14 +61,17 @@ def main() -> None: "install", "--disable-pip-version-check", "--no-deps", - str(wheel), + "--no-build-isolation", + str(artifact), ], check=True, + env=clean_env, ) subprocess.run( [str(python), str(root / "tools" / "verify_installed_wheel.py")], check=True, cwd=temporary, + env=clean_env, )