What changed, and why it matters
This is a routine release merge for Krux firmware (version 26.08.0). It includes several genuine security fixes: a heap buffer overflow in camera-based entropy generation for a discontinued device, stricter fee calculation when signing Bitcoin transactions, and a warning when a transaction's input amounts cannot be verified. It also swaps the QR-code encoding library for a faster native one, removes support for the discontinued Maix Bit device, and makes various reliability improvements. The commit itself is a large merge, so the exact code changes are spread across many files and not all visible in the supplied diff.
Users on Krux 26.04.0 or earlier should upgrade to 26.08.0, especially if they sign PSBTs or use camera entropy. Review the CHANGELOG security fixes and verify the signed release assets with selfcustody.pem. Developers should note the build-system switch from Poetry to uv and the new libsecp256k1 build step in CI.
Security signals we found
Heap buffer overflow fix in camera entropy module (discontinued Maix Bit only)
PSBT fee calculation stricter checks and unverified-input-amount warning
Stored mnemonic file corruption now preserved instead of overwritten
Settings file malformed JSON handled without errors or write-back side effects
UR decoding migrated from pure-Python packages to native uUR C module
Removal of deterministic os.urandom from MaixPy firmware
Base58 addresses with unknown network version byte now rejected
Evidence from the diff
Merge commit ‘release-26.08.0’ bundles firmware changes. Security-relevant code changes visible or described include: (1) removal of a fixed-size 320x240 RGB565 scratch buffer in the Shannon entropy module that caused a ~49 KB heap overflow on the discontinued Maix Bit (CIF 352x288 frames); the copy was removed and read length is now capped/rounded to whole pixels. (2) PSBT fee display hardening and a new _unverified_amounts_psbt_warn() path that warns the user when input amounts are not backed by previous transactions. (3) Migration from pure-Python urtypes/foundation-ur-py to the native uUR C extension for UR QR decoding. (4) Corrupt seeds.json/settings.json no longer silently overwritten. (5) Removal of Maix Bit / OV5642 sensor code. The rest of the diff is build tooling (Poetry→uv), CI matrix updates, docs, translations, and UI polish.
Changed components
src/krux/camera.pysrc/krux/psbt.pysrc/krux/pages/home_pages/home.pysrc/krux/encryption.pysrc/krux/settings.pysrc/krux/pages/encryption_ui.pysrc/krux/bbqr.pysrc/krux/datum_tool.pysrc/krux/qr.pyfirmware/MaixPysrc/krux/format.pysrc/krux/key.pysrc/krux/krux_settings.pyInspect captured patch +4335 / −3028
### .github/workflows/conventional-commits.yml
@@ -10,4 +10,4 @@ jobs:
- uses: actions/checkout@v4
- uses: webiny/action-conventional-commits@v1.3.0
with:
- allowed-commit-types: "feat,fix,docs,style,refactor,test,i18n,ci,chore,git"
+ allowed-commit-types: "feat,fix,docs,style,refactor,perf,test,i18n,ci,chore,git"
### .github/workflows/docs.yml
@@ -12,18 +12,16 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
- - name: Setup Python
- uses: actions/setup-python@v5
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
with:
- python-version: '3.11'
- - name: Install dependencies
- run: |
- python3 -m pip install --upgrade pip
- python3 -m pip install poetry
+ enable-cache: true
+ - name: Set up Python
+ run: uv python install 3.12
- name: Install docs dependencies
- run: poetry install --extras docs
+ run: uv sync --frozen --python 3.12 --extra docs
- name: Build docs
- run: poetry run mkdocs build
+ run: uv run --python 3.12 mkdocs build
- name: Deploy
uses: peaceiris/actions-gh-pages@v4
with:
### .github/workflows/tests.yml
@@ -9,13 +9,19 @@ on:
jobs:
lint-black:
+ strategy:
+ matrix:
+ # minimum tested python version
+ # maximum python version
+ py-version: [ "3.11.5", "3.12.13"]
+
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
- python-version: '3.11'
+ python-version: ${{ matrix.py-version }}
- uses: psf/black@stable
with:
options: "--check --verbose"
@@ -38,72 +44,96 @@ jobs:
src: "./tests"
lint-pylint:
+ strategy:
+ matrix:
+ # minimum tested python version
+ # maximum python version
+ py-version: [ "3.11.5", "3.12.13"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Setup Python
- uses: actions/setup-python@v5
with:
- python-version: '3.11'
- - name: Install dependencies
- run: |
- python3 -m pip install --upgrade pip
- python3 -m pip install pylint
+ submodules: recursive
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ enable-cache: true
+ - name: Set up Python
+ run: uv python install ${{ matrix.py-version }}
+ - name: Sync dependencies
+ run: uv sync --frozen --python ${{ matrix.py-version }}
- name: Lint
- run: |
- pylint firmware/font/*.py
- pylint firmware/scripts/*.py
- pylint i18n/*.py
- pylint src
+ run: uv run --python ${{ matrix.py-version }} poe lint
check-translations:
+ strategy:
+ matrix:
+ # minimum tested python version
+ # maximum python version
+ py-version: [ "3.11.5", "3.12.13"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Setup Python
- uses: actions/setup-python@v5
with:
- python-version: '3.11'
+ submodules: recursive
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ enable-cache: true
+ - name: Set up Python
+ run: uv python install ${{ matrix.py-version }}
+ - name: Sync dependencies
+ run: uv sync --frozen --python ${{ matrix.py-version }}
- name: Validate translations
- run: cd i18n && python3 i18n.py validate
+ run: uv run --python ${{ matrix.py-version }} poe i18n validate
run-tests:
+ strategy:
+ matrix:
+ # minimum tested python version
+ # maximum python version
+ py-version: [ "3.11.5", "3.12.13"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- - name: Setup Python
- uses: actions/setup-python@v5
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
with:
- python-version: '3.11'
- - name: Install dependencies
- run: |
- python3 -m pip install --upgrade pip
- python3 -m pip install poetry
- - name: Install project and its dependencies
- run: poetry install
+ enable-cache: true
+ - name: Set up Python
+ run: uv python install ${{ matrix.py-version }}
+ - name: Sync dependencies
+ run: uv sync --frozen --python ${{ matrix.py-version }}
+ - name: Build libsecp256k1
+ run: uv run --python ${{ matrix.py-version }} poe secp256k1-build
+ - name: Check embit uses the C secp256k1
+ run: uv run --python ${{ matrix.py-version }} poe secp256k1-check
- name: Run tests
- run: poetry run pytest --cache-clear tests
+ run: uv run --python ${{ matrix.py-version }} poe test-simple
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@main
with:
submodules: recursive
- - name: Setup Python
- uses: actions/setup-python@v5
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
with:
- python-version: '3.11'
- - name: Install dependencies
- run: |
- python3 -m pip install --upgrade pip
- python3 -m pip install poetry
- - name: Install project and its dependencies
- run: poetry install
+ enable-cache: true
+ - name: Set up Python
+ # no need to use many coverages
+ run: uv python install 3.12.13
+ - name: Sync dependencies
+ run: uv sync --frozen --python 3.12.13
+ - name: Build libsecp256k1
+ run: uv run --python 3.12.13 poe secp256k1-build
+ - name: Check embit uses the C secp256k1
+ run: uv run --python 3.12.13 poe secp256k1-check
- name: Build coverage file
- run: poetry run pytest --cache-clear --cov src/krux --cov-report xml tests
+ run: uv run --python 3.12.13 pytest --cache-clear --cov src/krux --cov-report xml tests
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
with:
### .gitignore
@@ -90,4 +90,11 @@ krux-*/ktool*
# IDE files
.vscode
-.claude
\ No newline at end of file
+
+# AI tooling local context (per maintainer policy, never commit)
+.agents/
+.codex/
+.claude/
+.cursorrules
+AGENT.md
+CLAUDE.md
### .gitmodules
@@ -1,12 +1,6 @@
[submodule "embit"]
path = vendor/embit
url = ../../diybitcoinhardware/embit
-[submodule "urtypes"]
- path = vendor/urtypes
- url = ../../selfcustody/urtypes
-[submodule "foundation-ur-py"]
- path = vendor/foundation-ur-py
- url = ../../selfcustody/foundation-ur-py
[submodule "firmware/Kboot"]
path = firmware/Kboot
url = ../../selfcustody/Kboot
### .pylintrc
@@ -3,7 +3,9 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
-extension-pkg-whitelist=
+# uUR is the native BC-UR module: pylint has to import it to see UR, URDecoder,
+# UREncoder and Types, which it cannot deduce from a compiled extension.
+extension-pkg-whitelist=uUR
# Specify a score threshold to be exceeded before program exits with error.
fail-under=10.0
@@ -14,9 +16,7 @@ ignore=CVS,
.vagrant,
build,
embit,
- foundation-ur-py,
MaixPy,
- urtypes,
translations.py
# Add files or directories matching the regex patterns to the blacklist. The
@@ -183,9 +183,20 @@ contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
+# uUR.Types is a submodule the C extension builds at import time, so pylint
+# infers it as a bare module and cannot enumerate its codecs. Listing them keeps
+# the rest of uUR checked instead of silencing the whole module.
generated-members=sleep_ms,
ticks_ms,
- print_exception
+ print_exception,
+ CRYPTO_PSBT_TYPE,
+ bip39_words_from_cbor,
+ bytes_from_cbor,
+ bytes_to_cbor,
+ output_from_cbor,
+ output_from_cbor_account,
+ psbt_from_cbor,
+ psbt_to_cbor
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
### CHANGELOG.md
@@ -1,4 +1,41 @@
-# Changelog 26.04.0 - April 2025
+# Changelog 26.08.0 - August 2026
+
+### Security Fixes
+- Camera entropy: fix a heap buffer overflow in the Shannon entropy module. Only the Maix Bit could trigger it, a device discontinued in 25.09.0 with no known users; every other device feeds the module a frame that fits. The module copied the whole frame into a fixed 320x240 RGB565 (153,600 byte) scratch buffer, so the Maix Bit's larger CIF frames (352x288 RGB565, 202,752 bytes) wrote 49,152 bytes past the end. The scratch copy has been removed entirely, the read length is now capped and rounded to whole pixels, and the CIF path is gone along with the Maix Bit
+- PSBT: stricter checks on the calculation of fee shown on screen
+- PSBT: warn before signing when the wallet coordinator did not send enough data to confirm those amounts
+
+### Removed Maix Bit Code
+All Maix Bit support has been removed from the source tree, including its firmware build project. Support for the device was discontinued in 25.09.0, which at the time kept the build parameters available; those are now gone too. The OV5642 sensor handling, used only by that device, was removed along with it.
+
+### Stackbit 1248 Vertical Layout
+Added vertical layout option for Stackbit 1248 backup display, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations.
+
+### Migrate UR encoding to uUR C module
+Switch from the pure-Python urtypes and foundation-ur-py packages to the new uUR C module, allowing faster UR QR codes decoding with a smaller RAM footprint. Tests and the simulator now build the same module for CPython instead of shimming the pure-Python packages, so host and device run identical UR code.
+
+
+### Other Bug Fixes and Improvements
+- Remove the unused `os.urandom()` from the MaixPy firmware. It was never called by Krux and played no part in generating keys or mnemonics, which draw entropy from the camera or dice. It was backed by a deterministic PRNG, so it has been removed to keep it from being mistaken for a secure source later
+- Fix display of negative amounts
+- Improve scan TinySeed and other binary visibility by drawing punches only
+- Added `flash_success` method to standardize green success flashes across confirmation screens
+- Update Embit to latest - fff7ffa
+- Replace custom quirc (library to decode QR codes from images) with k_quirc
+- Fix default theme contrast failures for Light, CypherPink, network
+ indicators, and Amigo info panels
+- Simplify generated-mnemonic confirmation with `Continue` and grouped
+ `Wallet Options`
+- Stored mnemonics: a corrupt `seeds.json` is now reported and kept intact
+ instead of being overwritten when storing a new mnemonic
+- Reject Base58 addresses whose version byte matches no network, previously
+ accepted as valid when verifying an address
+- Settings: malformed `settings.json` content is handled without errors, and
+ reading a setting no longer writes back to storage
+- Hide the QR code title in line and region view modes, where it overlapped
+ the part index
+
+# Changelog 26.04.0 - April 2026
### Security Fixes
- Reject PSBT inputs with non-standard sighash types before signing
@@ -13,7 +50,7 @@
- File manager: filter "."/".." and entries containing path separators from SD listings, blocking directory traversal via crafted FAT entries
- KEF decryption: in-session exponential backoff (1s, 2s, 4s … capped at 30s) on failed attempts, slowing interactive brute forcing without persisting lockout state to flash
-# Changelog 26.03.0 - March 2025
+# Changelog 26.03.0 - March 2026
### New Device Support: Embed Fire
This device shares similarities with the WonderMV but stands out with its larger 2.4" touchscreen.
### CONTRIBUTING.md
@@ -0,0 +1,210 @@
+# Contributing to Krux firmware
+
+Krux firmware is free and open-source software, built as a community effort.
+We welcome contributions of any kind: bug reports, feature requests, code,
+and documentation, from contributors of any experience level. We only ask
+that you respect others and follow the process described here.
+
+---
+
+## Read the documentation
+
+We maintain detailed
+[user documentation](https://selfcustody.github.io/krux). Please read it
+carefully before contributing. If you find it incomplete or unclear,
+improving it is a welcome contribution and a good first issue.
+
+---
+
+## Communication channels
+
+The primary channel is the
+[GitHub repository](https://github.com/selfcustody/krux). Contributors are
+credited in the
+[krux](https://github.com/selfcustody/krux/contributors) and
+[krux-installer](https://github.com/selfcustody/krux-installer/contributors)
+contributor lists.
+
+---
+
+## Contribution workflow
+
+The workflow is meant to support cooperation and keep quality high, not to
+impose rigid procedure. To contribute a patch:
+
+ 1. Fork the repository
+ 2. Create a topic branch
+ 3. Commit your changes
+ 4. Push to your fork
+ 5. Open a pull request
+ 6. Address peer review
+
+### Fork the repository
+
+[Fork the repository](https://github.com/selfcustody/krux) and clone your
+fork:
+
+```bash
+git clone git@github.com:<user>/krux.git
+```
+
+Then follow the setup steps in [README.md](./README.md).
+
+### Create a topic branch
+
+`main` is the stable branch and `develop` is the integration branch that all
+development starts from. Create your topic branch off `develop`, using a
+`<type>/<name>` convention (as used by Bitcoin projects such as bdk and
+floresta):
+
+```bash
+main -> develop -> chore/task-stuff
+ -> ci/job-stuff
+ -> docs/info-new
+```
+
+```bash
+git checkout develop
+git checkout -b <type>/<name>
+```
+
+### Commit your changes
+
+Commits should be atomic and their diffs easy to read. Do not mix formatting
+changes or code moves with functional changes. Each commit should build and
+pass tests where possible, so that `git bisect` and other tools work
+reliably. New features should be covered by tests. When refactoring, keep
+pull requests focused and split large changes into smaller ones.
+
+Follow these
+[commit message guidelines](https://chris.beams.io/posts/git-commit/) and the
+["Conventional Commits 1.0.0"](https://www.conventionalcommits.org/en/v1.0.0/)
+specification. The commit types we use are:
+
+- `chore`: maintenance tasks (mostly lint and format);
+- `ci`: continuous integration (generally `.github/**` files);
+- `docs`: documentation changes (`*.md` files);
+- `feat`: new feature (`src/**`; tests and docs should accompany it);
+- `fix`: bug fix (use `!` for breaking changes; tests and docs should
+ accompany it);
+- `i18n`: addition or fix of locale strings;
+- `refactor`: change that neither fixes a bug nor adds a feature;
+- `style`: formatting, colors, icons, etc., with no functional change;
+- `test`: adding or correcting tests.
+
+### Push to your fork
+
+```bash
+# first push
+git push --set-upstream origin <branch>
+```
+
+### Open a pull request
+
+After pushing, GitHub shows an "Open pull request" button on your fork's
+branch page. Fill in the template and open the PR against `develop`. For work
+in progress, open a Draft PR.
+
+### Peer review
+
+To keep the codebase high quality and maintainable, every pull request is
+reviewed by at least one maintainer and should have no unresolved comments
+from contributors (unless a maintainer accepts the rationale).
+
+Reviews are expressed with acknowledgement (ACK) tags:
+
+- `cACK`: concept ACK, I agree with the goal of this PR;
+- `nACK`: I disagree with the change (must include a rationale; an
+ unexplained `nACK` may be disregarded);
+- `utACK`: untested ACK, I reviewed the code but did not test it;
+- `tACK`: tested ACK, I reviewed and tested the code.
+
+A code review references the branch commit being reviewed. A "nit" is a
+trivial, usually non-blocking issue. For example:
+
+```markdown
+tACK c00febab
+
+I like the approach! One nit: line 3 has a typo in a comment,
+`Helllo` should be `Hello`:
+
+- # The resulting Helllo
++ # The resulting Hello
+
+Nice work, @user!
+```
+
+Maintainers weigh reviewer opinions using their judgement, giving more weight
+to reviewers with proven commitment or domain expertise.
+
+---
+
+## Coding conventions
+
+A few rules keep the code readable and maintainable. Most are checked by
+`uv run poe lint` and `uv run poe format`, and enforced by CI.
+
+### Python
+
+Every source file starts with the license header:
+
+```python
+# The MIT License (MIT)
+
+# Copyright (c) 2021-2026 Krux contributors
+```
+
+Prefer explicit, meaningful error handling that tells developers and users
+exactly what went wrong. Keep in mind that Krux runs on MicroPython. For
+example:
+
+```python
+def foo(bar, baz):
+ """Serialize an int and a float into a new str"""
+ if not isinstance(bar, int):
+ raise ValueError("Expected bar to be an int")
+
+ if not isinstance(baz, float):
+ raise ValueError("Expected baz to be a float")
+
+ return str(bar) + str(baz)
+```
+
+### Markdown
+
+Markdown files are linted to stay consistent across editors. Some of the
+rules enforced by CI:
+
+- the first heading should be a top-level heading;
+- keep lines compact, around 80 characters;
+- use the `[text](link)` format instead of raw links;
+- follow correct list indentation.
+
+---
+
+## Testing
+
+We aim for high test coverage (95% or more) on each PR. Run the tests with
+`uv run poe test`, and list all available tasks with `uv run poe`.
+
+---
+
+## Release
+
+When maintainers and contributors agree that `develop` is stable and has
+enough features, `develop` is merged into `main`. After further testing, a
+maintainer publishes the release.
+
+Releases include pre-built binaries on the GitHub assets page. They are
+OpenSSL signed by [odudex](mailto:odudex@proton.me) and verifiable with
+[`selfcustody.pem`](./selfcustody.pem), and ship as a `zip` accompanied by a
+`zip.sha256.txt` file.
+
+If bugs are found in a release, fixes may be backported on top of the release
+branch and published as a new minor release.
+
+To report a security issue, please use the repository's
+[security advisories](https://github.com/selfcustody/krux/security).
+
+If you have questions about this process or the codebase, don't hesitate to
+reach out. We are happy to help newcomers. Have fun!
### Dockerfile
@@ -26,7 +26,7 @@
# build-base
# install kendryte (k210), cmake and python dependencies
############
-FROM gcc:9.5.0-bullseye AS build-base
+FROM gcc:12-bookworm@sha256:112aacdc53e949b9d2ccefb9ed64930a7fda5e10e007430f244be27e0263220b AS build-base
RUN apt-get update -y && \
apt-get install --no-install-recommends -y -q \
@@ -100,12 +100,6 @@ WORKDIR /src
# copy vendor to WORKDIR (src)
COPY ./vendor vendor
-# clean vendor/urtypes
-RUN find vendor/urtypes -type d -name '__pycache__' -exec rm -rv {} + -depth
-
-# clean vendor/foundation-ur-py
-RUN find vendor/foundation-ur-py -type d -name '__pycache__' -exec rm -rv {} + -depth
-
# install vendor/embit
RUN /kruxenv/bin/pip install vendor/embit
# clean vendor/embit
@@ -125,8 +119,6 @@ COPY ./firmware firmware
RUN find firmware -type d -name '__pycache__' -exec rm -rv {} + -depth
# copy all vendors to DEVICE_BUILTIN
-RUN cp -r vendor/urtypes/src/urtypes "${DEVICE_BUILTIN}"
-RUN cp -r vendor/foundation-ur-py/src/ur "${DEVICE_BUILTIN}"
RUN cp -r vendor/embit/src/embit "${DEVICE_BUILTIN}"
# copy Krux (src) to WORKDIR (src)
### LICENSE.md
@@ -7,8 +7,7 @@ All the source code in this repository is either MIT or Apache v2.0 licensed. Mo
The source code for the `MaixPy` firmware (which has been modified by the repository owner) is under the Apache v2.0 license. The source code within it related to `MicroPython` and `OpenMV` is released separately under the MIT license.
The source code for the `embit` library is under the MIT license.
-The source code for the `urtypes` library is under the MIT license.
-The source code for the `foundation-ur-py` library is under the BSD-2-Clause Plus Patent license.
+The source code for the `cUR` library, which provides the `uUR` UR encoder/decoder module, is under the BSD-2-Clause Plus Patent license.
The source code for the `Adafruit Thermal Printer` library is under the MIT license.
The source code for the `Kboot` bootloader is under the Apache v2.0 license.
### README.md
@@ -66,61 +66,65 @@ To build and flash the firmware:
The first time, the build can take around an hour or so to complete. Subsequent builds should take only a few minutes. If all goes well, you should see a new `build` folder containing `firmware.bin` and `kboot.kfpkg` files when the build completes.
## Install Krux and dev tools
-Krux uses [Poetry](https://python-poetry.org/) as Python packaging and dependency management. This cmd installs development dependencies like [embit](https://github.com/diybitcoinhardware/embit), [ur](https://github.com/selfcustody/foundation-ur-py) and [urtypes](https://github.com/selfcustody/urtypes), and tools to run [tests](https://docs.pytest.org), review code with [pylint](https://pypi.org/project/pylint/), format code with [black](https://github.com/psf/black) and a lib to help handle i18n translations.
+Krux uses [uv](https://docs.astral.sh/uv/) for Python packaging and environment management. Install uv by following its [installation guide](https://docs.astral.sh/uv/getting-started/installation/), then sync the project to install runtime deps ([embit](https://github.com/diybitcoinhardware/embit) and [uUR](https://github.com/selfcustody/cUR), the native UR module compiled from the same sources the devices run) along with the `dev` group ([pytest](https://docs.pytest.org), [pylint](https://pypi.org/project/pylint/), [black](https://github.com/psf/black) and i18n helpers):
```bash
-pip install poetry
-poetry install
+uv sync
```
-If you have a problem installing Poetry on Linux OS:
+> **`uUR` is a C extension** It is built from the `bc-ur` submodule nested under `firmware/MaixPy`, so clone with `--recursive` (or run `git submodule update --init --recursive`) and make sure a C compiler and the Python development headers are installed (`python3-dev` on Debian/Ubuntu). After changing the submodule, rebuild it with `uv sync --reinstall-package uUR`.
+
+`uv sync` creates a `.venv` in the project root, resolves `uv.lock` if needed, and installs everything — this is the day-to-day command. When dependencies in `pyproject.toml` change but you only want to refresh `uv.lock` without touching the venv, run `uv lock` instead; `uv sync` will then pick the new pins on its next run.
+
+> **CI uses `uv sync --frozen`** The workflows refuse to silently re-resolve when `uv.lock` drifts (we value a lot reproducible builds). Whenever you edit `pyproject.toml` (add, remove, or bump a dependency), run `uv lock` (or `uv sync`) and commit `uv.lock` (in same change). Otherwise CI will fail.
+
+### Migrating from a previous Poetry clone
+If your clone was set up with Poetry, remove the old environment before the first `uv sync` so the two managers do not shadow each other:
```bash
-# we considered the name of the venv .krux
-python -m venv .krux
-source .krux/bin/activate
+rm -rf .venv poetry.lock
+uv sync
```
-The result will be something like:
+
+## Format code
```bash
-(.krux) username:~/directory name$
+uv run poe format
```
-Now you can run normaly the pip of the poetry:
+
+## Review code
```bash
-pip install poetry
-poetry install
+uv run poe lint
```
-Note: when changing the dependencies in `pyptoject.toml` you need to generate a new `poetry.lock` file using the cmd: `poetry lock --no-update`.
-
-## Format code
+## Run tests with coverage
```bash
-poetry run poe format
+uv run poe test
```
-## Review code
+Before the first run, build the `libsecp256k1` that the `embit` submodule pins (needs `gcc` and `make`):
```bash
-poetry run poe lint
+uv run poe secp256k1-build
```
-## Run tests with coverage
+Without it `embit` falls back to its pure Python EC implementation, which is slower and does not always match the C library the firmware runs, so some signature paths get exercised differently than on device. CI builds it and fails if the fallback is in use. To check your own setup:
```bash
-poetry run poe test
+uv run poe secp256k1-check
```
Note: The coverage report will be created at the `htmlcov` folder `file:///path/to/krux/htmlcov/index.html`.
For more verbose output (e.g., to see the output of print statements):
```bash
-poetry run poe test-verbose
+uv run poe test-verbose
```
To run just a specific test from a specific file:
```bash
-poetry run pytest --cache-clear ./tests/pages/test_login.py -k 'test_load_key_from_hexadecimal'
+uv run pytest --cache-clear ./tests/pages/test_login.py -k 'test_load_key_from_hexadecimal'
```
## Use the Python interpreter (REPL)
This is useful for rapid development of non-visual code:
```bash
-poetry run python
+uv run python
```
```
Python 3.9.1
@@ -134,37 +138,37 @@ Type "help", "copyright", "credits" or "license" for more information.
## Run the device simulator
This is useful for rapid code development that utilizes UI/UX. It is also good for newcomers to try Krux before purchasing a device. However, the simulator does not behave exactly as the HW device and may not have all features implemented (e.g. scanning via camera a TinySeed currently only works on the HW device).
-Before executing, make sure you have installed the poetry extras:
+Before executing, make sure you have synced the simulator extras:
```bash
-# This cmd will uninstall other extras
-poetry install --extras simulator
+# This cmd installs the simulator extras alongside the dev group
+uv sync --extra simulator
# To install all extras, use:
-poetry install --all-extras
+uv sync --all-extras
```
Run the simulator:
```bash
# Run simulator with the touch device Amigo, then use mouse to navigate
-poetry run poe simulator
+uv run poe simulator
# Run simulator with SD enabled (folder `simulator/sd`) on the small button-only device M5stickV, then use keyboard (arrow keys UP or DOWN and ENTER)
-poetry run poe simulator-m5stickv --sd
+uv run poe simulator-m5stickv --sd
# Run simulator on the device dock, then use keyboard (arrow keys UP or DOWN and ENTER)
-poetry run poe simulator-dock
+uv run poe simulator-dock
# Run simulator with the touch device yahboom, then use mouse to navigate
-poetry run poe simulator-yahboom
+uv run poe simulator-yahboom
# Run simulator on the device cube, then use keyboard (arrow keys UP or DOWN and ENTER)
-poetry run poe simulator-cube
+uv run poe simulator-cube
# Run simulator with the touch device wonderMV, then use mouse to navigate
-poetry run poe simulator-wonder-mv
+uv run poe simulator-wonder-mv
# Run simulator with the touch device tzt, then use mouse to navigate
-poetry run poe simulator-tzt
+uv run poe simulator-tzt
```
Note: With emulated SD card it is possible to store settings, encrypted mnemonics, also drop and sign PSBTs. After some time running, the simulator may become slow. If that happens, just close and open again!
@@ -192,10 +196,10 @@ cd simulator
./generate-all-screenshots.sh
# Run a specific sequence for a specific device's with SD enabled (folder `simulator/sd`)
-poetry run poe simulator --sequence sequences/about.txt --sd
+uv run poe simulator --sequence sequences/about.txt --sd
# Sequence screenshots are scaled to fit in docs. Use --no-screenshot-scale to get full size
-poetry run poe simulator --sequence sequences/home-options.txt --no-screenshot-scale
+uv run poe simulator --sequence sequences/home-options.txt --no-screenshot-scale
```
## Live debug a device (Linux)
@@ -250,25 +254,19 @@ The project has lots of translations [here](i18n/translations), if you add new e
```bash
# Clean unused translations:
-poetry run poe i18n clean
+uv run poe i18n clean
# Create a new translation file in JSON:
-poetry run poe i18n new tr-TR
-
-# Use Google translate to create missing translations, copy them to respective files, review phrases and commas.
-poetry run poe i18n fill
-
-# Create missing translations for a single language. Ex: Brazilian Portuguese
-poetry run poe i18n fill pt-BR
+uv run poe i18n new tr-TR
# Make sure all files have this new translated message:
-poetry run poe i18n validate
+uv run poe i18n validate
# Format translation files properly:
-poetry run poe i18n prettify
+uv run poe i18n prettify
# Create the compiled table for krux translations.py
-poetry run poe i18n bake
+uv run poe i18n bake
```
## Fonts
@@ -278,21 +276,21 @@ Learn about how to setup fonts [here](firmware/font/README.md)
Use [this script](firmware/scripts/rgbconv.py) to generate device compatible colors from RGB values (usefull for color themes).
## Documentation
-Before change documentation, and run the mkdocs server, make sure you have installed the poetry extras:
+Before change documentation, and run the mkdocs server, make sure you have synced the docs extras:
```bash
-# This cmd will uninstall other extras
-poetry install --extras docs
+# This cmd installs the docs extras alongside the dev group
+uv sync --extra docs
# To install all extras, use:
-poetry install --all-extras
+uv sync --all-extras
```
To change lateral and upper menus on documentation, see `mkdocs.yml` file on `nav` section. To create or edit translations (TODO: need help!), read [here](i18n/README.md).
Create the documentation site locally - `http://127.0.0.1:8000/krux/`:
```bash
-poetry run poe docs
+uv run poe docs
```
# Inspired by these similar projects
### SECURITY.md
@@ -0,0 +1,12 @@
+# Security policy
+
+Please report any vulnerability or any bug that could potentially affect the
+security of users' funds by mail to
+[`odudex@proton.me`](mailto:odudex@proton.me).
+
+You may use the [PGP public](https://github.com/odudex.gpg) key to encrypt your
+mail.
+
+In the subject type `[Krux firmware] Security Report: <short description>`
+and in the body a long description describing the issue. We aim to respond
+within one week and patch within 90 days.
### docs/getting-started/features/QR-transcript-tools.en.md
@@ -1,4 +1,4 @@
-When you export a mnemonic, encrypted mnemonic or a generic text QR code, alternative visualization modes will be available. Swipe left :material-gesture-swipe-left: or right :material-gesture-swipe-right: to change modes, or if your device doesn't have a touchscreen, press the `PAGE` buttons. See our [available transcribe templates](../templates/templates.md).
+When you export a mnemonic, encrypted mnemonic or a generic text QR code, alternative visualization modes will be available. Swipe left :material-gesture-swipe-left: or right :material-gesture-swipe-right: to change modes, or if your device doesn't have a touchscreen, press the `PAGE` buttons. See our [available transcribe templates](../templates/index.md).
### Standard Mode
<img src="../../../img/maixpy_m5stickv/standard-qr-code-250.png" align="right" class="m5stickv">
### docs/getting-started/features/tamper-detection.en.md
@@ -39,7 +39,7 @@ When you enable the *TC Flash Hash at Boot* feature, the device will require you
Before being stored in the device’s flash, the *TC Code* is hashed together with the K210 chip’s unique ID and stretched using PBKDF2. This ensures the *TC Code* is not retrievable via a flash dump and can only be brute-forced outside the device if the attacker also has access to the device’s unique ID (UID). By allowing letters, special characters, and running 100k iterations of PBKDF2, brute-forcing the *TC Code* from dumped data becomes more time-consuming and resource-intensive.
### Enhancing Tamper Detection
-After setting the *TC Code*, you are prompted to fill empty flash memory blocks with random entropy from the camera. This process ensures that attackers cannot exploit unused memory space.
+Once the *TC Code* is stored, Krux briefly flashes a green *"Tamper check code set successfully"* confirmation. You are then prompted to fill empty flash memory blocks with random entropy from the camera. This process ensures that attackers cannot exploit unused memory space.
## Tamper Check Flash Hash (TC Flash Hash) - A Tamper Detection Tool
### docs/getting-started/templates/index.en.md
@@ -0,0 +1,137 @@
+# Templates
+
+Here we offer a few templates to [transcribe QR codes](../features/QR-transcript-tools.md), [Tinyseed or Binary Grid](../features/tinyseed.en.md) backups.
+
+## QR Code Templates
+You can manually copy compact SeedQR codes or place a proper sized template over the device screen.
+Protect the template backside with a transparent tape so you won't bleed ink through the paper to your device's screen.
+Then, using a marker, paint the QR code.
+
+### V1 - 21x21
+
+<a href="QR/png/qr_v1_dots_regions.png">
+ <img src="QR/png/qr_v1_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v1_lines_regions.png">
+ <img src="QR/png/qr_v1_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v1_dots_lines_regions.png">
+ <img src="QR/png/qr_v1_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+SVG: [Dots](QR/svg/qr_v1_dots_regions.svg), [Lines](QR/svg/qr_v1_lines_regions.svg), [Dots and Lines](QR/svg/qr_v1_dots_lines_regions.svg)
+
+### V2 - 25x25
+
+<a href="QR/png/qr_v2_dots_regions.png">
+ <img src="QR/png/qr_v2_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v2_lines_regions.png">
+ <img src="QR/png/qr_v2_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v2_dots_lines_regions.png">
+ <img src="QR/png/qr_v2_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+SVG: [Dots](QR/svg/qr_v2_dots_regions.svg), [Lines](QR/svg/qr_v2_lines_regions.svg), [Dots and Lines](QR/svg/qr_v2_dots_lines_regions.svg)
+
+### V3 - 29x29
+
+<a href="QR/png/qr_v3_dots_regions.png">
+ <img src="QR/png/qr_v3_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v3_lines_regions.png">
+ <img src="QR/png/qr_v3_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v3_dots_lines_regions.png">
+ <img src="QR/png/qr_v3_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+SVG: [Dots](QR/svg/qr_v3_dots_regions.svg), [Lines](QR/svg/qr_v3_lines_regions.svg), [Dots and Lines](QR/svg/qr_v3_dots_lines_regions.svg)
+
+### V4 - 33x33
+
+<a href="QR/png/qr_v4_dots_regions.png">
+ <img src="QR/png/qr_v4_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v4_lines_regions.png">
+ <img src="QR/png/qr_v4_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v4_dots_lines_regions.png">
+ <img src="QR/png/qr_v4_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+SVG: [Dots](QR/svg/qr_v4_dots_regions.svg), [Lines](QR/svg/qr_v4_lines_regions.svg), [Dots and Lines](QR/svg/qr_v4_dots_lines_regions.svg)
+
+### V5 - 37x37
+
+<a href="QR/png/qr_v5_dots_regions.png">
+ <img src="QR/png/qr_v5_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v5_lines_regions.png">
+ <img src="QR/png/qr_v5_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<a href="QR/png/qr_v5_dots_lines_regions.png">
+ <img src="QR/png/qr_v5_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+SVG: [Dots](QR/svg/qr_v5_dots_regions.svg), [Lines](QR/svg/qr_v5_lines_regions.svg), [Dots and Lines](QR/svg/qr_v5_dots_lines_regions.svg)
+
+## Tinyseed Templates
+
+[Tinyseed](../features/tinyseed.en.md) background of blank templates to be manually filled.
+
+<div style="clear: both"></div>
+
+<a href="tiny_seed_scan_background.png">
+ <img src="tiny_seed_scan_background.png" alt="Tinyseed Scan Background" style="width: 15%; float: left; margin-left: 10px;">
+</a>
+
+<a href="tiny_seed_template.png">
+ <img src="tiny_seed_template.png" alt="Tinyseed Scan Background" style="width: 15%; float: left; margin-left: 10px;">
+</a>
+
+<a href="tiny_seed_template_24w.png">
+ <img src="tiny_seed_template_24w.png" alt="Tinyseed Scan Background" style="width: 22%; float: left; margin-left: 10px;">
+</a>
+
+
+<div style="clear: both"></div>
+
+## Binary Grid Templates
+
+[Binary Grid](../features/tinyseed.en.md) labeled and and "stealth" clean templates.
+
+<div style="clear: both"></div>
+
+<a href="Krux_Binary_Grid_double_rev1.png">
+ <img src="Krux_Binary_Grid_double_rev1.png" alt="Tinyseed Scan Background" style="width: 30%; float: left; margin-left: 10px;">
+</a>
+
+<a href="Krux_Binary_Grid_double_clean_rev1.png">
+ <img src="Krux_Binary_Grid_double_clean_rev1.png" alt="Tinyseed Scan Background" style="width: 30%; float: left; margin-left: 10px;">
+</a>
+
+<div style="clear: both"></div>
+
+[Binary Grid svg source](Krux_Binary_Grid_double_rev1.svg)
+
+[Binary Grid Clean svg source](Krux_Binary_Grid_double_clean_rev1.svg)
+
+## Edit Templates
+To edit the source file (.svg) it is recommended to use Inkscape and set it to use mm unit. "Unscaled models" from QR code templates have the 21x21 or 25x25mm size for 12 or 24 respectively, this way making them easier to edit.
### docs/getting-started/templates/templates.en.md
@@ -1,135 +0,0 @@
-Here we offer a few templates to [transcribe QR codes](../features/QR-transcript-tools.md), [Tinyseed or Binary Grid](../features/tinyseed.en.md) backups.
-
-## QR Code Templates
-You can manually copy compact SeedQR codes or place a proper sized template over the device screen.
-Protect the template backside with a transparent tape so you won't bleed ink through the paper to your device's screen.
-Then, using a marker, paint the QR code.
-
-### V1 - 21x21
-
-<a href="../QR/png/qr_v1_dots_regions.png">
- <img src="../QR/png/qr_v1_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v1_lines_regions.png">
- <img src="../QR/png/qr_v1_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v1_dots_lines_regions.png">
- <img src="../QR/png/qr_v1_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-SVG: [Dots](../QR/svg/qr_v1_dots_regions.svg), [Lines](../QR/svg/qr_v1_lines_regions.svg), [Dots and Lines](../QR/svg/qr_v1_dots_lines_regions.svg)
-
-### V2 - 25x25
-
-<a href="../QR/png/qr_v2_dots_regions.png">
- <img src="../QR/png/qr_v2_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v2_lines_regions.png">
- <img src="../QR/png/qr_v2_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v2_dots_lines_regions.png">
- <img src="../QR/png/qr_v2_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-SVG: [Dots](../QR/svg/qr_v2_dots_regions.svg), [Lines](../QR/svg/qr_v2_lines_regions.svg), [Dots and Lines](../QR/svg/qr_v2_dots_lines_regions.svg)
-
-### V3 - 29x29
-
-<a href="../QR/png/qr_v3_dots_regions.png">
- <img src="../QR/png/qr_v3_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v3_lines_regions.png">
- <img src="../QR/png/qr_v3_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v3_dots_lines_regions.png">
- <img src="../QR/png/qr_v3_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-SVG: [Dots](../QR/svg/qr_v3_dots_regions.svg), [Lines](../QR/svg/qr_v3_lines_regions.svg), [Dots and Lines](../QR/svg/qr_v3_dots_lines_regions.svg)
-
-### V4 - 33x33
-
-<a href="../QR/png/qr_v4_dots_regions.png">
- <img src="../QR/png/qr_v4_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v4_lines_regions.png">
- <img src="../QR/png/qr_v4_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v4_dots_lines_regions.png">
- <img src="../QR/png/qr_v4_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-SVG: [Dots](../QR/svg/qr_v4_dots_regions.svg), [Lines](../QR/svg/qr_v4_lines_regions.svg), [Dots and Lines](../QR/svg/qr_v4_dots_lines_regions.svg)
-
-### V5 - 37x37
-
-<a href="../QR/png/qr_v5_dots_regions.png">
- <img src="../QR/png/qr_v5_dots_regions.png" alt="Dots" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v5_lines_regions.png">
- <img src="../QR/png/qr_v5_lines_regions.png" alt="Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../QR/png/qr_v5_dots_lines_regions.png">
- <img src="../QR/png/qr_v5_dots_lines_regions.png" alt="Dots Lines" style="width: 20%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-SVG: [Dots](../QR/svg/qr_v5_dots_regions.svg), [Lines](../QR/svg/qr_v5_lines_regions.svg), [Dots and Lines](../QR/svg/qr_v5_dots_lines_regions.svg)
-
-## Tinyseed Templates
-
-[Tinyseed](../features/tinyseed.en.md) background of blank templates to be manually filled.
-
-<div style="clear: both"></div>
-
-<a href="../tiny_seed_scan_background.png">
- <img src="../tiny_seed_scan_background.png" alt="Tinyseed Scan Background" style="width: 15%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../tiny_seed_template.png">
- <img src="../tiny_seed_template.png" alt="Tinyseed Scan Background" style="width: 15%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../tiny_seed_template_24w.png">
- <img src="../tiny_seed_template_24w.png" alt="Tinyseed Scan Background" style="width: 22%; float: left; margin-left: 10px;">
-</a>
-
-
-<div style="clear: both"></div>
-
-## Binary Grid Templates
-
-[Binary Grid](../features/tinyseed.en.md) labeled and and "stealth" clean templates.
-
-<div style="clear: both"></div>
-
-<a href="../Krux_Binary_Grid_double_rev1.png">
- <img src="../Krux_Binary_Grid_double_rev1.png" alt="Tinyseed Scan Background" style="width: 30%; float: left; margin-left: 10px;">
-</a>
-
-<a href="../Krux_Binary_Grid_double_clean_rev1.png">
- <img src="../Krux_Binary_Grid_double_clean_rev1.png" alt="Tinyseed Scan Background" style="width: 30%; float: left; margin-left: 10px;">
-</a>
-
-<div style="clear: both"></div>
-
-[Binary Grid svg source](Krux_Binary_Grid_double_rev1.svg)
-
-[Binary Grid Clean svg source](Krux_Binary_Grid_double_clean_rev1.svg)
-
-## Edit Templates
-To edit the source file (.svg) it is recommended to use Inkscape and set it to use mm unit. "Unscaled models" from QR code templates have the 21x21 or 25x25mm size for 12 or 24 respectively, this way making them easier to edit.
### docs/getting-started/usage/generating-a-mnemonic.en.md
@@ -80,7 +80,14 @@ A low Shannon's entropy value could suggest that your dice are biased or that th
After sufficient entropy is given, you can manually add custom entropy by editing some of the words. Simply touch or navigate to the word you want to change and replace it. Edited words will be highlighted, and the final word will automatically update to ensure a valid checksum. However, proceed with caution, modifying words can negatively impact the natural entropy previously captured.
-On the next screen, you will be loading a wallet. You can read more about this in [Loading a Mnemonic -> Confirm Wallet Attributes](./loading-a-mnemonic.md/#confirm-wallet-attributes).
+On the next screen, review the wallet attributes. Select `Continue` to load
+the wallet with the displayed settings, or select `Wallet Options` to set a
+`Passphrase` or `Customize` the wallet.
+
+<img src="../../../img/maixpy_amigo/new-mnemonic-wallet-summary-300.png" class="amigo">
+<img src="../../../img/maixpy_m5stickv/new-mnemonic-wallet-summary-250.png" class="m5stickv">
+<img src="../../../img/maixpy_amigo/new-mnemonic-wallet-options-300.png" class="amigo">
+<img src="../../../img/maixpy_m5stickv/new-mnemonic-wallet-options-250.png" class="m5stickv">
<div style="clear: both"></div>
### docs/getting-started/usage/loading-a-mnemonic.en.md
@@ -31,7 +31,7 @@ You can also use [an offline QR code generator for this](https://iancoleman.io/b
#### Tinyseed, OneKey KeyTag or Binary Grid
[Tinyseed](https://tinyseed.io/), [Onekey KeyTag](https://onekey.so/products/onekey-keytag/) and others directly encode a seed as binary, allowing for a very compact mnemonic storage. Krux devices have machine vision capabilities that allow users to scan these metal plates and instantly load mnemonics engraved on them (this feature is not available in [Krux Mobile Android app](../../faq.md#what-is-krux-mobile-android-app)).
-To ensure a proper scan, place the backup plate over a black background and fill in the punched areas with black to enhance contrast. Alternatively, you can scan a [thermally printed version](../features/printing/printing.md) or a completed template. You can view some [examples of encoded mnemonics here](../features/tinyseed.md), and explore our [available transcription templates here](../templates/templates.md).
+To ensure a proper scan, place the backup plate over a black background and fill in the punched areas with black to enhance contrast. Alternatively, you can scan a [thermally printed version](../features/printing/printing.md) or a completed template. You can view some [examples of encoded mnemonics here](../features/tinyseed.md), and explore our [available transcription templates here](../templates/index.md).
### Via Manual Input
<img src="../../../img/maixpy_m5stickv/load-mnemonic-manual-options-250.png" align="right" class="m5stickv">
@@ -117,7 +117,10 @@ If you make a mistake while loading a mnemonic, you can easily edit it. Simply t
<img src="../../../img/maixpy_m5stickv/load-mnemonic-seq-overview-250.png" align="right" class="m5stickv">
<img src="../../../img/maixpy_amigo/load-mnemonic-seq-overview-300.png" align="right" class="amigo">
-After confirming your mnemonic, a screen with an **information box at the top** with the wallet's attributes is shown. If they are as expected, just press `Load Wallet`. If you need to change something you may customize the wallet by setting a `Passphrase` or using the `Customize` button.
+After confirming an existing mnemonic, a screen with an **information box at the
+top** shows the wallet's attributes. If they are as expected, just press
+`Load Wallet`. If you need to change something, you may customize the wallet by
+setting a `Passphrase` or using the `Customize` button.
<div style="clear: both"></div>
### docs/getting-started/usage/navigating-the-main-menu.en.md
@@ -96,11 +96,15 @@ Display the BIP39 mnemonic word numbers (1-2048) in decimal, hex, or octal forma
<img src="../../../img/maixpy_m5stickv/backup-stackbit-250.png" align="right" class="m5stickv">
<img src="../../../img/maixpy_amigo/backup-stackbit-300.png" align="right" class="amigo">
-
This metal backup format represents the BIP39 mnemonic word's numbers (1-2048). Each of the four digits is converted to a sum of 1, 2, 4 or 8. This option does not print even if a printer driver is set.
+<div style="clear: both"></div>
+<img src="../../../img/maixpy_m5stickv/backup-stackbit-vertical-250.png" align="right" class="m5stickv">
+<img src="../../../img/maixpy_amigo/backup-stackbit-vertical-300.png" align="right" class="amigo">
+Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits.
<div style="clear: both"></div>
+
- **Tinyseed**
<img src="../../../img/maixpy_m5stickv/backup-tiny-seed-250.png" align="right" class="m5stickv">
### docs/img/maixpy_amigo/backup-stackbit-vertical-300.png
[binary or diff unavailable]
### docs/img/maixpy_amigo/new-mnemonic-wallet-options-300.en.png
[binary or diff unavailable]
### docs/img/maixpy_amigo/new-mnemonic-wallet-summary-300.en.png
[binary or diff unavailable]
### docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png
[binary or diff unavailable]
### docs/img/maixpy_m5stickv/new-mnemonic-wallet-options-250.en.png
[binary or diff unavailable]
### docs/img/maixpy_m5stickv/new-mnemonic-wallet-summary-250.en.png
[binary or diff unavailable]
### docs/video-tutorials.en.md
@@ -19,6 +19,10 @@ Most people prefer to learn by watching videos, and we are fortunate to have exc
- [Faça sua hardware wallet em casa com a KRUX!](https://www.youtube.com/watch?v=1V6Lp0m8esc) — CAIOVSKI (Jun 2024)
- [MAIX CUBE + KRUX É UMA BOA CARTEIRA?](https://www.youtube.com/watch?v=8k2RivwnHUc) — DIG P2P - Bitcoin Para Iniciantes! (Nov 2025)
+## Spanish
+
+- [Cómo instalar Krux en Yahboom K210 + guía completa de uso](https://www.youtube.com/watch?v=jrYJD0VllYs) — Cripto Novedad (Mar 2026)
+
## Korean
- [Krux 월렛 설치 및 검증 방법(feat : 원더케이 비트코인 전용 하드월렛)](https://www.youtube.com/watch?v=7H1bI0A2y0w) — 봉현이형 (Oct 2024)
### firmware/MaixPy
@@ -1 +1 @@
-Subproject commit faf4aa9bff6a3e0e874f460288babf2154e0ce01
+Subproject commit 4c1c0880d7f77358084c123383bcbc7fac9a8fd2
### firmware/font/README.md
@@ -3,7 +3,7 @@ Krux uses a [custom fork](https://github.com/bachan/terminus-font-vietnamese) of
To rebuild the font for all devices, run:
```python
-poetry run python bdftokff.py True
+uv run python bdftokff.py True
```
If the `True` argument was passed, the Python script will automatically overwrite the contents of the `font_device.h` file in each of the projects `../MaixPy/projects/*/compile/overrides/components/micropython/port/src/omv/img/include/font_device.h`, otherwise the script will produce 3 files: `m5stickv_font_device.h`, `amigo_font_device.h` and `bit_dock_yahboom_font_device.h`. Use these files to manually replace the contents of the `font_device.h` file in each of your projects.
### firmware/font/bdftokff.py
@@ -50,12 +50,12 @@
SMALL_FONT_DEVICES_TO_COPY = ["cube"]
MID_FONT_DEVICES_TO_COPY = [
- "bit",
"yahboom",
"wonder_mv",
"tzt",
"wonder_k",
"yahboom_devkit",
+ "embed_fire",
]
BIG_FONT_DEVICES_TO_COPY = []
### i18n/README.md
@@ -16,7 +16,7 @@ Add a file in format `xy-WZ.json` in [translations](./translations), where `xy`
Execute:
```bash
-poetry install --extras docs
+uv sync --extra docs
```
### Configure translation
### i18n/i18n.py
@@ -23,7 +23,7 @@
import binascii
import sys
import json
-from os import listdir, walk, mkdir, remove, rmdir, getcwd, chdir
+from os import listdir, walk, mkdir, getcwd, chdir
from os.path import isfile, isdir, exists, join, basename
import re
@@ -56,9 +56,6 @@ def _get_translation_files_dir():
SRC_DIR = _get_src_dir()
TRANSLATION_FILES_DIR = _get_translation_files_dir()
-ELLIPSIS_UNICODE = "\u2026"
-ELLIPSIS_ASCII = "..."
-
KRUX_LICENSE = """# The MIT License (MIT)
# Copyright (c) 2021-2024 Krux contributors
@@ -144,147 +141,6 @@ def validate_translation_files():
sys.exit(1)
-def post_process_translation(slug, translation, verbose=False):
- """
- A place for post-translation fixes of poor translations per slug/translation
- returns original -- or corrected translation
- """
- err = None
-
- translation = translation.replace(ELLIPSIS_ASCII, ELLIPSIS_UNICODE)
- translation = translation.replace("(", "(")
- translation = translation.replace(")", ")")
- translation = translation.replace("。", ".")
- translation = translation.replace(",", ",")
- translation = translation.replace(":", ":")
- translation = translation.replace("?", "?")
- translation = translation.replace("!", "!")
- translation = translation.replace(" ", " ") # non-breaking space to thin-space
-
- # fix poorly translated newlines
- if " \\ n" in translation:
- err = "Poor newline translation: {}, {}".format(repr(slug), repr(translation))
- translation = translation.replace(" \\ n", "\\n")
-
- # fix poorly translated unicode ellipsis
- ellipsis = ELLIPSIS_UNICODE
- if slug[-1] == ellipsis:
- err = "Poor ellipsis translation: {}, {}".format(repr(slug), repr(translation))
- if translation[-2:] == ellipsis * 2:
- translation = translation[:-1]
- elif translation[-4:] == "." * 4:
- translation = translation[:-4] + ellipsis
- elif translation[-3:] == "." * 3:
- translation = translation[:-3] + ellipsis
- elif translation[-1:] in (".", " "):
- translation = translation[:-1] + ellipsis
- elif translation[-1] != ellipsis:
- translation = translation + ellipsis
- else:
- err = None # translation was fine
-
- if verbose and err:
- print(err, file=sys.stderr)
-
- return translation
-
-
-def print_missing(save_to_file=False, merge_after=False):
- """
- Uses translate 3.6.1 to automatically print missing translations
- and optionally save them to files
- """
- if len(sys.argv) > 2:
- force_target = sys.argv[2]
- else:
- force_target = None
- from translate import Translator
-
- slugs = find_translation_slugs()
- translation_filenames = [
- f
- for f in listdir(TRANSLATION_FILES_DIR)
- if isfile(join(TRANSLATION_FILES_DIR, f))
- ]
-
- filled_dir = join(TRANSLATION_FILES_DIR, "filled")
- if save_to_file and not exists(filled_dir):
- mkdir(filled_dir)
-
- for translation_filename in sorted(translation_filenames):
- target = translation_filename[:5]
- if force_target:
- if not force_target in translation_filename:
- continue
- translator = Translator(to_lang=target)
- print("Translating %s...\n" % translation_filename)
- complete = True
- new_translations = {}
- with open(
- join(TRANSLATION_FILES_DIR, translation_filename), "r", encoding="utf8"
- ) as translation_file:
- translations = load_translations(translation_file)
- for slug in slugs:
- if slug not in translations or translations[slug] == "":
- try:
- slug = slug.replace(ELLIPSIS_UNICODE, ELLIPSIS_ASCII)
- translated = translator.translate(slug)
- slug = slug.replace(ELLIPSIS_ASCII, ELLIPSIS_UNICODE)
- translated = post_process_translation(
- slug, translated, verbose=(save_to_file or merge_after)
- )
- print('"%s":' % slug, '"%s",' % translated)
- new_translations[slug] = translated
-
- except Exception as e:
- print("Error:", e)
- print("Failed to translate:", slug)
- break
- complete = False
- if complete:
- print("Nothing to add")
- else:
- print("\n -- Please review and copy items above -- ")
- if save_to_file:
- with open(
- join(filled_dir, translation_filename),
- "w",
- encoding="utf8",
- newline="\n",
- ) as filled_file:
- json.dump(
- new_translations, filled_file, ensure_ascii=False, indent=4
- )
- print(f"Saved translations to {join(filled_dir, translation_filename)}")
- if merge_after:
- translations.update(new_translations)
- with open(
- join(TRANSLATION_FILES_DIR, translation_filename),
- "w",
- encoding="utf8",
- newline="\n",
- ) as file:
- json.dump(
- translations,
- file,
- ensure_ascii=False,
- indent=4,
- sort_keys=True,
- )
- print(
- f"Saved translations to {join(TRANSLATION_FILES_DIR, translation_filename)}"
- )
-
- remove(join(filled_dir, translation_filename))
- print(
- f"Removed translation {join(filled_dir, translation_filename)}"
- )
- print("\n\n")
-
- if merge_after:
- rmdir(filled_dir)
-
-
def remove_unnecessary():
"""Remove unnecessary translations from files"""
code_slugs = find_translation_slugs()
@@ -431,44 +287,17 @@ def prettify_translation_files():
if len(sys.argv) < 2:
raise ValueError(
"ERROR: Provide one action as argument"
- + " (validate, new, fill, fill_merge, clean, prettify, bake)"
+ + " (validate, new, clean, prettify, bake)"
)
- if sys.argv[1] in ("new", "fill"):
- if sys.argv[1] == "new":
- if len(sys.argv) < 3:
- raise ValueError("ERROR: Provide the locale to fill")
- create_translation_file(sys.argv[2])
- else:
- print_missing()
+ if sys.argv[1] == "new":
+ if len(sys.argv) < 3:
+ raise ValueError("ERROR: Provide the locale to create")
+ create_translation_file(sys.argv[2])
else:
for arg in sys.argv[1:]:
if arg == "validate":
validate_translation_files()
- elif arg == "fill":
- print_missing()
- elif [
- True
- for text in (
- "fill_to_files",
- "fill-to-files",
- "fill_files",
- "fill-files",
- )
- if arg in text
- ]:
- print_missing(save_to_file=True)
- elif [
- True
- for text in (
- "fill_and_merge",
- "fill-and-merge",
- "fill_merge",
- "fill-merge",
- )
- if arg in text
- ]:
- print_missing(save_to_file=True, merge_after=True)
elif arg == "clean":
remove_unnecessary()
elif arg == "prettify":
### i18n/translations/de-DE.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "Überprüfte %d Adresse ohne Übereinstimmungen.",
"Checking for SD card…": "SD-Karte wird gesucht…",
"Confirm Tamper Check Code": "Bestätigen Sie den Tamper Check Code",
+ "Continue": "Weiter",
"Convert Datum": "Datum konvertieren",
"Could not determine change address.": "Änderungsadresse konnte nicht ermittelt werden.",
"Create QR Code": "QR Code erstellen",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Einige Knoten sind nicht gehärtet:",
"Spend (%d):": "Ausgabe (%d):",
"Spend:": "Ausgaben:",
+ "Standard": "Standard",
"Standard mode": "Standardmodus",
"Static": "Statisch",
"Stats for Nerds": "Statistiken für Nerds",
@@ -305,6 +307,7 @@
"Test Suite Results": "Ergebnisse der Testsuite",
"Test:": "Test:",
"Text": "Text",
+ "The fee shown may be lower than the real fee.": "Die angezeigte Gebühr kann niedriger als die tatsächliche Gebühr sein.",
"Theme": "Thema",
"Thermal": "Thermisch",
"To ensure data is unrecoverable use Wipe Device feature": "Um sicherzustellen, dass die Daten nicht wiederhergestellt werden können, verwenden Sie die Funktion 'Gerät löschen'",
@@ -317,6 +320,7 @@
"Type Key": "Schlüssel eingeben",
"Undo": "Widerrufen",
"Unit": "Einheit",
+ "Unverified input amounts!": "Unverifizierte Input-Beträge!",
"Update KEF ID?": "KEF-ID aktualisieren?",
"Update QR Label?": "QR-Etikett aktualisieren?",
"Upgrade complete.": "Upgrade abgeschlossen.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Wert %S außerhalb des Bereichs: [ %s, %s]",
"Verifying…": "Überprüfung…",
"Version": "Version",
+ "Vertical": "Vertikal",
"Via Camera": "Via Kamera",
"Via D20": "Via D20",
"Via D6": "Via D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Warte auf die Erfassung",
"Wallet": "Wallet",
"Wallet Descriptor": "Wallet-Deskriptor",
+ "Wallet Options": "Wallet-Optionen",
"Wallet mismatch:": "Geldbörse passt nicht:",
"Wallet output descriptor": "Wallet Ausgabedeskriptor",
"Wallet output descriptor loaded!": "Wallet Ausgabedeskriptor geladen!",
### i18n/translations/es-MX.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "Comprobado %d direcciones sin coincidencias.",
"Checking for SD card…": "Buscando tarjeta SD…",
"Confirm Tamper Check Code": "Confirmar el código de verificación",
+ "Continue": "Continuar",
"Convert Datum": "Convertir dato",
"Could not determine change address.": "No se pudo determinar la dirección de cambio.",
"Create QR Code": "Crear código QR",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Algunos nodos no están endurecidos:",
"Spend (%d):": "Gastos (%d):",
"Spend:": "Gasto:",
+ "Standard": "Estándar",
"Standard mode": "Modo estándar",
"Static": "Estático",
"Stats for Nerds": "Estadísticas para Entendidos",
@@ -305,6 +307,7 @@
"Test Suite Results": "Resultados de la suite de pruebas",
"Test:": "Prueba:",
"Text": "Texto",
+ "The fee shown may be lower than the real fee.": "La comisión mostrada puede ser menor que la comisión real.",
"Theme": "Tema",
"Thermal": "Térmico",
"To ensure data is unrecoverable use Wipe Device feature": "Para garantizar que los datos no se puedan recuperar, utiliza la función de borrar dispositivo",
@@ -317,6 +320,7 @@
"Type Key": "Introduce la clave",
"Undo": "Deshacer",
"Unit": "Unidad",
+ "Unverified input amounts!": "¡Montos de entrada no verificados!",
"Update KEF ID?": "¿Actualizar ID de Kef?",
"Update QR Label?": "¿Actualizar etiqueta QR?",
"Upgrade complete.": "Actualización completa.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Valor %s fuera del rango: [ %s, %s]",
"Verifying…": "Verificando…",
"Version": "Versión",
+ "Vertical": "Vertical",
"Via Camera": "Desde Cámara",
"Via D20": "Vía D20",
"Via D6": "Vía D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Espera la captura",
"Wallet": "Cartera",
"Wallet Descriptor": "Descriptor de Cartera",
+ "Wallet Options": "Opciones de cartera",
"Wallet mismatch:": "Cartera no coincide:",
"Wallet output descriptor": "Descriptor de salida de cartera",
"Wallet output descriptor loaded!": "¡Se ha cargado el descriptor de salida de la cartera!",
### i18n/translations/fr-FR.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "%d adresses vérifiées sans correspondance.",
"Checking for SD card…": "Recherche de carte SD…",
"Confirm Tamper Check Code": "Confirmer le code de non compromis",
+ "Continue": "Continuer",
"Convert Datum": "Convertir le datum",
"Could not determine change address.": "Impossible de déterminer l'adresse de monnaie.",
"Create QR Code": "Créer un QR Code",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Certains nœuds ne sont pas durcis :",
"Spend (%d):": "Dépense (%d) :",
"Spend:": "Dépense :",
+ "Standard": "Standard",
"Standard mode": "Mode standard",
"Static": "Statique",
"Stats for Nerds": "Statistiques pour les geeks",
@@ -305,6 +307,7 @@
"Test Suite Results": "Résultats de la suite de tests",
"Test:": "Test:",
"Text": "Texte",
+ "The fee shown may be lower than the real fee.": "Les frais affichés peuvent être inférieurs aux frais réels.",
"Theme": "Thème",
"Thermal": "Thermique",
"To ensure data is unrecoverable use Wipe Device feature": "Pour assurer que les données soient irrécupérables, utilisez la fonctionnalité 'Effacer l'appareil'",
@@ -317,6 +320,7 @@
"Type Key": "Taper clé",
"Undo": "Annuler",
"Unit": "Unité",
+ "Unverified input amounts!": "Montants d'entrée non vérifiés !",
"Update KEF ID?": "Mettre à jour l'ID KEF ?",
"Update QR Label?": "Mettre à jour l'étiquette QR ?",
"Upgrade complete.": "Mise à jour complète.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Valeur %s hors de portée: [%s, %s]",
"Verifying…": "Vérification…",
"Version": "Version",
+ "Vertical": "Vertical",
"Via Camera": "Par caméra",
"Via D20": "Via D20",
"Via D6": "Via D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Attendez la capture",
"Wallet": "Portefeuille",
"Wallet Descriptor": "Descripteur de Portefeuille",
+ "Wallet Options": "Options du portefeuille",
"Wallet mismatch:": "Portefeuille différent:",
"Wallet output descriptor": "Descripteur de sortie du portefeuille",
"Wallet output descriptor loaded!": "Descripteur de sortie du portefeuille chargé !",
### i18n/translations/ja-JP.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "%d のアドレスを確認しましたが、一致するものはありませんでした.",
"Checking for SD card…": "SDカードを確認しています…",
"Confirm Tamper Check Code": "改ざんチェックコードの確認",
+ "Continue": "続行",
"Convert Datum": "データムの変換",
"Could not determine change address.": "変更先住所を特定できませんでした.",
"Create QR Code": "QRコードを作成",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "一部のノードは硬化されていません:",
"Spend (%d):": "支出(%d):",
"Spend:": "支出:",
+ "Standard": "標準",
"Standard mode": "標準モード",
"Static": "静止画",
"Stats for Nerds": "オタクのための統計",
@@ -305,6 +307,7 @@
"Test Suite Results": "テストスイートの結果",
"Test:": "テスト:",
"Text": "テキスト",
+ "The fee shown may be lower than the real fee.": "手数料は表示より高いかもしれません.",
"Theme": "テーマ",
"Thermal": "サーマル",
"To ensure data is unrecoverable use Wipe Device feature": "データが復元不可能であることを確実にするには、デバイス消去機能を使用してください",
@@ -317,6 +320,7 @@
"Type Key": "キーを入力する",
"Undo": "取り消し",
"Unit": "ユニット",
+ "Unverified input amounts!": "未検証のインプット金額!",
"Update KEF ID?": "KEF IDを更新しますか?",
"Update QR Label?": "QRラベルを更新しますか?",
"Upgrade complete.": "アップグレードが完了しました.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "値%sが範囲外です: [ %s, %s]",
"Verifying…": "認証中…",
"Version": "バージョン",
+ "Vertical": "縦向き",
"Via Camera": "カメラ経由",
"Via D20": "D20経由",
"Via D6": "D6経由",
@@ -340,6 +345,7 @@
"Wait for the capture": "キャプチャを待ってください",
"Wallet": "ワレット",
"Wallet Descriptor": "ウォレットディスクリプター",
+ "Wallet Options": "ウォレットオプション",
"Wallet mismatch:": "ウォレット不一致:",
"Wallet output descriptor": "ウォレット出力ディスクリプター",
"Wallet output descriptor loaded!": "ウォレット出力ディスクリプターがロードされました!",
### i18n/translations/ko-KR.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "일치하는 주소가 없는 %d 개를 확인했습니다.",
"Checking for SD card…": "SD 카드 확인 중…",
"Confirm Tamper Check Code": "탬퍼 체크 코드 확인",
+ "Continue": "계속",
"Convert Datum": "날짜 변환",
"Could not determine change address.": "변경 주소를 확인할 수 없습니다.",
"Create QR Code": "QR 코드 생성",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "일부 노드가 경화되지 않습니다:",
"Spend (%d):": "Spend (%d):",
"Spend:": "지출:",
+ "Standard": "표준",
"Standard mode": "표준 모드",
"Static": "Static",
"Stats for Nerds": "전문가를 위한 통계",
@@ -305,6 +307,7 @@
"Test Suite Results": "테스트 제품군 결과",
"Test:": "Test:",
"Text": "텍스트",
+ "The fee shown may be lower than the real fee.": "표시된 수수료가 실제 수수료보다 적을 수 있습니다.",
"Theme": "테마",
"Thermal": "Thermal",
"To ensure data is unrecoverable use Wipe Device feature": "데이터 복구가 불가능하도록 장치 전체지우기 기능을 사용하십시오",
@@ -317,6 +320,7 @@
"Type Key": "비밀번호 입력",
"Undo": "실행 취소",
"Unit": "단위",
+ "Unverified input amounts!": "검증되지 않은 입력 값!",
"Update KEF ID?": "KEF ID를 업데이트하시겠습니까?",
"Update QR Label?": "QR 레이블을 업데이트하시겠습니까?",
"Upgrade complete.": "업그레이드가 완료되었습니다.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "%s는 [%s, %s] 범위를 벗어났습니다",
"Verifying…": "확인…",
"Version": "버전",
+ "Vertical": "세로",
"Via Camera": "카메라",
"Via D20": "20면체 주사위",
"Via D6": "일반 주사위",
@@ -340,6 +345,7 @@
"Wait for the capture": "캡처될때까지 기다리십시오",
"Wallet": "지갑 설정",
"Wallet Descriptor": "지갑 디스크립터",
+ "Wallet Options": "지갑 옵션",
"Wallet mismatch:": "지갑 불일치:",
"Wallet output descriptor": "지갑 출력 디스크립터",
"Wallet output descriptor loaded!": "지갑 출력 디스크립터가 로드되었습니다!",
### i18n/translations/nl-NL.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "%d adressen gecontroleerd zonder overeenkomsten.",
"Checking for SD card…": "Controleren op SD-kaart…",
"Confirm Tamper Check Code": "Bevestig de sabotagecontrolecode",
+ "Continue": "Doorgaan",
"Convert Datum": "Datum converteren",
"Could not determine change address.": "Kan adreswijziging niet bepalen.",
"Create QR Code": "QR-code aanmaken",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Sommige knooppunten zijn niet gehard:",
"Spend (%d):": "Uitgaven (%d):",
"Spend:": "Uitgaven:",
+ "Standard": "Standaard",
"Standard mode": "Standaardmodus",
"Static": "Statisch",
"Stats for Nerds": "Statistieken voor nerds",
@@ -305,6 +307,7 @@
"Test Suite Results": "Test Suite-resultaten",
"Test:": "Test:",
"Text": "Tekst",
+ "The fee shown may be lower than the real fee.": "Het getoonde tarief kan lager zijn dan het werkelijke tarief.",
"Theme": "Thema",
"Thermal": "Thermisch",
"To ensure data is unrecoverable use Wipe Device feature": "Gebruik de functie 'Apparaat wissen' om te zorgen dat de gegevens onherstelbaar zijn",
@@ -317,6 +320,7 @@
"Type Key": "Voer sleutel in",
"Undo": "Ongedaan maken",
"Unit": "Eenheid",
+ "Unverified input amounts!": "Niet-geverifieerde invoerbedragen!",
"Update KEF ID?": "KEF-ID bijwerken?",
"Update QR Label?": "QR-label bijwerken?",
"Upgrade complete.": "Upgrade afgerond.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Waarde %s is buiten bereik: [%s, %s]",
"Verifying…": "Controleren…",
"Version": "Versie",
+ "Vertical": "Verticaal",
"Via Camera": "Via camera",
"Via D20": "Via D20",
"Via D6": "Via D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Wacht op opname",
"Wallet": "Portemonnee",
"Wallet Descriptor": "Descriptor",
+ "Wallet Options": "Portemonneeopties",
"Wallet mismatch:": "Portemonnee onjuist:",
"Wallet output descriptor": "Portemonnee descriptor",
"Wallet output descriptor loaded!": "Portemonnee descriptor geladen!",
### i18n/translations/pt-BR.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "%d endereços checados sem correspondência.",
"Checking for SD card…": "Procurando por cartão SD…",
"Confirm Tamper Check Code": "Confirmar código de verificação de integridade",
+ "Continue": "Continuar",
"Convert Datum": "Converter dados",
"Could not determine change address.": "Não foi possível determinar endereços de troco.",
"Create QR Code": "Criar Código QR",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Alguns nós não são hardened:",
"Spend (%d):": "Gastos (%d):",
"Spend:": "Gasto:",
+ "Standard": "Padrão",
"Standard mode": "Modo padrão",
"Static": "Estático",
"Stats for Nerds": "Estatísticas para nerds",
@@ -305,6 +307,7 @@
"Test Suite Results": "Resultados da suíte de testes",
"Test:": "Teste:",
"Text": "Texto",
+ "The fee shown may be lower than the real fee.": "A taxa exibida pode ser menor que a taxa real.",
"Theme": "Tema",
"Thermal": "Térmica",
"To ensure data is unrecoverable use Wipe Device feature": "Para garantir que os dados sejam irrecuperáveis, use o recurso Limpar Dispositivo",
@@ -317,6 +320,7 @@
"Type Key": "Digite a Chave",
"Undo": "Desfazer",
"Unit": "Unidade",
+ "Unverified input amounts!": "Valores de entrada não verificados!",
"Update KEF ID?": "Atualizar KEF ID?",
"Update QR Label?": "Atualizar etiqueta QR?",
"Upgrade complete.": "Atualização concluída.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Valor %s fora do intervalo: [%s, %s]",
"Verifying…": "Checando…",
"Version": "Versão",
+ "Vertical": "Vertical",
"Via Camera": "Pela Câmera",
"Via D20": "Via D20",
"Via D6": "Via D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Aguarde a captura",
"Wallet": "Carteira",
"Wallet Descriptor": "Descritor da Carteira",
+ "Wallet Options": "Opções da carteira",
"Wallet mismatch:": "Carteira diferente:",
"Wallet output descriptor": "Descritor da carteira",
"Wallet output descriptor loaded!": "Descritor da carteira carregado!",
### i18n/translations/ru-RU.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "Проверено %d адресов без совпадений.",
"Checking for SD card…": "Проверка SD-карты…",
"Confirm Tamper Check Code": "Подтвердите код проверки вскрытия",
+ "Continue": "Продолжить",
"Convert Datum": "Преобразовать датум",
"Could not determine change address.": "Не удалось определить адрес изменения.",
"Create QR Code": "Создать QR-код",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Некоторые узлы не укреплены:",
"Spend (%d):": "Расход (%d):",
"Spend:": "Расход:",
+ "Standard": "Стандартный",
"Standard mode": "Стандартный режим",
"Static": "Static / Статическое оборудование",
"Stats for Nerds": "Статистика для Гиков",
@@ -305,6 +307,7 @@
"Test Suite Results": "Результаты набора тестов",
"Test:": "Испыт.:",
"Text": "Текст",
+ "The fee shown may be lower than the real fee.": "Показанная комиссия может быть ниже реальной комиссии.",
"Theme": "Тема",
"Thermal": "Термальный",
"To ensure data is unrecoverable use Wipe Device feature": "Для гарантии невосстановления данных используйте функцию Очистки Устройства",
@@ -317,6 +320,7 @@
"Type Key": "Ввести Ключ",
"Undo": "Отменить",
"Unit": "Единица Измерения",
+ "Unverified input amounts!": "Непроверенные суммы входов!",
"Update KEF ID?": "Обновить идентификатор KEF?",
"Update QR Label?": "Обновить QR-метку?",
"Upgrade complete.": "Обновление завершено.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Значение %s вне диапозона: [%s, %s]",
"Verifying…": "Верификация…",
"Version": "Версия",
+ "Vertical": "Вертикальный",
"Via Camera": "С Помощью Камеры",
"Via D20": "С Помощью D20",
"Via D6": "С Помощью D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "Дождитесь Захвата",
"Wallet": "Кошелек",
"Wallet Descriptor": "Дескриптор Кошелька",
+ "Wallet Options": "Параметры кошелька",
"Wallet mismatch:": "Кошелёк не совпадает:",
"Wallet output descriptor": "Выходной дескриптор кошелька",
"Wallet output descriptor loaded!": "Выходной дескриптор кошелька загружен!",
### i18n/translations/tr-TR.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "Eşleşmeyen %d adres kontrol edildi.",
"Checking for SD card…": "SD kart kontrol ediliyor…",
"Confirm Tamper Check Code": "Kurcalama Kontrol Kodunu Onayla",
+ "Continue": "Devam",
"Convert Datum": "Veriyi Dönüştür",
"Could not determine change address.": "Değişiklik adresi belirlenemedi.",
"Create QR Code": "QR Kodu Oluştur",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Bazı düğümler sertleştirilmemiş:",
"Spend (%d):": "Harcama (%d):",
"Spend:": "Harcama:",
+ "Standard": "Standart",
"Standard mode": "Standart Mod",
"Static": "Statik",
"Stats for Nerds": "İnekler İçin İstatistikler",
@@ -305,6 +307,7 @@
"Test Suite Results": "Test Paketi Sonuçları",
"Test:": "Test:",
"Text": "Metin",
+ "The fee shown may be lower than the real fee.": "Gösterilen ücret gerçek ücretten düşük olabilir.",
"Theme": "Tema",
"Thermal": "Termal",
"To ensure data is unrecoverable use Wipe Device feature": "Verilerin geri kullanılamaz olduğundan emin olmak için Cihazı Sil özelliğini kullanın",
@@ -317,6 +320,7 @@
"Type Key": "Anahtar Yaz",
"Undo": "Geri Al",
"Unit": "Birim",
+ "Unverified input amounts!": "Doğrulanmamış giriş tutarları!",
"Update KEF ID?": "Kef Kimliği Güncellensin mi?",
"Update QR Label?": "QR Etiketi Güncellensin mi",
"Upgrade complete.": "Güncelleme tamamlandı.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "%s değeri aralık dışında: [%s, %s]",
"Verifying…": "Doğrulanıyor…",
"Version": "Sürüm",
+ "Vertical": "Dikey",
"Via Camera": "Kamera Aracılığıyla",
"Via D20": "D20 Aracılığıyla",
"Via D6": "D6 Aracılığıyla",
@@ -340,6 +345,7 @@
"Wait for the capture": "Yakalamanın tamamlanmasını bekleyin",
"Wallet": "Cüzdan",
"Wallet Descriptor": "Cüzdan Tanımlayıcısı",
+ "Wallet Options": "Cüzdan Seçenekleri",
"Wallet mismatch:": "Cüzdan uyuşmazlığı:",
"Wallet output descriptor": "Cüzdan çıktı tanımlayıcısı",
"Wallet output descriptor loaded!": "Cüzdan çıktı tanımlayıcısı yüklendi!",
### i18n/translations/vi-VN.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "Đã kiểm tra %d địa chỉ không khớp.",
"Checking for SD card…": "Đang kiểm tra thẻ SD…",
"Confirm Tamper Check Code": "Xác nhận mã kiểm tra giả mạo",
+ "Continue": "Tiếp tục",
"Convert Datum": "Chuyển đổi dữ liệu",
"Could not determine change address.": "Không thể xác định địa chỉ thay đổi.",
"Create QR Code": "Tạo mã QR",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "Một số nút không được làm cứng:",
"Spend (%d):": "Chi tiêu (%d):",
"Spend:": "Chi tiêu:",
+ "Standard": "Tiêu chuẩn",
"Standard mode": "Chế độ Tiêu chuẩn",
"Static": "Tĩnh",
"Stats for Nerds": "Số liệu thống kê cho Mọt sách",
@@ -305,6 +307,7 @@
"Test Suite Results": "Kết quả bộ thử nghiệm",
"Test:": "Kiểm tra bài cũ:",
"Text": "Văn bản",
+ "The fee shown may be lower than the real fee.": "Phí hiển thị có thể thấp hơn phí thực tế.",
"Theme": "Chủ đề",
"Thermal": "Nhiệt",
"To ensure data is unrecoverable use Wipe Device feature": "Sử dụng tính năng Xóa dữ liệu trên thiết bị để đảm bảo dữ liệu không thể phục hồi",
@@ -317,6 +320,7 @@
"Type Key": "Nhập khóa",
"Undo": "Hoàn tác",
"Unit": "Đơn vị",
+ "Unverified input amounts!": "Số tiền đầu vào chưa được xác minh!",
"Update KEF ID?": "Cập nhật ID KEF?",
"Update QR Label?": "Cập nhật nhãn QR?",
"Upgrade complete.": "Nâng cấp hoàn tất.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "Giá trị %s ngoài phạm vi: [ %s, %s]",
"Verifying…": "Xác minh…",
"Version": "Phiên Bản",
+ "Vertical": "Dọc",
"Via Camera": "Qua máy ảnh",
"Via D20": "Qua xúc xắc 20 mặt",
"Via D6": "Qua xúc xắc 6 mặt",
@@ -340,6 +345,7 @@
"Wait for the capture": "Chờ bắt",
"Wallet": "Ví",
"Wallet Descriptor": "Trình mô tả ví",
+ "Wallet Options": "Tùy chọn ví",
"Wallet mismatch:": "Ví không khớp:",
"Wallet output descriptor": "Ví đầu ra mô tả",
"Wallet output descriptor loaded!": "Đã tải bộ mô tả đầu ra của ví!",
### i18n/translations/zh-CN.json
@@ -46,6 +46,7 @@
"Checked %d addresses with no matches.": "已检查 %d 个不匹配的地址.",
"Checking for SD card…": "检查卡…",
"Confirm Tamper Check Code": "确认防篡改检查码",
+ "Continue": "继续",
"Convert Datum": "转换基准",
"Could not determine change address.": "无法确定更改地址.",
"Create QR Code": "创建二维码",
@@ -284,6 +285,7 @@
"Some nodes are not hardened:": "有些节点未硬化:",
"Spend (%d):": "花费 (%d):",
"Spend:": "花费",
+ "Standard": "标准",
"Standard mode": "标准模式",
"Static": "Static 静态?",
"Stats for Nerds": "极客统计数据",
@@ -305,6 +307,7 @@
"Test Suite Results": "测试套件结果",
"Test:": "测试:",
"Text": "文本",
+ "The fee shown may be lower than the real fee.": "费用可能高于显示的金额.",
"Theme": "主题",
"Thermal": "热敏",
"To ensure data is unrecoverable use Wipe Device feature": "要确保数据不可恢复,请使用擦除设备功能",
@@ -317,6 +320,7 @@
"Type Key": "输入私钥",
"Undo": "撤销",
"Unit": "单位",
+ "Unverified input amounts!": "未验证的输入金额!",
"Update KEF ID?": "更新KEF ID ?",
"Update QR Label?": "更新二维码标签?",
"Upgrade complete.": "升级已完成.",
@@ -331,6 +335,7 @@
"Value %s out of range: [%s, %s]": "值 %s 超出范围:[ %s,%s ]",
"Verifying…": "验证中…",
"Version": "版本",
+ "Vertical": "垂直",
"Via Camera": "通过摄像头",
"Via D20": "通过 D20",
"Via D6": "通过 D6",
@@ -340,6 +345,7 @@
"Wait for the capture": "等待截取",
"Wallet": "钱包",
"Wallet Descriptor": "钱包描述",
+ "Wallet Options": "钱包选项",
"Wallet mismatch:": "钱包不匹配:",
"Wallet output descriptor": "钱包输出描述符",
"Wallet output descriptor loaded!": "钱包输出描述符加载重复!",
### mkdocs.yml
@@ -48,7 +48,7 @@ edit_uri: edit/main/docs
docs_dir: docs
site_dir: public
extra:
- latest_krux: krux-v26.04.0
+ latest_krux: krux-v26.08.0
latest_installer: v0.0.21
latest_installer_rpm: krux_installer-0.0.21-1.x86_64.rpm
latest_installer_deb: krux_installer_0.0.21_amd64.deb
@@ -117,7 +117,8 @@ nav:
- Mnemonic XOR: getting-started/features/mnemonic-xor.en.md
- Interface:
- Settings: getting-started/settings.en.md
- - Templates: getting-started/templates/templates.en.md
+ - Templates:
+ - getting-started/templates/index.en.md
- Video Tutorials: video-tutorials.en.md
- Devices and Parts List: parts.en.md
- FAQ: faq.en.md
### poetry.lock
[binary or diff unavailable]
### pyproject.toml
@@ -1,6 +1,6 @@
# The MIT License (MIT)
-# Copyright (c) 2021-2023 Krux contributors
+# Copyright (c) 2021-2026 Krux contributors
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,58 +20,71 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
-[tool.poetry]
+[project]
name = "krux"
-version = "26.04.0"
+version = "26.08.0"
description = "Open-source signing device firmware for Bitcoin"
-authors = ["Jeff S <jeffreesun@protonmail.com>"]
+authors = [{ name = "Jeff S", email = "jeffreesun@protonmail.com" }]
readme = "README.md"
-license = "MIT"
-
-[tool.poetry.dependencies]
-python = "^3.12.3"
-embit = { path = "./vendor/embit/", develop = true }
-ur = { path = "./vendor/foundation-ur-py/", develop = true }
-urtypes = { path = "./vendor/urtypes/", develop = true }
-
-# Docs site dependencies. Optional extras
-mkdocs = { version = "^1.6.1", optional = true }
-mkdocs-material = { version = "^9.7.5", optional = true }
-mkdocs-static-i18n = { version = "^1.3.1", optional = true }
-pymdown-extensions = { version = "^10.21", optional = true }
-mkdocs-macros-plugin = { version = "^1.5.0", optional = true }
-
-# Simulator dependencies. Optional extras
-numpy = { version = "^2.4.3", optional = true }
-opencv-python = { version = "^4.13.0.92", optional = true }
-Pillow = { version = "^12.1.1", optional = true }
-pygame = { version = "^2.6.1", optional = true }
-pyzbar = { version = "^0.1.9", optional = true }
-
-# Flash dependencies. Optional extras
-pyserial = { version = "^3.5", optional = true }
-
-# Sign release script via QR Codes. Optional extras
-qrcode = { version = "^8.2", optional = true }
-
-[tool.poetry.group.dev.dependencies]
-black = "^26.3.1"
-pylint = "^4.0.5"
-pytest = "^9.0.2"
-pytest-cov = "^7.0.0"
-pytest-mock = "^3.15.1"
-PyQRCode = "^1.2.1"
-pycryptodome = "^3.23.0"
-poethepoet = "^0.42.1"
-vulture = "^2.15"
-translate = "^3.8.0"
-urllib3 = ">=2.6.3"
-
-[tool.poetry.extras]
-docs = ["mkdocs", "mkdocs-material", "mkdocs-static-i18n", "pymdown-extensions", "mkdocs-macros-plugin"]
-simulator = ["numpy", "opencv-python", "Pillow", "pygame", "pyzbar"]
-flash = ["pyserial"]
-sign = ["qrcode"]
+license = { text = "MIT" }
+requires-python = ">=3.11.5, <3.13"
+dependencies = [
+ "embit",
+ "uUR",
+]
+
+[project.optional-dependencies]
+docs = [
+ "mkdocs>=1.6.1,<2",
+ "mkdocs-material>=9.7.5,<10",
+ "mkdocs-static-i18n>=1.3.1,<2",
+ "pymdown-extensions>=10.21,<11",
+ "mkdocs-macros-plugin>=1.5.0,<2",
+]
+simulator = [
+ "numpy>=2.4.3,<3",
+ "opencv-python>=4.13.0.92,<5",
+ "Pillow>=12.2.0,<13",
+ "pygame>=2.6.1,<3",
+ "pyzbar>=0.1.9,<1",
+]
+flash = [
+ "pyserial>=3.5,<4",
+]
+sign = [
+ "qrcode>=8.2,<9",
+]
+
+[dependency-groups]
+dev = [
+ "black>=26.3.1,<27",
+ "pylint>=4.0.5,<5",
+ "pytest>=9.0.2,<10",
+ "pytest-cov>=7.0.0,<8",
+ "pytest-mock>=3.15.1,<4",
+ "PyQRCode>=1.2.1,<2",
+ "pycryptodome>=3.23.0,<4",
+ "poethepoet>=0.42.1,<1",
+ "vulture>=2.15,<3",
+ "urllib3>=2.6.3",
+]
+
+# krux is firmware, not a distributable Python package
+[tool.uv]
+package = false
+
+[tool.uv.sources]
+embit = { path = "vendor/embit", editable = true }
+# Native BC-UR module, the same cUR sources the devices run. Not editable: it is
+# a C extension, so it has to be rebuilt (uv sync --reinstall-package uUR) after
+# changing the submodule anyway.
+uUR = { path = "firmware/MaixPy/components/micropython/port/src/bc-ur" }
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+
+[tool.poe.env]
+PYTHONPATH = "${POE_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
[tool.poe.tasks]
# format tasks
@@ -96,6 +109,25 @@ vulture.ref = "vulture-src vulture_whitelist.py"
vulture-make = { shell = "vulture src --make-whitelist > vulture_whitelist.py" }
vulture-whitelist.ref = "vulture-make"
+# secp256k1 tasks
+# Builds the libsecp256k1-zkp pinned by the embit submodule and drops it where
+# embit's ctypes backend looks for it. Without it embit falls back to its pure
+# Python EC implementation, which is slower and does not always behave like the
+# C library the firmware runs.
+secp256k1-build = { shell = """
+make -C vendor/embit/secp256k1
+mkdir -p vendor/embit/src/embit/util/prebuilt
+cp vendor/embit/secp256k1/build/libsecp256k1_* vendor/embit/src/embit/util/prebuilt/
+""" }
+secp256k1-check = { shell = '''python -c "
+import sys
+from embit.util import secp256k1
+
+using_c = hasattr(secp256k1, '_secp')
+print('embit secp256k1 backend:', 'C library' if using_c else 'pure Python')
+sys.exit(0 if using_c else 1)
+"''' }
+
# test tasks
test-clean = """python -c 'import shutil, os; os.path.exists("htmlcov") and shutil.rmtree("htmlcov")'"""
test-cov = "pytest --cache-clear --cov src/krux --cov-report html ./tests --cov-context=test --cov-report term-missing"
@@ -111,7 +143,7 @@ pre-commit = ["format", "lint", "i18n validate", "vulture", "test-simple"]
pre-release = ["format", "lint", "i18n validate", "i18n-build", "update-glyphs", "vulture", "test-simple"]
# run docs locally
-docs = "poetry run mkdocs serve --livereload"
+docs = "mkdocs serve --livereload"
# translations tasks
i18n = "python i18n/i18n.py"
@@ -148,7 +180,7 @@ git-update = "git submodule update --init --recursive"
git-pull = "git pull git@github.com:selfcustody/krux.git"
git-pull-https = "git pull https://github.com/selfcustody/krux.git"
-# ktool tasks
+# ktool tasks
ktool-cmd = "python firmware/Kboot/build/ktool.py -b 2000000"
flash-cmd.ref = "ktool-cmd build/kboot.kfpkg"
flash.ref = "flash-cmd -B goE"
### render_docs_math.py
@@ -8,8 +8,7 @@
This is a dev only build tool. It is never shipped to the site and adds no
runtime dependency for visitors. It only needs matplotlib:
- poetry run pip install matplotlib # or add to the docs/dev extras
- poetry run python render_docs_math.py
+ uv run --with matplotlib python render_docs_math.py
Source of truth is the markdown itself: every display equation is an image whose
alt text is the original LaTeX, e.g.
### simulator/generate-device-screenshots.sh
@@ -42,45 +42,46 @@ echo "$encrypted_mnemonics" > sd/seeds.json
# Sequences
# Login
-poetry run poe simulator --sequence sequences/logo.txt --device $device
-poetry run poe simulator --sequence sequences/about.txt --sd --device $device
-poetry run poe simulator --sequence sequences/load-mnemonic-options.txt --sd --device $device
-poetry run poe simulator --sequence sequences/new-mnemonic-options.txt --sd --device $device
-poetry run poe simulator --sequence sequences/load-mnemonic-sequence.txt --sd --device $device
-poetry run poe simulator --sequence sequences/load-mnemonic-double-mnemonic.txt --sd --device $device
-poetry run poe simulator --sequence sequences/edit-mnemonic.txt --sd --device $device
+uv run poe simulator --sequence sequences/logo.txt --device $device
+uv run poe simulator --sequence sequences/about.txt --sd --device $device
+uv run poe simulator --sequence sequences/load-mnemonic-options.txt --sd --device $device
+uv run poe simulator --sequence sequences/new-mnemonic-options.txt --sd --device $device
+uv run poe simulator --sequence sequences/new-mnemonic-wallet-options.txt --sd --device $device
+uv run poe simulator --sequence sequences/load-mnemonic-sequence.txt --sd --device $device
+uv run poe simulator --sequence sequences/load-mnemonic-double-mnemonic.txt --sd --device $device
+uv run poe simulator --sequence sequences/edit-mnemonic.txt --sd --device $device
# Home
-poetry run poe simulator --sequence sequences/home-options.txt --sd --device $device
-poetry run poe simulator --sequence sequences/encrypt-mnemonic.txt --sd --device $device
-poetry run poe simulator --sequence sequences/extended-public-key-wpkh.txt --sd --device $device
-poetry run poe simulator --sequence sequences/extended-public-key-wsh.txt --sd --device $device
-poetry run poe simulator --sequence sequences/wallet-descriptor-wsh.txt --sd --device $device
-# poetry run poe simulator --sequence sequences/wallet-descriptor-wpkh.txt --sd --device $device
-poetry run poe simulator --sequence sequences/wallet-descriptor-exp-tr-minis.txt --sd --device $device
-poetry run poe simulator --sequence sequences/bip85.txt --sd --device $device
-poetry run poe simulator --sequence sequences/mnemonic-xor.txt --sd --device $device
-poetry run poe simulator --sequence sequences/scan-address.txt --sd --device $device
-poetry run poe simulator --sequence sequences/list-address.txt --sd --device $device
-poetry run poe simulator --sequence sequences/export-address.txt --sd --device $device
-poetry run poe simulator --sequence sequences/sign-psbt.txt --sd --device $device
-poetry run poe simulator --sequence sequences/sign-message.txt --sd --device $device
-poetry run poe simulator --sequence sequences/sign-message-at-address.txt --device $device
+uv run poe simulator --sequence sequences/home-options.txt --sd --device $device
+uv run poe simulator --sequence sequences/encrypt-mnemonic.txt --sd --device $device
+uv run poe simulator --sequence sequences/extended-public-key-wpkh.txt --sd --device $device
+uv run poe simulator --sequence sequences/extended-public-key-wsh.txt --sd --device $device
+uv run poe simulator --sequence sequences/wallet-descriptor-wsh.txt --sd --device $device
+# uv run poe simulator --sequence sequences/wallet-descriptor-wpkh.txt --sd --device $device
+uv run poe simulator --sequence sequences/wallet-descriptor-exp-tr-minis.txt --sd --device $device
+uv run poe simulator --sequence sequences/bip85.txt --sd --device $device
+uv run poe simulator --sequence sequences/mnemonic-xor.txt --sd --device $device
+uv run poe simulator --sequence sequences/scan-address.txt --sd --device $device
+uv run poe simulator --sequence sequences/list-address.txt --sd --device $device
+uv run poe simulator --sequence sequences/export-address.txt --sd --device $device
+uv run poe simulator --sequence sequences/sign-psbt.txt --sd --device $device
+uv run poe simulator --sequence sequences/sign-message.txt --sd --device $device
+uv run poe simulator --sequence sequences/sign-message-at-address.txt --device $device
# Tools
-poetry run poe simulator --sequence sequences/tools-datum-tool.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-check-sd.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-create-QR.txt --sd --device $device
-# poetry run poe simulator --sequence sequences/tools-mnemonic.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-device-tests-test-suite.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-print-test-qr.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-descriptor-addresses.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tools-flash.txt --sd --device $device
-poetry run poe simulator --sequence sequences/tc-flash-hash.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-datum-tool.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-check-sd.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-create-QR.txt --sd --device $device
+# uv run poe simulator --sequence sequences/tools-mnemonic.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-device-tests-test-suite.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-print-test-qr.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-descriptor-addresses.txt --sd --device $device
+uv run poe simulator --sequence sequences/tools-flash.txt --sd --device $device
+uv run poe simulator --sequence sequences/tc-flash-hash.txt --sd --device $device
# Settings
-poetry run poe simulator --sequence sequences/all-settings.txt --sd --device $device
+uv run poe simulator --sequence sequences/all-settings.txt --sd --device $device
# Other
-poetry run poe simulator --sequence sequences/qr-transcript.txt --sd --printer --device $device
-poetry run poe simulator --sequence sequences/print-qr.txt --sd --printer --device $device
+uv run poe simulator --sequence sequences/qr-transcript.txt --sd --printer --device $device
+uv run poe simulator --sequence sequences/print-qr.txt --sd --printer --device $device
### simulator/sequences/home-options.txt
@@ -52,9 +52,23 @@ press BUTTON_A
press BUTTON_B
press BUTTON_A
+screenshot backup-stackbit-menu.png
+
+press BUTTON_B
+press BUTTON_A
+
+screenshot backup-stackbit-vertical.png
+
+x2 press BUTTON_A
+press_amigo_only BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
+
screenshot backup-stackbit.png
x2 press BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
press BUTTON_B
press BUTTON_A
### simulator/sequences/new-mnemonic-wallet-options.txt
@@ -0,0 +1,28 @@
+include _wait-for-logo.txt
+
+# Navigate to New Mnemonic
+press BUTTON_B
+press BUTTON_A
+
+# Generate a 12-word mnemonic via camera entropy
+press BUTTON_A
+press BUTTON_A
+press BUTTON_A
+qrcode arara.png
+qrcode arara.png
+qrcode arara.png
+wait 0.3
+press BUTTON_A
+
+# Continue through entropy details, SHA256, and the mnemonic screen
+press BUTTON_A
+press BUTTON_A
+press BUTTON_A
+
+screenshot new-mnemonic-wallet-summary.png
+
+# Open Wallet Options
+press BUTTON_B
+press BUTTON_A
+
+screenshot new-mnemonic-wallet-options.png
### src/krux/bbqr.py
@@ -35,6 +35,15 @@
BBQR_ALWAYS_COMPRESS_THRESHOLD = 5000 # bytes
+# Upper bound for the accumulated payload of an animated BBQr, in chars.
+# The header encodes the part total as 2 base36 chars, so a crafted stream may
+# announce up to 1295 parts and keep the parser accumulating them. A part count
+# limit can't be used here: small screens generate many small parts, so a few KB
+# PSBT already takes more than a hundred of them. Bound the total instead, at the
+# base32 expansion (8/5) of the 100 KB decompression limit. Anything above it
+# could not be decoded anyway.
+BBQR_MAX_PAYLOAD_LEN = 160 * 1024
+
class BBQrCode:
"""A BBQr code, containing the data, encoding, and file type"""
### src/krux/camera.py
@@ -26,8 +26,7 @@
from .krux_settings import Settings
from .kboard import kboard
-OV2640_ID = 0x2642 # Lenses, vertical flip - Bit
-OV5642_ID = 0x5642 # Lenses, horizontal flip - Bit
+OV2640_ID = 0x2642 # Lenses, vertical flip - Embed Fire
OV7740_ID = 0x7742 # No lenses, no Flip - M5sitckV, Amigo
GC0328_ID = 0x9D # Dock
GC2145_ID = 0x45 # Yahboom, WonderK
@@ -53,11 +52,6 @@
(OV2640_ID, ENTROPY_MODE): (0x68, 0x78),
(OV2640_ID, BINARY_GRID_MODE): (0x44, 0x48),
(OV2640_ID, ZOOMED_MODE): (0x35, 0x50),
- (OV5642_ID, QR_SCAN_MODE): (0x60, 0x70),
- (OV5642_ID, ANTI_GLARE_MODE): (0x20, 0x28),
- (OV5642_ID, ENTROPY_MODE): (0x68, 0x78),
- (OV5642_ID, BINARY_GRID_MODE): (0x44, 0x48),
- (OV5642_ID, ZOOMED_MODE): (0x35, 0x50),
(OV7740_ID, QR_SCAN_MODE): (0x60, 0x70),
(OV7740_ID, ANTI_GLARE_MODE): (0x20, 0x28),
(OV7740_ID, ENTROPY_MODE): (0x68, 0x78),
@@ -114,18 +108,12 @@ def initialize_sensor(self, mode=QR_SCAN_MODE):
sensor.set_pixformat(sensor.GRAYSCALE)
else:
sensor.set_pixformat(sensor.RGB565)
- if self.cam_id == OV5642_ID:
- sensor.set_hmirror(1)
if self.cam_id == OV2640_ID:
if kboard.is_embed_fire:
sensor.set_hmirror(0)
else:
sensor.set_vflip(1)
- if kboard.is_bit:
- # CIF mode will use central pixels and discard darker periphery
- sensor.set_framesize(sensor.CIF)
- else:
- sensor.set_framesize(sensor.QVGA)
+ sensor.set_framesize(sensor.QVGA)
if mode != ENTROPY_MODE:
if self.cam_id == OV7740_ID:
self.config_ov_7740()
@@ -186,7 +174,6 @@ def luminosity_threshold(self):
GC0328_ID: self._config_gc0328_lum,
OV2640_ID: self._config_ovxx40_lum,
OV7740_ID: self._config_ovxx40_lum, # Same as OV2640
- OV5642_ID: self._config_ovxx40_lum, # Same as OV2640
GC2145_ID: self._config_gc2145_lum,
}
@@ -359,11 +346,7 @@ def toggle_camera_mode(self):
def snapshot(self):
"""Helper to take a customized snapshot from sensor"""
- img = sensor.snapshot()
- if kboard.is_bit:
- img.lens_corr(strength=1.1)
- img.rotation_corr(z_rotation=180)
- return img
+ return sensor.snapshot()
def initialize_run(self, mode=QR_SCAN_MODE):
"""Initializes and runs sensor"""
### src/krux/display.py
@@ -167,9 +167,10 @@ def initialize_lcd(self):
offset_h0=80,
)
elif kboard.is_amigo:
- lcd_type = Settings().hardware.display.lcd_type
- invert = Settings().hardware.display.inverted_colors
- bgr_to_rgb = Settings().hardware.display.bgr_colors
+ display_settings = Settings().hardware.display
+ lcd_type = display_settings.lcd_type
+ invert = display_settings.inverted_colors
+ bgr_to_rgb = display_settings.bgr_colors
lcd.init(invert=invert, lcd_type=lcd_type)
lcd.mirror(True)
lcd.bgr_to_rgb(bgr_to_rgb)
@@ -243,23 +244,15 @@ def qr_data_width(self):
def to_landscape(self):
"""Changes the rotation of the display to landscape"""
if self.portrait:
- lcd.rotation(
- (LANDSCAPE + 2) % 4
- if hasattr(Settings().hardware, "display")
- and getattr(Settings().hardware.display, "flipped_orientation", False)
- else LANDSCAPE
- )
+ flipped = Settings().is_flipped_orientation()
+ lcd.rotation((LANDSCAPE + 2) % 4 if flipped else LANDSCAPE)
self.portrait = False
def to_portrait(self):
"""Changes the rotation of the display to portrait"""
if not self.portrait:
- lcd.rotation(
- (PORTRAIT + 2) % 4
- if hasattr(Settings().hardware, "display")
- and getattr(Settings().hardware.display, "flipped_orientation", False)
- else PORTRAIT
- )
+ flipped = Settings().is_flipped_orientation()
+ lcd.rotation((PORTRAIT + 2) % 4 if flipped else PORTRAIT)
self.portrait = True
def _usable_pixels_in_line(self):
### src/krux/encryption.py
@@ -33,21 +33,32 @@
QR_CODE_ITER_MULTIPLE = 10000
+class StorageCorruptedError(Exception):
+ """Stored mnemonics file exists but is not a valid object; it is left
+ untouched instead of being overwritten, so its data can be recovered."""
+
+
class MnemonicStorage:
"""Handler of stored encrypted seeds"""
+ @staticmethod
+ def _load_mnemonics(contents):
+ return json.loads(contents)
+
def __init__(self) -> None:
self.stored = {}
self.stored_sd = {}
try:
with SDHandler() as sd:
- self.stored_sd = json.loads(sd.read(MNEMONICS_FILE))
- except:
+ self.stored_sd = self._load_mnemonics(sd.read(MNEMONICS_FILE))
+ except (OSError, ValueError):
+ # missing/unreadable SD card or malformed JSON -> start empty
pass
try:
with open(FLASH_PATH_STR % MNEMONICS_FILE, "r") as f:
- self.stored = json.loads(f.read())
- except:
+ self.stored = self._load_mnemonics(f.read())
+ except (OSError, ValueError):
+ # missing/unreadable flash file or malformed JSON -> start empty
pass
def _deprecated_decrypt(self, key, salt, iterations, mode, payload):
@@ -73,24 +84,24 @@ def stretch_key(key, salt, iterations):
plaintext = kef._unpad(decryptor.decrypt(payload), pkcs_pad=False)
return plaintext.decode()
except:
+ # broad on purpose: any failure here means a wrong key or
+ # incompatible legacy ciphertext -> return None
return None
def list_mnemonics(self, sd_card=False):
"""List all seeds stored on a file"""
- mnemonic_ids = []
source = self.stored_sd if sd_card else self.stored
- for mnemonic_id in source:
- mnemonic_ids.append(mnemonic_id)
- return mnemonic_ids
+ if not isinstance(source, dict):
+ # corrupt/non-dict storage -> nothing to list
+ return []
+ return list(source)
def decrypt(self, key, mnemonic_id, sd_card=False):
"""Decrypt a selected encrypted mnemonic from a file"""
- try:
- if sd_card:
- stored_value = self.stored_sd.get(mnemonic_id)
- else:
- stored_value = self.stored.get(mnemonic_id)
- except:
+ source = self.stored_sd if sd_card else self.stored
+ stored_value = source.get(mnemonic_id) if isinstance(source, dict) else None
+ if not isinstance(stored_value, dict):
+ # unknown id, or a corrupt/non-dict storage entry -> nothing to decrypt
return None
if stored_value.get("b64_kef"):
@@ -114,13 +125,21 @@ def store_encrypted_kef(self, mnemonic_id, kef_envelope, sd_card=False):
mnemonics = {}
if sd_card:
# load current MNEMONICS_FILE
+ orig_len = 0
try:
with SDHandler() as sd:
contents = sd.read(MNEMONICS_FILE)
orig_len = len(contents)
- mnemonics = json.loads(contents)
- except:
- orig_len = 0
+ mnemonics = self._load_mnemonics(contents)
+ except OSError:
+ # missing file -> write a fresh store
+ pass
+ except ValueError as exc:
+ # corrupt JSON -> preserve for recovery
+ raise StorageCorruptedError(MNEMONICS_FILE) from exc
+ if not isinstance(mnemonics, dict):
+ # wrong shape -> preserve for recovery
+ raise StorageCorruptedError(MNEMONICS_FILE)
# save the new MNEMONICS_FILE
try:
@@ -132,20 +151,29 @@ def store_encrypted_kef(self, mnemonic_id, kef_envelope, sd_card=False):
contents += " " * (orig_len - len(contents))
sd.write(MNEMONICS_FILE, contents)
except:
+ # broad on purpose: any failure to save means the store failed
return False
else:
try:
# load current MNEMONICS_FILE
with open(FLASH_PATH_STR % MNEMONICS_FILE, "r") as f:
- mnemonics = json.loads(f.read())
- except:
+ mnemonics = self._load_mnemonics(f.read())
+ except OSError:
+ # missing file -> write a fresh store
pass
+ except ValueError as exc:
+ # corrupt JSON -> preserve for recovery
+ raise StorageCorruptedError(MNEMONICS_FILE) from exc
+ if not isinstance(mnemonics, dict):
+ # wrong shape -> preserve for recovery
+ raise StorageCorruptedError(MNEMONICS_FILE)
try:
# save the new MNEMONICS_FILE
with open(FLASH_PATH_STR % MNEMONICS_FILE, "w") as f:
mnemonics[mnemonic_id] = {"b64_kef": b64_kef}
f.write(json.dumps(mnemonics))
except:
+ # broad on purpose: any failure to save means the store failed
return False
return True
### src/krux/format.py
@@ -33,12 +33,18 @@ def format_btc(amount):
while still using the idea behind the Satcomma standard
"""
+ # Floor division and modulo round towards minus infinity, so the sign has
+ # to be taken out before splitting the amount
+ sign = "-" if amount < 0 else ""
+ amount = abs(amount)
+
btc_without_decimal = amount // SATS_PER_BTC
btc_decimal_only = amount % SATS_PER_BTC
btc_decimal_8char = ("{:0>" + BTC_SATS_LEN + "}").format(btc_decimal_only)
return (
- generate_thousands_separator(btc_without_decimal)
+ sign
+ + generate_thousands_separator(btc_without_decimal)
+ render_decimal_separator()
+ btc_decimal_8char[:2]
+ THOUSANDS_SEPARATOR
### src/krux/kboard.py
@@ -28,7 +28,6 @@ class KBoard:
def __init__(self):
self.is_amigo = board.config["type"] == "amigo"
- self.is_bit = board.config["type"] == "bit"
self.is_cube = board.config["type"] == "cube"
self.is_embed_fire = board.config["type"] == "embed_fire"
self.is_yahboom = board.config["type"] == "yahboom"
### src/krux/key.py
@@ -202,7 +202,7 @@ def extract_fingerprint(
Key.extract_root(mnemonic, passphrase, network).child(0).fingerprint,
pretty,
)
- except:
+ except Exception:
pass
return ""
### src/krux/krux_settings.py
@@ -490,20 +490,32 @@ class Settings(SettingsNamespace):
"""The top-level settings namespace under which other namespaces reside"""
namespace = "settings"
+ _instance = None
+
+ def __new__(cls):
+ # Cache the namespace tree: values are read live from the `store`
+ # singleton, so reusing the instance avoids rebuilding ~16 objects
+ # on every Settings() call without staling any setting.
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
def __init__(self):
+ if getattr(self, "_built", False):
+ return
self.wallet = DefaultWallet()
self.security = SecuritySettings()
self.hardware = HardwareSettings()
self.i18n = I18nSettings()
self.encryption = EncryptionSettings()
self.persist = PersistSettings()
self.appearance = ThemeSettings()
+ self._built = True
def is_flipped_orientation(self):
"""Returns flipped orientation setting"""
- return hasattr(Settings().hardware, "display") and getattr(
- Settings().hardware.display, "flipped_orientation", False
+ return hasattr(self.hardware, "display") and getattr(
+ self.hardware.display, "flipped_orientation", False
)
def label(self, attr):
### src/krux/metadata.py
@@ -19,5 +19,5 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
-VERSION = "26.04.0"
+VERSION = "26.08.0"
SIGNER_PUBKEY = "03339e883157e45891e61ca9df4cd3bb895ef32d475b8e793559ea10a36766689b"
### src/krux/pages/__init__.py
@@ -138,6 +138,12 @@ def flash_error(self, text):
"""Flashes text centered on the display for duration ms"""
self.flash_text(text, theme.error_color)
+ def flash_success(self, text, duration=FLASH_MSG_TIME, highlight_prefix=""):
+ """Flashes success text centered on the display for duration ms"""
+ self.flash_text(
+ text, theme.go_color, duration, highlight_prefix=highlight_prefix
+ )
+
# pylint: disable=too-many-arguments
def capture_from_keypad(
self,
@@ -288,7 +294,7 @@ def display_qr_codes(
while not done:
try:
code, num_parts = next(code_generator)
- except:
+ except StopIteration:
code_generator = to_qr_codes(data, qr_data_width, qr_format)
code, num_parts = next(code_generator)
@@ -517,7 +523,7 @@ def has_sd_card(self):
# Check for SD hot-plug
with SDHandler():
return True
- except:
+ except Exception:
return False
def shutdown(self):
### src/krux/pages/datum_tool.py
@@ -78,23 +78,19 @@
def urobj_to_data(ur_obj):
"""returns flatened data from a UR object. belongs in qr or qr_capture???"""
- from urtypes.crypto.bip39 import BIP39
- from urtypes.crypto.account import Account
- from urtypes.crypto.output import Output
- from urtypes.crypto.psbt import PSBT
- from urtypes.bytes import Bytes
-
- if ur_obj.type.upper() == "CRYPTO-BIP39":
- data = BIP39.from_cbor(ur_obj.cbor).words
+ from uUR import Types
+
+ if ur_obj.type == "crypto-bip39":
+ data = Types.bip39_words_from_cbor(ur_obj.cbor)
data = " ".join(data)
- elif ur_obj.type.upper() == "CRYPTO-ACCOUNT":
- data = Account.from_cbor(ur_obj.cbor).output_descriptors[0].descriptor()
- elif ur_obj.type.upper() == "CRYPTO-OUTPUT":
- data = Output.from_cbor(ur_obj.cbor).descriptor()
- elif ur_obj.type.upper() == "CRYPTO-PSBT":
- data = PSBT.from_cbor(ur_obj.cbor).data
- elif ur_obj.type.upper() == "BYTES":
- data = Bytes.from_cbor(ur_obj.cbor).data
+ elif ur_obj.type == "crypto-account":
+ data = Types.output_from_cbor_account(ur_obj.cbor)
+ elif ur_obj.type == "crypto-output":
+ data = Types.output_from_cbor(ur_obj.cbor)
+ elif ur_obj.type == "crypto-psbt":
+ data = Types.psbt_from_cbor(ur_obj.cbor)
+ elif ur_obj.type == "bytes":
+ data = Types.bytes_from_cbor(ur_obj.cbor)
else:
data = None
return data
@@ -422,9 +418,6 @@ def view_qr(self):
"""Reusable handler for viewing a QR code"""
from ..qr import QR_CAPACITY_BYTE, QR_CAPACITY_ALPHANUMERIC, QR_CAPACITY_NUMERIC
from ..bbqr import encode_bbqr
- from urtypes.bytes import Bytes
- from urtypes.crypto.psbt import PSBT
- from ur.ur import UR
# Helper function to check if character is alphanumeric
def is_alnum(c):
@@ -514,11 +507,13 @@ def is_alnum(c):
encoded = encode_bbqr(encoded, file_type=menu_opts[idx][1][1])
elif qr_fmt == FORMAT_UR:
+ from uUR import UR, Types
+
ur_type = menu_opts[idx][1][1]
if ur_type == "bytes":
- encoded = UR(ur_type, Bytes(encoded).to_cbor())
+ encoded = UR(ur_type, Types.bytes_to_cbor(encoded))
elif ur_type == "crypto-psbt":
- encoded = UR(ur_type, PSBT(encoded).to_cbor())
+ encoded = UR(ur_type, Types.psbt_to_cbor(encoded))
# TODO: other urtypes
try:
@@ -831,7 +826,7 @@ def view_contents(self, try_decrypt=True, offer_convert=False):
self.ctx,
todo_menu,
offset=info_len * FONT_HEIGHT + DEFAULT_PADDING,
- **back_status
+ **back_status,
)
_, status = menu.run_loop()
### src/krux/pages/encryption_ui.py
@@ -615,7 +615,7 @@ def encrypt_menu(self):
def store_mnemonic_on_memory(self, sd_card=False):
"""Save encrypted mnemonic on flash or sd_card"""
- from ..encryption import MnemonicStorage
+ from ..encryption import MnemonicStorage, StorageCorruptedError
encrypted_data, mnemonic_id = self._encrypt_mnemonic_with_label()
if encrypted_data is None:
@@ -629,7 +629,23 @@ def store_mnemonic_on_memory(self, sd_card=False):
del mnemonic_storage
return
- if mnemonic_storage.store_encrypted_kef(mnemonic_id, encrypted_data, sd_card):
+ try:
+ stored = mnemonic_storage.store_encrypted_kef(
+ mnemonic_id, encrypted_data, sd_card
+ )
+ except StorageCorruptedError:
+ self.ctx.display.clear()
+ # English-only: rare corruption guard, not worth translating
+ self.ctx.display.draw_centered_text(
+ "Stored seeds file is corrupted and was preserved.\n"
+ "Encrypted mnemonic was not stored.",
+ theme.error_color,
+ )
+ self.ctx.input.wait_for_button()
+ del mnemonic_storage
+ return
+
+ if stored:
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Encrypted mnemonic stored with ID:") + " " + mnemonic_id,
### src/krux/pages/file_operations.py
@@ -89,7 +89,7 @@ def save_file(
sd.write(new_filename, data)
# Show the user the filename
- self.flash_text(
+ self.flash_success(
t("Saved to SD card:") + "\n\n%s" % new_filename,
highlight_prefix=":",
)
### src/krux/pages/fill_flash.py
@@ -122,5 +122,5 @@ def fill_flash_with_camera_entropy(self):
block_count += 1
self.ctx.camera.stop_sensor()
- self.flash_text(t("Flash filled with camera entropy"))
+ self.flash_success(t("Flash filled with camera entropy"))
return MENU_CONTINUE
### src/krux/pages/home_pages/addresses.py
@@ -254,7 +254,7 @@ def export_address(self, addr_type=0):
)
wdt.feed()
- self.flash_text(
+ self.flash_success(
t("Saved to SD card:") + "\n\n%s" % filename,
highlight_prefix=":",
)
### src/krux/pages/home_pages/bip85.py
@@ -86,7 +86,7 @@ def _derive_mnemonic(self):
from ...wallet import Wallet
self.ctx.wallet = Wallet(key)
- self.flash_text(
+ self.flash_success(
t("%s: loaded!") % key.fingerprint_hex_str(True), highlight_prefix=":"
)
### src/krux/pages/home_pages/home.py
@@ -311,7 +311,7 @@ def _sign_menu(self, signer, psbt_filename, outputs):
with open(SDHandler.PATH_STR % psbt_filename, "wb") as f:
# Write PSBT data directly to the file
signer.psbt.write_to(f)
- self.flash_text(
+ self.flash_success(
t("Saved to SD card:") + "\n\n%s" % psbt_filename,
highlight_prefix=":",
)
@@ -411,6 +411,23 @@ def _post_load_psbt_warn(self, signer):
return True
+ def _unverified_amounts_psbt_warn(self, signer):
+ """Warn when input amounts are not backed by their previous transactions"""
+ if signer.unverified_input_amounts():
+ self.ctx.display.clear()
+ self.ctx.display.draw_centered_text(
+ t("Warning:")
+ + " "
+ + t("Unverified input amounts!")
+ + "\n"
+ + t("The fee shown may be lower than the real fee."),
+ highlight_prefix=":",
+ )
+
+ return self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE)
+
+ return True
+
def _fees_psbt_warn(self, fee_percent):
"""Warn if fees greater than 10% of what is spent"""
if fee_percent >= 10.0:
@@ -510,6 +527,9 @@ def sign_psbt(self):
self.ctx.display.draw_centered_text(t("Processing…"))
outputs, fee_percent = signer.outputs()
+ if not self._unverified_amounts_psbt_warn(signer):
+ return MENU_CONTINUE
+
if not self._fees_psbt_warn(fee_percent):
return MENU_CONTINUE
### src/krux/pages/home_pages/mnemonic_backup.py
@@ -178,7 +178,19 @@ def display_seed_qr(self, binary=False):
return seed_qr_view.display_qr()
def stackbit(self):
- """Displays which numbers 1248 user should punch on 1248 steel card"""
+ """Displays layout selection submenu for Stackbit 1248 backup"""
+ submenu = Menu(
+ self.ctx,
+ [
+ (t("Standard"), self._stackbit_standard),
+ (t("Vertical"), self._stackbit_vertical),
+ ],
+ )
+ submenu.run_loop()
+ return MENU_CONTINUE
+
+ def _stackbit_standard(self):
+ """Displays Stackbit 1248 in standard (horizontal) format — 6 words per page"""
from ..stack_1248 import Stackbit
stackbit = Stackbit(self.ctx)
@@ -198,6 +210,67 @@ def stackbit(self):
self.ctx.display.clear()
return MENU_CONTINUE
+ def _stackbit_vertical(self):
+ """Dispatches to the grouped or minimal layout based on the current device"""
+ if kboard.has_minimal_display:
+ return self._stackbit_vertical_compact()
+ return self._stackbit_vertical_default()
+
+ def _stackbit_vertical_default(self):
+ """Draws vertical Stackbit 1248 layout with 2 words per group, 4 words per page"""
+ from ..stack_1248 import Stackbit
+
+ stackbit = Stackbit(self.ctx)
+ words = self.ctx.wallet.key.mnemonic.split(" ")
+ total_words = len(words)
+
+ words_per_group = 2
+
+ groups_per_page = 2
+ block_h = 7 * FONT_HEIGHT + 2
+ group_gap = max(2, FONT_HEIGHT // 4)
+
+ word_index = 1
+ while word_index <= total_words:
+ self.ctx.display.draw_hcentered_text("Stackbit 1248")
+ y_offset = 2 * FONT_HEIGHT
+ for _ in range(groups_per_page):
+ if word_index > total_words:
+ break
+ group = []
+ for _ in range(words_per_group):
+ if word_index > total_words:
+ break
+ group.append((word_index, words[word_index - 1]))
+ word_index += 1
+ stackbit.export_1248_vertical_grouped(y_offset, group)
+ y_offset += block_h + group_gap
+ self.ctx.input.wait_for_button()
+ self.ctx.display.clear()
+ return MENU_CONTINUE
+
+ def _stackbit_vertical_compact(self):
+ """Draws compact Stackbit 1248 layout for M5StickV, 6 words per page"""
+ from ..stack_1248 import Stackbit
+
+ stackbit = Stackbit(self.ctx)
+ words = self.ctx.wallet.key.mnemonic.split(" ")
+ total_words = len(words)
+ words_per_page = 6
+ word_index = 0
+
+ while word_index < total_words:
+ self.ctx.display.draw_hcentered_text("Stackbit 1248")
+ page_words = []
+ for _ in range(words_per_page):
+ if word_index < total_words:
+ page_words.append((word_index + 1, words[word_index]))
+ word_index += 1
+ stackbit.export_1248_vertical_compact(page_words, 2 * FONT_HEIGHT)
+ self.ctx.input.wait_for_button()
+ self.ctx.display.clear()
+ return MENU_CONTINUE
+
def tiny_seed(self):
"""Displays the seed in Tinyseed format"""
from ..tiny_seed import TinySeed
### src/krux/pages/home_pages/mnemonic_xor.py
@@ -171,7 +171,7 @@ def _load_key_from_words(self, words, charset=LETTERS, new=False):
self.ctx.wallet.key.script_type,
)
self.ctx.wallet = Wallet(xored_key)
- self.flash_text(
+ self.flash_success(
t("%s: loaded!") % xored_fingerprint,
highlight_prefix=":",
)
### src/krux/pages/home_pages/wallet_descriptor.py
@@ -359,7 +359,7 @@ def _load_wallet(self):
self.display_loading_wallet(wallet)
if self.prompt(t("Load?"), BOTTOM_PROMPT_LINE):
self.ctx.wallet = wallet
- self.flash_text(t("Wallet output descriptor loaded!"))
+ self.flash_success(t("Wallet output descriptor loaded!"))
return MENU_CONTINUE
### src/krux/pages/login.py
@@ -199,6 +199,38 @@ def new_key_from_snapshot(self):
return self._load_key_from_words(entropy_mnemonic.split(), new=True)
return MENU_CONTINUE
+ def _wallet_info_menu(self, key, wallet_info, network_name, menu_items):
+ """Draws the wallet info box and returns a menu placed below it"""
+ from ..themes import theme
+ from .utils import Utils
+
+ self.ctx.display.clear()
+ menu = Menu(
+ self.ctx,
+ menu_items,
+ offset=(
+ self.ctx.display.draw_hcentered_text(wallet_info, info_box=True)
+ * FONT_HEIGHT
+ + DEFAULT_PADDING
+ ),
+ )
+
+ # draw fingerprint with highlight color
+ self.ctx.display.draw_hcentered_text(
+ key.fingerprint_hex_str(True),
+ color=theme.highlight_color,
+ bg_color=theme.info_bg_color,
+ )
+
+ # draw network with highlight color
+ self.ctx.display.draw_hcentered_text(
+ network_name,
+ DEFAULT_PADDING + FONT_HEIGHT,
+ color=Utils.get_network_color(network_name),
+ bg_color=theme.info_bg_color,
+ )
+ return menu
+
def _load_key_from_words(self, words, charset=LETTERS, new=False):
mnemonic = " ".join(words)
@@ -256,7 +288,6 @@ def _load_key_from_words(self, words, charset=LETTERS, new=False):
derivation_path = ""
from ..wallet import Wallet
- from ..themes import theme
from .utils import Utils
utils = Utils(self.ctx)
@@ -283,43 +314,48 @@ def _load_key_from_words(self, words, charset=LETTERS, new=False):
else t("Passphrase") + " (%d): *…*" % len(passphrase)
)
- self.ctx.display.clear()
- submenu = Menu(
- self.ctx,
- [
- (t("Load Wallet"), lambda: None),
- (t("Passphrase"), lambda: None),
- (t("Customize"), lambda: None),
- ],
- offset=(
- self.ctx.display.draw_hcentered_text(wallet_info, info_box=True)
- * FONT_HEIGHT
- + DEFAULT_PADDING
- ),
- )
-
- # draw fingerprint with highlight color
- self.ctx.display.draw_hcentered_text(
- key.fingerprint_hex_str(True),
- color=theme.highlight_color,
- bg_color=theme.info_bg_color,
- )
-
- # draw network with highlight color
- self.ctx.display.draw_hcentered_text(
+ submenu = self._wallet_info_menu(
+ key,
+ wallet_info,
network_name,
- DEFAULT_PADDING + FONT_HEIGHT,
- color=Utils.get_network_color(network_name),
- bg_color=theme.info_bg_color,
+ (
+ [
+ (t("Continue"), lambda: None),
+ (t("Wallet Options"), lambda: None),
+ ]
+ if new
+ else [
+ (t("Load Wallet"), lambda: None),
+ (t("Passphrase"), lambda: None),
+ (t("Customize"), lambda: None),
+ ]
+ ),
)
index, _ = submenu.run_loop()
if index == submenu.back_index:
if self.prompt(t("Are you sure?"), self.ctx.display.height() // 2):
del key
return MENU_CONTINUE
+ continue
if index == 0:
break
+ if new and index == 1:
+ submenu = self._wallet_info_menu(
+ key,
+ wallet_info,
+ network_name,
+ [
+ (t("Passphrase"), lambda: None),
+ (t("Customize"), lambda: None),
+ ],
+ )
+
+ index, _ = submenu.run_loop()
+ if index == submenu.back_index:
+ continue
+ # shift onto the Passphrase and Customize arms of the main menu
+ index += 1
if index == 1:
from .wallet_settings import PassphraseEditor
### src/krux/pages/mnemonic_loader.py
@@ -326,9 +326,9 @@ def load_key_from_qr_code(self):
words = []
if qr_format == FORMAT_UR:
- from urtypes.crypto.bip39 import BIP39
+ from uUR import Types
- words = BIP39.from_cbor(data.cbor).words
+ words = Types.bip39_words_from_cbor(data.cbor)
else:
try:
data_str = data.decode() if not isinstance(data, str) else data
### src/krux/pages/qr_view.py
@@ -375,7 +375,7 @@ def save_bmp_image(self, file_name, resolution):
return
bmp_img.save(SDHandler.PATH_STR % new_filename)
- self.flash_text(
+ self.flash_success(
t("Saved to SD card:") + "\n\n%s" % new_filename,
highlight_prefix=":",
)
@@ -529,7 +529,10 @@ def toggle_brightness():
self.qr_foreground = None
self.draw_grided_qr(mode)
- if self.ctx.display.height() > self.ctx.display.width():
+ if self.ctx.display.height() > self.ctx.display.width() and mode in (
+ STANDARD_MODE,
+ TRANSCRIBE_MODE,
+ ):
y_offset = self.ctx.display.qr_offset() + DEFAULT_PADDING
self.ctx.display.draw_hcentered_text(
label,
### src/krux/pages/settings_page.py
@@ -187,7 +187,7 @@ def enter_modify_tc_code(self):
with open(TC_CODE_PATH, "wb") as f:
f.write(secret)
self.ctx.tc_code_enabled = True
- self.flash_text(t("Tamper check code set successfully"))
+ self.flash_success(t("Tamper check code set successfully"))
from .fill_flash import FillFlash
@@ -226,7 +226,7 @@ def _settings_exit_check(self):
# Check for SD hot-plug
with SDHandler():
if store.save_settings():
- self.flash_text(
+ self.flash_success(
t("Settings stored on SD card."),
duration=PERSIST_MSG_TIME,
)
@@ -240,7 +240,7 @@ def _settings_exit_check(self):
else:
self.ctx.display.clear()
if store.save_settings():
- self.flash_text(
+ self.flash_success(
t("Settings stored internally on flash."),
duration=PERSIST_MSG_TIME,
)
### src/krux/pages/stack_1248.py
@@ -38,6 +38,8 @@
STACKBIT_GO_INDEX = 38
STACKBIT_ESC_INDEX = 35
STACKBIT_MAX_INDEX = 13
+BIT_WEIGHTS = (1, 2, 4, 8)
+BIT_LABELS = ("1", "2", "4", "8")
class Stackbit(Page):
@@ -342,6 +344,248 @@ def _draw_menu(self):
)
x_offset += 3 * self.x_pad
+ def export_1248_vertical_compact(
+ self, words_list, y_start
+ ): # pylint: disable=too-many-locals
+ """Draws compact Stackbit 1248 grids for minimal displays, 2 words per row"""
+ n_word_cols = 2
+ n_cols_per_word = 4
+ word_col_gap = 4
+ row_gap = 3
+ header_h = FONT_HEIGHT
+
+ label_w = FONT_WIDTH
+ x_start = MINIMAL_PADDING
+ right_pad = MINIMAL_PADDING
+
+ available_w = (
+ self.ctx.display.width() - x_start - label_w - word_col_gap - right_pad
+ )
+ cell_w = available_w // (n_word_cols * n_cols_per_word)
+
+ n_rows = (len(words_list) + n_word_cols - 1) // n_word_cols
+ available_h = self.ctx.display.height() - y_start
+ cell_h = (available_h - n_rows * header_h - (n_rows - 1) * row_gap) // (
+ n_rows * 4
+ )
+ cell_h = min(cell_h, FONT_HEIGHT)
+
+ grid_w = n_cols_per_word * cell_w
+ row_total_h = header_h + 4 * cell_h
+
+ dot_size = max(min(cell_w, cell_h) - 6, 1)
+ radius = dot_size // 2
+
+ x_label = x_start
+ x_grid_left = x_label + label_w
+ x_grid_right = x_grid_left + grid_w + word_col_gap
+
+ for row_idx in range(n_rows):
+ word_pair = words_list[row_idx * n_word_cols : (row_idx + 1) * n_word_cols]
+ y_row = y_start + row_idx * (row_total_h + row_gap)
+ y_grid = y_row + header_h # grid starts below the header
+ grid_h = 4 * cell_h
+
+ # Word-number headers
+ for col_idx, (word_idx, _) in enumerate(word_pair):
+ x_grid = x_grid_left if col_idx == 0 else x_grid_right
+ self.ctx.display.fill_rectangle(
+ x_grid, y_row, grid_w, header_h, theme.disabled_color
+ )
+ x_num = x_grid + (grid_w - 2 * FONT_WIDTH) // 2
+ self.ctx.display.draw_string(
+ x_num,
+ y_row,
+ "%02d" % word_idx,
+ theme.fg_color,
+ theme.disabled_color,
+ )
+
+ # Row labels
+ for bit_row, label in enumerate(BIT_LABELS):
+ self.ctx.display.draw_string(
+ x_label,
+ y_grid + bit_row * cell_h,
+ label,
+ theme.fg_color,
+ )
+
+ for col_idx, (_, word) in enumerate(word_pair):
+ x_grid = x_grid_left if col_idx == 0 else x_grid_right
+
+ # Outer grid border
+ self.ctx.display.draw_line(
+ x_grid, y_grid, x_grid + grid_w, y_grid, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid,
+ y_grid + grid_h,
+ x_grid + grid_w,
+ y_grid + grid_h,
+ theme.frame_color,
+ )
+ self.ctx.display.draw_line(
+ x_grid, y_grid, x_grid, y_grid + grid_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid + grid_w,
+ y_grid,
+ x_grid + grid_w,
+ y_grid + grid_h,
+ theme.frame_color,
+ )
+
+ # Internal vertical column dividers
+ for c in range(1, n_cols_per_word):
+ x_line = x_grid + c * cell_w
+ self.ctx.display.draw_line(
+ x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color
+ )
+
+ # Internal horizontal row dividers
+ for r in range(1, 4):
+ y_line = y_grid + r * cell_h
+ self.ctx.display.draw_line(
+ x_grid, y_line, x_grid + grid_w, y_line, theme.frame_color
+ )
+
+ # Punched marks
+ digits, _ = self._word_to_digits(word)
+ for col, d in enumerate(digits):
+ x_col = x_grid + col * cell_w
+ for bit_row, bit_val in enumerate(BIT_WEIGHTS):
+ if d & bit_val:
+ x_dot = x_col + (cell_w - dot_size) // 2
+ y_dot = y_grid + bit_row * cell_h + (cell_h - dot_size) // 2
+ self.ctx.display.fill_rectangle(
+ x_dot,
+ y_dot,
+ dot_size,
+ dot_size,
+ theme.highlight_color,
+ radius,
+ )
+
+ def export_1248_vertical_grouped(
+ self, y_offset, words_group
+ ): # pylint: disable=too-many-locals
+ """Draws grouped Stackbit 1248 grids with individual bordered sections per word"""
+ if kboard.is_m5stickv:
+ self.x_offset = MINIMAL_PADDING
+ else:
+ self.x_offset = DEFAULT_PADDING
+
+ n_words = len(words_group)
+ n_cols_per_word = 4
+ n_gaps = n_words - 1
+ word_gap = 4
+
+ label_w = FONT_WIDTH + 2
+ available = self.ctx.display.width() - self.x_offset - DEFAULT_PADDING - label_w
+ cell_w = (available - n_gaps * word_gap) // (n_words * n_cols_per_word)
+ cell_h = FONT_HEIGHT
+ header_h = FONT_HEIGHT
+
+ word_block_w = n_cols_per_word * cell_w
+ word_stride = word_block_w + word_gap
+
+ x_label = self.x_offset
+ x_grid0 = x_label + label_w
+ grid_h = 4 * cell_h
+ y_grid = y_offset + header_h
+
+ # Row labels
+ for i, label in enumerate(BIT_LABELS):
+ self.ctx.display.draw_string(
+ x_label, y_grid + i * cell_h, label, theme.fg_color
+ )
+
+ # Per-word header and grid
+ for i, (word_idx, _) in enumerate(words_group):
+ x_sec = x_grid0 + i * word_stride
+
+ # Header background with centred word number
+ self.ctx.display.fill_rectangle(
+ x_sec, y_offset, word_block_w, header_h, theme.disabled_color
+ )
+ x_num = x_sec + (word_block_w - 2 * FONT_WIDTH) // 2
+ self.ctx.display.draw_string(
+ x_num, y_offset, "%02d" % word_idx, theme.fg_color, theme.disabled_color
+ )
+
+ # Outer border of this word's grid
+ self.ctx.display.draw_line(
+ x_sec, y_grid, x_sec + word_block_w, y_grid, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_sec,
+ y_grid + grid_h,
+ x_sec + word_block_w,
+ y_grid + grid_h,
+ theme.frame_color,
+ )
+ self.ctx.display.draw_line(
+ x_sec, y_grid, x_sec, y_grid + grid_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_sec + word_block_w,
+ y_grid,
+ x_sec + word_block_w,
+ y_grid + grid_h,
+ theme.frame_color,
+ )
+
+ # Vertical column dividers
+ for col in range(1, n_cols_per_word):
+ x_line = x_sec + col * cell_w
+ self.ctx.display.draw_line(
+ x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color
+ )
+
+ # Horizontal row dividers
+ if i == 0:
+ for row in range(1, 4):
+ y_line = y_grid + row * cell_h
+ for j in range(n_words):
+ xs = x_grid0 + j * word_stride
+ self.ctx.display.draw_line(
+ xs, y_line, xs + word_block_w, y_line, theme.frame_color
+ )
+
+ # Punched marks
+ dot_size = max(min(cell_w, cell_h) - 6, 1)
+ radius = dot_size // 2
+
+ for w_i, (_, word) in enumerate(words_group):
+ x_sec = x_grid0 + w_i * word_stride
+ digits, _ = self._word_to_digits(word)
+ for col, d in enumerate(digits):
+ x_col = x_sec + col * cell_w
+ for row_idx, bit_val in enumerate(BIT_WEIGHTS):
+ if d & bit_val:
+ x_dot = x_col + (cell_w - dot_size) // 2
+ y_dot = y_grid + row_idx * cell_h + (cell_h - dot_size) // 2
+ self.ctx.display.fill_rectangle(
+ x_dot,
+ y_dot,
+ dot_size,
+ dot_size,
+ theme.highlight_color,
+ radius,
+ )
+
+ # Code and word name below each section
+ y_text = y_grid + grid_h + 2
+ for i, (_, word) in enumerate(words_group):
+ x_sec = x_grid0 + i * word_stride
+ _, digits_str = self._word_to_digits(word)
+ self.ctx.display.draw_string(
+ x_sec, y_text, digits_str, theme.highlight_color
+ )
+ self.ctx.display.draw_string(
+ x_sec, y_text + FONT_HEIGHT, word, theme.disabled_color
+ )
+
def digits_to_word(self, digits):
"""Returns seed word respective to digits BIP39 dictionaty position"""
word_number = int("".join(str(num) for num in digits))
### src/krux/pages/tiny_seed.py
@@ -633,24 +633,6 @@ def choose_rect(rects):
img.draw_rectangle(outline, lcd.WHITE, thickness=thickness)
return rect
- def _draw_grid(self, img):
- if not kboard.has_minimal_display:
- for i in range(13):
- img.draw_line(
- self.x_regions[i],
- self.y_regions[0],
- self.x_regions[i],
- self.y_regions[-1],
- lcd.WHITE,
- )
- img.draw_line(
- self.x_regions[0],
- self.y_regions[i],
- self.x_regions[-1],
- self.y_regions[i],
- lcd.WHITE,
- )
-
def _detect_and_draw_punches(self, img):
"""Detect punched bits on the grid and update the seed numbers accordingly."""
page_seed_numbers = [0] * 12
@@ -811,7 +793,6 @@ def scanner(self, w24=False):
self._gradient_corners(rect, img)
self._map_punches_region(rect, page)
page_seed_numbers = self._detect_and_draw_punches(img)
- self._draw_grid(img)
if kboard.is_m5stickv:
img.lens_corr(strength=1.0, zoom=0.56)
if kboard.is_amigo:
### src/krux/pages/utils.py
@@ -189,6 +189,14 @@ def generate_wallet_info(self, network, policy, script, derivation, is_login=Fal
@staticmethod
def get_network_color(network_name: str):
"""Returns the correct theme color to write network"""
- from ..themes import TEST_TXT_COLOR, MAIN_TXT_COLOR
+ from ..krux_settings import Settings, ThemeSettings
+ from ..themes import (
+ DARKERGREEN,
+ DARKERORANGE,
+ MAIN_TXT_COLOR,
+ TEST_TXT_COLOR,
+ )
+ if Settings().appearance.theme == ThemeSettings.LIGHT_THEME_NAME:
+ return DARKERORANGE if network_name == "Mainnet" else DARKERGREEN
return MAIN_TXT_COLOR if network_name == "Mainnet" else TEST_TXT_COLOR
### src/krux/psbt.py
@@ -21,8 +21,7 @@
# THE SOFTWARE.
import gc
from embit.psbt import PSBT, CompressMode
-from ur.ur import UR
-from urtypes.crypto.psbt import PSBT as URTYPE_PSBT, CRYPTO_PSBT
+from uUR import UR, Types
from .baseconv import base_decode
from .krux_settings import t
from .settings import THIN_SPACE, ELLIPSIS
@@ -92,8 +91,11 @@ def __init__(self, wallet, psbt_data, qr_format, psbt_filename=None):
self.base_encoding = 64 # In case it is exported as QR code
elif isinstance(psbt_data, UR):
try:
- self.psbt = PSBT.parse(URTYPE_PSBT.from_cbor(psbt_data.cbor).data)
- self.ur_type = CRYPTO_PSBT
+ raw = Types.psbt_from_cbor(psbt_data.cbor)
+ self.ur_type = Types.CRYPTO_PSBT_TYPE
+ self.psbt = PSBT.parse(raw)
+ del raw
+ gc.collect()
# self.base_encoding = 64
except:
raise ValueError("invalid PSBT")
@@ -145,6 +147,8 @@ def file_is_base64_encoded(self, file_path, chunk_size=64):
def validate(self):
"""Validates the PSBT"""
+ # Any non_witness_utxo present must really hash to the prevout txid.
+ self.psbt.verify(ignore_missing=True)
# From: https://github.com/diybitcoinhardware/embit/blob/master/examples/change.py#L110
xpubs = []
origin_less_xpub = None
@@ -154,6 +158,10 @@ def validate(self):
# Expected to fail to get xpubs from Miniscript PSBT
pass
for inp in self.psbt.inputs:
+ # Legacy sighashes do not commit to the input amount, so the full
+ # previous transaction is mandatory for non-segwit inputs.
+ if not inp.is_verified and not is_segwit_input(inp):
+ raise ValueError("missing non_witness_utxo on a legacy input")
# get policy of the input
try:
inp_policy = self.get_policy_from_psbt_input(
@@ -170,6 +178,13 @@ def validate(self):
if self.policy != inp_policy:
raise ValueError("mixed inputs in the tx")
+ # A transaction spending more than it funds is invalid and would render
+ # as a negative fee, which reads like a cheap transaction on screen
+ if sum(out.value for out in self.psbt.outputs) > sum(
+ inp.utxo.value for inp in self.psbt.inputs
+ ):
+ raise ValueError("outputs exceed inputs")
+
if self.wallet.is_miniscript():
if not is_miniscript(self.policy):
raise ValueError("Not a miniscript PSBT")
@@ -185,17 +200,28 @@ def validate(self):
if self.wallet.policy != self.policy:
raise ValueError("policy mismatch")
+ def unverified_input_amounts(self):
+ """True if an input amount could be understated without breaking its signature.
+
+ BIP143 commits only to the amount of the input being signed, so with more
+ than one input a coordinator can declare a different amount truthfully in
+ each of two signing sessions and combine one valid signature per input.
+ BIP341 hashes every input amount, so taproot is immune, and with a single
+ input the lie goes into its own sighash and invalidates it.
+ """
+ if len(self.psbt.inputs) < 2 or self.policy["type"] == P2TR:
+ return False
+ return any(not inp.is_verified for inp in self.psbt.inputs)
+
def get_policy_from_psbt_input(self, tx_input, xpubs, origin_less_xpub=None):
"""Extracts the scriptPubKey from an input's UTXO and determines the policy."""
- if tx_input.witness_utxo:
- scriptpubkey = tx_input.witness_utxo.script_pubkey
- elif tx_input.non_witness_utxo:
- # Retrieve the scriptPubKey from the specified output in the non_witness_utxo
- scriptpubkey = tx_input.non_witness_utxo.vout[tx_input.vout].script_pubkey
- else:
+ # Same UTXO object the signer commits to, so policy, displayed amount
+ # and sighash can never be read from different fields
+ utxo = tx_input.utxo
+ if utxo is None:
raise ValueError("No UTXO information available in the input.")
- return get_policy(tx_input, scriptpubkey, xpubs, origin_less_xpub)
+ return get_policy(tx_input, utxo.script_pubkey, xpubs, origin_less_xpub)
def path_mismatch(self):
"""Verifies if the PSBT key path matches loaded keys's derivation path"""
@@ -325,11 +351,8 @@ def outputs(self):
inp_amount = 0
for inp in self.psbt.inputs:
- if inp.witness_utxo:
- inp_amount += inp.witness_utxo.value
- elif inp.non_witness_utxo: # Legacy
- # Retrieve the value from the specified output in the non_witness_utxo
- inp_amount += inp.non_witness_utxo.vout[inp.vout].value
+ # Use exactly the same UTXO object the signer commits to
+ inp_amount += inp.utxo.value
resume_inputs_str = (
(t("Inputs (%d):") % len(self.psbt.inputs))
+ self._btc_render(inp_amount)
@@ -552,14 +575,11 @@ def psbt_qr(self):
psbt_data = base_encode(psbt_data, self.base_encoding)
- if self.ur_type == CRYPTO_PSBT:
- return (
- UR(
- CRYPTO_PSBT.type,
- URTYPE_PSBT(psbt_data).to_cbor(),
- ),
- self.qr_format,
- )
+ if self.ur_type == Types.CRYPTO_PSBT_TYPE:
+ cbor = Types.psbt_to_cbor(psbt_data)
+ del psbt_data
+ gc.collect()
+ return UR(Types.CRYPTO_PSBT_TYPE, cbor), self.qr_format
return psbt_data, self.qr_format
def xpubs(self):
@@ -823,3 +843,22 @@ def get_policy(scope, scriptpubkey, xpubs, origin_less_xpub=None):
pass
return policy
+
+
+def is_segwit_input(inp):
+ """True if the input's sighash commits to the input amount (BIP143/BIP341).
+
+ Only the scriptPubKey and the redeem script are consulted. A declared
+ witness_script is not enough on its own, otherwise attaching one to a
+ legacy input would be enough to skip the previous transaction requirement.
+ """
+ if inp.utxo is not None and inp.utxo.script_pubkey.script_type() in (
+ P2WPKH,
+ P2WSH,
+ P2TR,
+ ):
+ return True
+ return inp.redeem_script is not None and inp.redeem_script.script_type() in (
+ P2WPKH,
+ P2WSH,
+ )
### src/krux/qr.py
@@ -131,6 +131,7 @@ class QRPartParser:
def __init__(self):
self.parts = {}
+ self.payload_len = 0
self.total = -1
self.format = None
self.decoder = None
@@ -139,28 +140,28 @@ def __init__(self):
def parsed_count(self):
"""Returns the number of parsed parts so far"""
if self.format == FORMAT_UR:
- # Single-part URs have no expected part indexes
- if self.decoder.fountain_decoder.expected_part_indexes is None:
+ # Single-part URs report expected_part_count == 0
+ if self.decoder.expected_part_count == 0:
return 1 if self.decoder.result is not None else 0
completion_pct = self.decoder.estimated_percent_complete()
- return math.ceil(completion_pct * self.total_count() / 2) + len(
- self.decoder.fountain_decoder.received_part_indexes
+ return math.ceil(completion_pct * self.total_count() / 2) + min(
+ self.decoder.processed_parts_count, self.decoder.expected_part_count
)
return len(self.parts)
def processed_parts_count(self):
"""Returns quantity of processed QR code parts"""
if self.format == FORMAT_UR:
- return self.decoder.fountain_decoder.processed_parts_count
+ return self.decoder.processed_parts_count
return len(self.parts)
def total_count(self):
"""Returns the total number of parts there should be"""
if self.format == FORMAT_UR:
- # Single-part URs have no expected part indexes
- if self.decoder.fountain_decoder.expected_part_indexes is None:
+ # Single-part URs report expected_part_count == 0
+ if self.decoder.expected_part_count == 0:
return 1
- return self.decoder.expected_part_count() * 2
+ return self.decoder.expected_part_count * 2
return self.total
def parse(self, data):
@@ -177,16 +178,34 @@ def parse(self, data):
self.total = total
return index - 1
elif self.format == FORMAT_UR:
- if not self.decoder:
- from ur.ur_decoder import URDecoder
+ from uUR import URDecoder, DECODER_NO_RESULT, DECODER_ERR_INVALID_CHECKSUM
+ if not self.decoder:
self.decoder = URDecoder()
data = data.decode() if isinstance(data, bytes) else data
- self.decoder.receive_part(data)
+ if self.decoder.receive_part(data) in (
+ DECODER_NO_RESULT,
+ DECODER_ERR_INVALID_CHECKSUM,
+ ):
+ raise ValueError("Failed to decode UR")
elif self.format == FORMAT_BBQR:
- from .bbqr import parse_bbqr
+ from .bbqr import parse_bbqr, BBQR_MAX_PAYLOAD_LEN
part, index, total = parse_bbqr(data)
+ # Only the first part is passed to detect_format, and its encoding and
+ # file type are used to decode all of them. Parts of a BBQr aren't bound
+ # to each other by any checksum, so reject the ones that disagree with
+ # the first instead of splicing different streams into a corrupt result.
+ if data[2] != self.bbqr.encoding or data[3] != self.bbqr.file_type:
+ raise ValueError("BBQr header mismatch")
+ if self.total not in (-1, total):
+ raise ValueError("BBQr part total mismatch")
+ if self.parts.get(index, part) != part:
+ raise ValueError("Conflicting BBQr part")
+ if index not in self.parts:
+ self.payload_len += len(part)
+ if self.payload_len > BBQR_MAX_PAYLOAD_LEN:
+ raise ValueError("BBQr payload too big")
self.parts[index] = part
self.total = total
return index
@@ -195,7 +214,9 @@ def parse(self, data):
def is_complete(self):
"""Returns a boolean indicating whether or not enough parts have been parsed"""
if self.format == FORMAT_UR:
- return self.decoder.is_complete()
+ from uUR import DECODER_OK
+
+ return self.decoder.state == DECODER_OK
keys_check = (
sum(range(1, self.total + 1))
if self.format in (FORMAT_PMOFN, FORMAT_NONE)
@@ -256,11 +277,11 @@ def to_qr_codes(data, max_width, qr_format):
code = qrcode.encode(part)
yield (code, num_parts)
elif qr_format == FORMAT_UR:
- from ur.ur_encoder import UREncoder
+ from uUR import UREncoder
encoder = UREncoder(data, part_size, 0)
while True:
- part = encoder.next_part().upper()
+ part = encoder.next_part()
code = qrcode.encode(part)
yield (code, encoder.fountain_encoder.seq_len())
elif qr_format == FORMAT_BBQR:
@@ -308,7 +329,7 @@ def max_qr_bytes(max_width, encoding="byte"):
try:
return capacity_list[qr_version - 1]
- except:
+ except IndexError:
# Limited to version 20
return capacity_list[-1]
@@ -317,7 +338,7 @@ def find_min_num_parts(data, max_width, qr_format):
"""Finds the minimum number of QR parts necessary to encode the data in
the specified format within the max_width constraint
"""
- encoding = "alphanumeric" if qr_format == FORMAT_BBQR else "byte"
+ encoding = "alphanumeric" if qr_format in (FORMAT_BBQR, FORMAT_UR) else "byte"
qr_capacity = max_qr_bytes(max_width, encoding)
if qr_format == FORMAT_PMOFN:
data_length = len(data)
@@ -406,6 +427,6 @@ def detect_format(data):
bbqr_encoding = data[2]
return FORMAT_BBQR, BBQrCode(None, bbqr_encoding, bbqr_file_type)
- except:
+ except Exception:
pass
return qr_format, None
### src/krux/settings.py
@@ -170,11 +170,7 @@ def __init__(self):
self._load_settings()
# Define location based on what was loaded or default undefined
- self.file_location = (
- self.settings.get("settings", {})
- .get("persist", {})
- .get("location", "undefined")
- )
+ self.file_location = self._persisted_location("undefined")
# Settings not found on SD, or 'persist.location' key not defined
if SD_PATH not in self.file_location:
@@ -184,11 +180,25 @@ def __init__(self):
# Settings persist location will point to SD (if defined) else defaults to flash
self.file_location = Store.get_vfs_location(
- self.settings.get("settings", {})
- .get("persist", {})
- .get("location", FLASH_PATH)
+ self._persisted_location(FLASH_PATH)
)
+ def _persisted_location(self, default):
+ """Reads persist.location defensively; returns a known path or default."""
+ # A corrupted/hand-edited file may have a non-dict at any namespace level
+ # or a bogus location value — walk defensively and validate before returning.
+ node = self.settings
+ for level in ("settings", "persist"):
+ if not isinstance(node, dict):
+ return default
+ node = node.get(level)
+ if not isinstance(node, dict):
+ return default
+ location = node.get("location", default)
+ if location not in (SD_PATH, FLASH_PATH):
+ return default
+ return location
+
@classmethod
def get_vfs_location(cls, location):
"""Returns the formatted vfs location for SD/flash"""
@@ -215,24 +225,22 @@ def _load_settings(self):
pass
def get(self, namespace, setting_name, default_value):
- """Returns a setting value under the given namespace, or default value if not set"""
- s = json.loads(
- json.dumps(self.settings)
- ) # deepcopy to avoid building out namespaces
+ """Returns setting value under the given namespace, or default_value if not set."""
+ s = self.settings
for level in namespace.split("."):
- s[level] = s.get(level, {})
- s = s[level]
- if setting_name not in s:
- return default_value
- return s[setting_name]
+ s = s.get(level)
+ if not isinstance(s, dict):
+ return default_value
+ return s.get(setting_name, default_value)
def set(self, namespace, setting_name, setting_value):
- """Stores a setting value under the given namespace if new/changed.
- Does NOT automatically save settings to flash or sd!
- """
+ """Stores a setting value under the given namespace if new/changed. Does not auto-save."""
+ # A non-dict intermediate level is replaced with a fresh dict,
+ # repairing malformed structure.
s = self.settings
for level in namespace.split("."):
- s[level] = s.get(level, {})
+ if not isinstance(s.get(level), dict):
+ s[level] = {}
s = s[level]
old_value = s.get(setting_name, None)
if old_value != setting_value:
@@ -246,7 +254,8 @@ def delete(self, namespace, setting_name):
s = self.settings
levels = []
for level in namespace.split("."):
- s[level] = s.get(level, {})
+ if not isinstance(s.get(level), dict):
+ s[level] = {}
levels.append([s, level])
s = s[level]
if setting_name in s:
### src/krux/themes.py
@@ -29,17 +29,19 @@
LIGHTBLACK = 0x0842
DARKGREY = 0xEF7B
GREY = 0x14A5
-LIGHTGREY = 0x38C6
DARKWHITE = 0x1CE7
WHITE = 0xFFFF
GREEN = 0xE007
DARKGREEN = 0x8005
+DARKERGREEN = 0x4004
RED = 0x00F8
+DARKERRED = 0x00C0
LIGHT_PINK = 0xDFFC
PINK = 0x1FF8
-PURPLE = 0x0F78
+DARKPINK = 0x1AD0
ORANGE = 0x20FD
DARKORANGE = 0xA0CA
+DARKERORANGE = 0xE0B2
YELLOW = 0x85F6
BLUE = 0xF800
LIGHTBLUE = 0xBD0E
@@ -67,13 +69,13 @@
"background": WHITE,
"info_background": DARKWHITE,
"foreground": BLACK,
- "frame": LIGHTGREY,
+ "frame": DARKGREY,
"disabled": DARKWHITE,
- "go": DARKGREEN,
+ "go": DARKERGREEN,
"esc_no": RED,
"del": DARKORANGE,
"toggle": BLUE,
- "error": RED,
+ "error": DARKERRED,
"highlight": BLUE,
},
ThemeSettings.ORANGE_THEME_NAME: {
@@ -93,7 +95,7 @@
"background": BLACK,
"info_background": LIGHTBLACK,
"foreground": LIGHT_PINK,
- "frame": PURPLE,
+ "frame": DARKPINK,
"disabled": DARKGREY,
"go": PINK,
"esc_no": RED,
### src/krux/touch.py
@@ -93,9 +93,7 @@ def add_x_delimiter(self, region):
def valid_position(self, data):
"""Checks if touch position is within buttons area"""
- if hasattr(Settings().hardware, "display") and getattr(
- Settings().hardware.display, "flipped_orientation", False
- ):
+ if Settings().is_flipped_orientation():
data = (self.height - data[0], self.width - data[1])
if self.x_regions and data[0] < self.x_regions[0]:
@@ -189,9 +187,7 @@ def set_regions(self, x_list=None, y_list=None):
def _store_points(self, data):
"""Store pressed points and calculare an average pressed point"""
- if hasattr(Settings().hardware, "display") and getattr(
- Settings().hardware.display, "flipped_orientation", False
- ):
+ if Settings().is_flipped_orientation():
new_y = max(0, self.height - data[0])
new_y = min(new_y, self.height - 1)
new_x = max(0, self.width - data[1])
### src/krux/translations/__init__.py
@@ -80,6 +80,7 @@
1187826970,
4011811253,
422237057,
+ 3935651977,
1464900930,
3625040530,
4094072796,
@@ -318,6 +319,7 @@
2863098142,
2090568351,
1260825919,
+ 3917592017,
1075810813,
2272013587,
1232757391,
@@ -339,6 +341,7 @@
912182018,
3701549678,
2612594937,
+ 3800135594,
1454688268,
1180180513,
2258131455,
@@ -351,6 +354,7 @@
2061556020,
1128404172,
2089395053,
+ 3805912365,
1374262427,
2518890350,
2786714360,
@@ -365,6 +369,7 @@
4003084591,
3846217531,
1889659487,
+ 1358960330,
4191058607,
1254681955,
525309547,
@@ -374,6 +379,7 @@
2504354847,
2076481321,
2297028319,
+ 3600621033,
3409743444,
4232654916,
2587172867,
### src/krux/translations/de.py
@@ -68,6 +68,7 @@
"Überprüfte %d Adresse ohne Übereinstimmungen.",
"SD-Karte wird gesucht…",
"Bestätigen Sie den Tamper Check Code",
+ "Weiter",
"Datum konvertieren",
"Änderungsadresse konnte nicht ermittelt werden.",
"QR Code erstellen",
@@ -306,6 +307,7 @@
"Einige Knoten sind nicht gehärtet:",
"Ausgabe (%d):",
"Ausgaben:",
+ "Standard",
"Standardmodus",
"Statisch",
"Statistiken für Nerds",
@@ -327,6 +329,7 @@
"Ergebnisse der Testsuite",
"Test:",
"Text",
+ "Die angezeigte Gebühr kann niedriger als die tatsächliche Gebühr sein.",
"Thema",
"Thermisch",
"Um sicherzustellen, dass die Daten nicht wiederhergestellt werden können, verwenden Sie die Funktion 'Gerät löschen'",
@@ -339,6 +342,7 @@
"Schlüssel eingeben",
"Widerrufen",
"Einheit",
+ "Unverifizierte Input-Beträge!",
"KEF-ID aktualisieren?",
"QR-Etikett aktualisieren?",
"Upgrade abgeschlossen.",
@@ -353,6 +357,7 @@
"Wert %S außerhalb des Bereichs: [ %s, %s]",
"Überprüfung…",
"Version",
+ "Vertikal",
"Via Kamera",
"Via D20",
"Via D6",
@@ -362,6 +367,7 @@
"Warte auf die Erfassung",
"Wallet",
"Wallet-Deskriptor",
+ "Wallet-Optionen",
"Geldbörse passt nicht:",
"Wallet Ausgabedeskriptor",
"Wallet Ausgabedeskriptor geladen!",
### src/krux/translations/es.py
@@ -68,6 +68,7 @@
"Comprobado %d direcciones sin coincidencias.",
"Buscando tarjeta SD…",
"Confirmar el código de verificación",
+ "Continuar",
"Convertir dato",
"No se pudo determinar la dirección de cambio.",
"Crear código QR",
@@ -306,6 +307,7 @@
"Algunos nodos no están endurecidos:",
"Gastos (%d):",
"Gasto:",
+ "Estándar",
"Modo estándar",
"Estático",
"Estadísticas para Entendidos",
@@ -327,6 +329,7 @@
"Resultados de la suite de pruebas",
"Prueba:",
"Texto",
+ "La comisión mostrada puede ser menor que la comisión real.",
"Tema",
"Térmico",
"Para garantizar que los datos no se puedan recuperar, utiliza la función de borrar dispositivo",
@@ -339,6 +342,7 @@
"Introduce la clave",
"Deshacer",
"Unidad",
+ "¡Montos de entrada no verificados!",
"¿Actualizar ID de Kef?",
"¿Actualizar etiqueta QR?",
"Actualización completa.",
@@ -353,6 +357,7 @@
"Valor %s fuera del rango: [ %s, %s]",
"Verificando…",
"Versión",
+ "Vertical",
"Desde Cámara",
"Vía D20",
"Vía D6",
@@ -362,6 +367,7 @@
"Espera la captura",
"Cartera",
"Descriptor de Cartera",
+ "Opciones de cartera",
"Cartera no coincide:",
"Descriptor de salida de cartera",
"¡Se ha cargado el descriptor de salida de la cartera!",
### src/krux/translations/fr.py
@@ -68,6 +68,7 @@
"%d adresses vérifiées sans correspondance.",
"Recherche de carte SD…",
"Confirmer le code de non compromis",
+ "Continuer",
"Convertir le datum",
"Impossible de déterminer l'adresse de monnaie.",
"Créer un QR Code",
@@ -306,6 +307,7 @@
"Certains nœuds ne sont pas durcis :",
"Dépense (%d)\u2009:",
"Dépense\u2009:",
+ "Standard",
"Mode standard",
"Statique",
"Statistiques pour les geeks",
@@ -327,6 +329,7 @@
"Résultats de la suite de tests",
"Test:",
"Texte",
+ "Les frais affichés peuvent être inférieurs aux frais réels.",
"Thème",
"Thermique",
"Pour assurer que les données soient irrécupérables, utilisez la fonctionnalité 'Effacer l'appareil'",
@@ -339,6 +342,7 @@
"Taper clé",
"Annuler",
"Unité",
+ "Montants d'entrée non vérifiés !",
"Mettre à jour l'ID KEF\u2009?",
"Mettre à jour l'étiquette QR\u2009?",
"Mise à jour complète.",
@@ -353,6 +357,7 @@
"Valeur %s hors de portée: [%s, %s]",
"Vérification…",
"Version",
+ "Vertical",
"Par caméra",
"Via D20",
"Via D6",
@@ -362,6 +367,7 @@
"Attendez la capture",
"Portefeuille",
"Descripteur de Portefeuille",
+ "Options du portefeuille",
"Portefeuille différent:",
"Descripteur de sortie du portefeuille",
"Descripteur de sortie du portefeuille chargé\u2009!",
### src/krux/translations/ja.py
@@ -68,6 +68,7 @@
"%d のアドレスを確認しましたが、一致するものはありませんでした.",
"SDカードを確認しています…",
"改ざんチェックコードの確認",
+ "続行",
"データムの変換",
"変更先住所を特定できませんでした.",
"QRコードを作成",
@@ -306,6 +307,7 @@
"一部のノードは硬化されていません:",
"支出(%d):",
"支出:",
+ "標準",
"標準モード",
"静止画",
"オタクのための統計",
@@ -327,6 +329,7 @@
"テストスイートの結果",
"テスト:",
"テキスト",
+ "手数料は表示より高いかもしれません.",
"テーマ",
"サーマル",
"データが復元不可能であることを確実にするには、デバイス消去機能を使用してください",
@@ -339,6 +342,7 @@
"キーを入力する",
"取り消し",
"ユニット",
+ "未検証のインプット金額!",
"KEF IDを更新しますか?",
"QRラベルを更新しますか?",
"アップグレードが完了しました.",
@@ -353,6 +357,7 @@
"値%sが範囲外です: [ %s, %s]",
"認証中…",
"バージョン",
+ "縦向き",
"カメラ経由",
"D20経由",
"D6経由",
@@ -362,6 +367,7 @@
"キャプチャを待ってください",
"ワレット",
"ウォレットディスクリプター",
+ "ウォレットオプション",
"ウォレット不一致:",
"ウォレット出力ディスクリプター",
"ウォレット出力ディスクリプターがロードされました!",
### src/krux/translations/ko.py
@@ -68,6 +68,7 @@
"일치하는 주소가 없는 %d 개를 확인했습니다.",
"SD 카드 확인 중…",
"탬퍼 체크 코드 확인",
+ "계속",
"날짜 변환",
"변경 주소를 확인할 수 없습니다.",
"QR 코드 생성",
@@ -306,6 +307,7 @@
"일부 노드가 경화되지 않습니다:",
"Spend (%d):",
"지출:",
+ "표준",
"표준 모드",
"Static",
"전문가를 위한 통계",
@@ -327,6 +329,7 @@
"테스트 제품군 결과",
"Test:",
"텍스트",
+ "표시된 수수료가 실제 수수료보다 적을 수 있습니다.",
"테마",
"Thermal",
"데이터 복구가 불가능하도록 장치 전체지우기 기능을 사용하십시오",
@@ -339,6 +342,7 @@
"비밀번호 입력",
"실행 취소",
"단위",
+ "검증되지 않은 입력 값!",
"KEF ID를 업데이트하시겠습니까?",
"QR 레이블을 업데이트하시겠습니까?",
"업그레이드가 완료되었습니다.",
@@ -353,6 +357,7 @@
"%s는 [%s, %s] 범위를 벗어났습니다",
"확인…",
"버전",
+ "세로",
"카메라",
"20면체 주사위",
"일반 주사위",
@@ -362,6 +367,7 @@
"캡처될때까지 기다리십시오",
"지갑 설정",
"지갑 디스크립터",
+ "지갑 옵션",
"지갑 불일치:",
"지갑 출력 디스크립터",
"지갑 출력 디스크립터가 로드되었습니다!",
### src/krux/translations/nl.py
@@ -68,6 +68,7 @@
"%d adressen gecontroleerd zonder overeenkomsten.",
"Controleren op SD-kaart…",
"Bevestig de sabotagecontrolecode",
+ "Doorgaan",
"Datum converteren",
"Kan adreswijziging niet bepalen.",
"QR-code aanmaken",
@@ -306,6 +307,7 @@
"Sommige knooppunten zijn niet gehard:",
"Uitgaven (%d):",
"Uitgaven:",
+ "Standaard",
"Standaardmodus",
"Statisch",
"Statistieken voor nerds",
@@ -327,6 +329,7 @@
"Test Suite-resultaten",
"Test:",
"Tekst",
+ "Het getoonde tarief kan lager zijn dan het werkelijke tarief.",
"Thema",
"Thermisch",
"Gebruik de functie 'Apparaat wissen' om te zorgen dat de gegevens onherstelbaar zijn",
@@ -339,6 +342,7 @@
"Voer sleutel in",
"Ongedaan maken",
"Eenheid",
+ "Niet-geverifieerde invoerbedragen!",
"KEF-ID bijwerken?",
"QR-label bijwerken?",
"Upgrade afgerond.",
@@ -353,6 +357,7 @@
"Waarde %s is buiten bereik: [%s, %s]",
"Controleren…",
"Versie",
+ "Verticaal",
"Via camera",
"Via D20",
"Via D6",
@@ -362,6 +367,7 @@
"Wacht op opname",
"Portemonnee",
"Descriptor",
+ "Portemonneeopties",
"Portemonnee onjuist:",
"Portemonnee descriptor",
"Portemonnee descriptor geladen!",
### src/krux/translations/pt.py
@@ -68,6 +68,7 @@
"%d endereços checados sem correspondência.",
"Procurando por cartão SD…",
"Confirmar código de verificação de integridade",
+ "Continuar",
"Converter dados",
"Não foi possível determinar endereços de troco.",
"Criar Código QR",
@@ -306,6 +307,7 @@
"Alguns nós não são hardened:",
"Gastos (%d):",
"Gasto:",
+ "Padrão",
"Modo padrão",
"Estático",
"Estatísticas para nerds",
@@ -327,6 +329,7 @@
"Resultados da suíte de testes",
"Teste:",
"Texto",
+ "A taxa exibida pode ser menor que a taxa real.",
"Tema",
"Térmica",
"Para garantir que os dados sejam irrecuperáveis, use o recurso Limpar Dispositivo",
@@ -339,6 +342,7 @@
"Digite a Chave",
"Desfazer",
"Unidade",
+ "Valores de entrada não verificados!",
"Atualizar KEF ID?",
"Atualizar etiqueta QR?",
"Atualização concluída.",
@@ -353,6 +357,7 @@
"Valor %s fora do intervalo: [%s, %s]",
"Checando…",
"Versão",
+ "Vertical",
"Pela Câmera",
"Via D20",
"Via D6",
@@ -362,6 +367,7 @@
"Aguarde a captura",
"Carteira",
"Descritor da Carteira",
+ "Opções da carteira",
"Carteira diferente:",
"Descritor da carteira",
"Descritor da carteira carregado!",
### src/krux/translations/ru.py
@@ -68,6 +68,7 @@
"Проверено %d адресов без совпадений.",
"Проверка SD-карты…",
"Подтвердите код проверки вскрытия",
+ "Продолжить",
"Преобразовать датум",
"Не удалось определить адрес изменения.",
"Создать QR-код",
@@ -306,6 +307,7 @@
"Некоторые узлы не укреплены:",
"Расход (%d):",
"Расход:",
+ "Стандартный",
"Стандартный режим",
"Static / Статическое оборудование",
"Статистика для Гиков",
@@ -327,6 +329,7 @@
"Результаты набора тестов",
"Испыт.:",
"Текст",
+ "Показанная комиссия может быть ниже реальной комиссии.",
"Тема",
"Термальный",
"Для гарантии невосстановления данных используйте функцию Очистки Устройства",
@@ -339,6 +342,7 @@
"Ввести Ключ",
"Отменить",
"Единица Измерения",
+ "Непроверенные суммы входов!",
"Обновить идентификатор KEF?",
"Обновить QR-метку?",
"Обновление завершено.",
@@ -353,6 +357,7 @@
"Значение %s вне диапозона: [%s, %s]",
"Верификация…",
"Версия",
+ "Вертикальный",
"С Помощью Камеры",
"С Помощью D20",
"С Помощью D6",
@@ -362,6 +367,7 @@
"Дождитесь Захвата",
"Кошелек",
"Дескриптор Кошелька",
+ "Параметры кошелька",
"Кошелёк не совпадает:",
"Выходной дескриптор кошелька",
"Выходной дескриптор кошелька загружен!",
### src/krux/translations/tr.py
@@ -68,6 +68,7 @@
"Eşleşmeyen %d adres kontrol edildi.",
"SD kart kontrol ediliyor…",
"Kurcalama Kontrol Kodunu Onayla",
+ "Devam",
"Veriyi Dönüştür",
"Değişiklik adresi belirlenemedi.",
"QR Kodu Oluştur",
@@ -306,6 +307,7 @@
"Bazı düğümler sertleştirilmemiş:",
"Harcama (%d):",
"Harcama:",
+ "Standart",
"Standart Mod",
"Statik",
"İnekler İçin İstatistikler",
@@ -327,6 +329,7 @@
"Test Paketi Sonuçları",
"Test:",
"Metin",
+ "Gösterilen ücret gerçek ücretten düşük olabilir.",
"Tema",
"Termal",
"Verilerin geri kullanılamaz olduğundan emin olmak için Cihazı Sil özelliğini kullanın",
@@ -339,6 +342,7 @@
"Anahtar Yaz",
"Geri Al",
"Birim",
+ "Doğrulanmamış giriş tutarları!",
"Kef Kimliği Güncellensin mi?",
"QR Etiketi Güncellensin mi",
"Güncelleme tamamlandı.",
@@ -353,6 +357,7 @@
"%s değeri aralık dışında: [%s, %s]",
"Doğrulanıyor…",
"Sürüm",
+ "Dikey",
"Kamera Aracılığıyla",
"D20 Aracılığıyla",
"D6 Aracılığıyla",
@@ -362,6 +367,7 @@
"Yakalamanın tamamlanmasını bekleyin",
"Cüzdan",
"Cüzdan Tanımlayıcısı",
+ "Cüzdan Seçenekleri",
"Cüzdan uyuşmazlığı:",
"Cüzdan çıktı tanımlayıcısı",
"Cüzdan çıktı tanımlayıcısı yüklendi!",
### src/krux/translations/vi.py
@@ -68,6 +68,7 @@
"Đã kiểm tra %d địa chỉ không khớp.",
"Đang kiểm tra thẻ SD…",
"Xác nhận mã kiểm tra giả mạo",
+ "Tiếp tục",
"Chuyển đổi dữ liệu",
"Không thể xác định địa chỉ thay đổi.",
"Tạo mã QR",
@@ -306,6 +307,7 @@
"Một số nút không được làm cứng:",
"Chi tiêu (%d):",
"Chi tiêu:",
+ "Tiêu chuẩn",
"Chế độ Tiêu chuẩn",
"Tĩnh",
"Số liệu thống kê cho Mọt sách",
@@ -327,6 +329,7 @@
"Kết quả bộ thử nghiệm",
"Kiểm tra bài cũ:",
"Văn bản",
+ "Phí hiển thị có thể thấp hơn phí thực tế.",
"Chủ đề",
"Nhiệt",
"Sử dụng tính năng Xóa dữ liệu trên thiết bị để đảm bảo dữ liệu không thể phục hồi",
@@ -339,6 +342,7 @@
"Nhập khóa",
"Hoàn tác",
"Đơn vị",
+ "Số tiền đầu vào chưa được xác minh!",
"Cập nhật ID KEF?",
"Cập nhật nhãn QR?",
"Nâng cấp hoàn tất.",
@@ -353,6 +357,7 @@
"Giá trị %s ngoài phạm vi: [ %s, %s]",
"Xác minh…",
"Phiên Bản",
+ "Dọc",
"Qua máy ảnh",
"Qua xúc xắc 20 mặt",
"Qua xúc xắc 6 mặt",
@@ -362,6 +367,7 @@
"Chờ bắt",
"Ví",
"Trình mô tả ví",
+ "Tùy chọn ví",
"Ví không khớp:",
"Ví đầu ra mô tả",
"Đã tải bộ mô tả đầu ra của ví!",
### src/krux/translations/zh.py
@@ -68,6 +68,7 @@
"已检查 %d 个不匹配的地址.",
"检查卡…",
"确认防篡改检查码",
+ "继续",
"转换基准",
"无法确定更改地址.",
"创建二维码",
@@ -306,6 +307,7 @@
"有些节点未硬化:",
"花费 (%d):",
"花费",
+ "标准",
"标准模式",
"Static 静态?",
"极客统计数据",
@@ -327,6 +329,7 @@
"测试套件结果",
"测试:",
"文本",
+ "费用可能高于显示的金额.",
"主题",
"热敏",
"要确保数据不可恢复,请使用擦除设备功能",
@@ -339,6 +342,7 @@
"输入私钥",
"撤销",
"单位",
+ "未验证的输入金额!",
"更新KEF ID ?",
"更新二维码标签?",
"升级已完成.",
@@ -353,6 +357,7 @@
"值 %s 超出范围:[ %s,%s ]",
"验证中…",
"版本",
+ "垂直",
"通过摄像头",
"通过 D20",
"通过 D6",
@@ -362,6 +367,7 @@
"等待截取",
"钱包",
"钱包描述",
+ "钱包选项",
"钱包不匹配:",
"钱包输出描述符",
"钱包输出描述符加载重复!",
### src/krux/wallet.py
@@ -423,28 +423,18 @@ def parse_wallet(wallet_data):
# Check if wallet_data is a UR object without loading the UR module
if wallet_data.__class__.__name__ == "UR":
- # Try to parse as a Crypto-Output type
- try:
- from urtypes.crypto.output import Output
+ from uUR import Types
- output = Output.from_cbor(wallet_data.cbor)
- return Descriptor.from_string(output.descriptor()), None
- except:
- pass
+ if wallet_data.type == "crypto-output":
+ output = Types.output_from_cbor(wallet_data.cbor)
+ return Descriptor.from_string(output), None
- # Try to parse as a Crypto-Account type
- try:
- from urtypes.crypto.account import Account
-
- account = Account.from_cbor(wallet_data.cbor).output_descriptors[0]
- return Descriptor.from_string(account.descriptor()), None
- except:
- pass
+ if wallet_data.type == "crypto-account":
+ output = Types.output_from_cbor_account(wallet_data.cbor)
+ return Descriptor.from_string(output), None
# Treat the UR as a generic UR bytes object and extract the data for further processing
- from urtypes.bytes import Bytes
-
- wallet_data = Bytes.from_cbor(wallet_data.cbor).data
+ wallet_data = Types.bytes_from_cbor(wallet_data.cbor)
# Process as a string
wallet_data = (
@@ -463,7 +453,9 @@ def parse_wallet(wallet_data):
raise KeyError('"descriptor" key not found in JSON')
except KeyError:
raise ValueError("invalid wallet format")
- except:
+ except Exception:
+ # Untrusted input: any non-KeyError parse failure (bad JSON, bad
+ # descriptor) falls through to the next format.
pass
# Try to parse as a key-value file
@@ -473,14 +465,17 @@ def parse_wallet(wallet_data):
return descriptor, label
except ValueError:
raise
- except:
+ except Exception:
+ # Untrusted input: an unexpected parse failure means "invalid wallet".
raise ValueError("invalid wallet format")
# Try to parse directly as a descriptor
try:
descriptor = Descriptor.from_string(wallet_data.strip())
return descriptor, None
- except:
+ except Exception:
+ # Untrusted input: not a bare descriptor either; fall through to the
+ # final raise.
pass
raise ValueError("invalid wallet format")
@@ -492,7 +487,7 @@ def parse_address(address_data):
If the address cannot be derived, an exception is raised.
"""
- from embit.script import Script, address_to_scriptpubkey
+ from embit.script import Script, address_to_scriptpubkey, EmbitError
addr = address_data
sc = None
@@ -508,13 +503,17 @@ def parse_address(address_data):
sc = address_to_scriptpubkey(addr.lower())
if isinstance(sc, Script):
return addr.lower()
- except:
+ except EmbitError:
pass
if not isinstance(sc, Script):
try:
- address_to_scriptpubkey(addr)
- except:
+ sc = address_to_scriptpubkey(addr)
+ except EmbitError:
+ raise ValueError("invalid address")
+ # A base58 address with a valid checksum but an unknown version byte
+ # returns None here instead of raising, so verify a Script came back.
+ if not isinstance(sc, Script):
raise ValueError("invalid address")
return addr
### tests/conftest.py
@@ -8,7 +8,6 @@
board_m5stickv,
board_wonder_mv,
board_yahboom,
- board_bit,
board_wonder_k,
board_embed_fire,
encode_to_string,
@@ -30,6 +29,18 @@ def reset_krux_modules():
del sys.modules[name]
+@pytest.fixture(autouse=True)
+def no_gc_collect(monkeypatch):
+ """Krux calls gc.collect() often to manage the device's small heap.
+ On CPython each call walks the much bigger test heap (mostly mock objects)
+ and does nothing useful, costing about a third of the suite runtime.
+ Tests that assert on gc.collect patch it themselves, over this one.
+ """
+ import gc
+
+ monkeypatch.setattr(gc, "collect", lambda *args: 0)
+
+
@pytest.fixture
def mp_modules(mocker, monkeypatch):
from embit.util import secp256k1
@@ -38,6 +49,8 @@ def mp_modules(mocker, monkeypatch):
import sys
import hashlib
+ # uUR is the native BC-UR extension, installed as a real dependency, so it
+ # needs no mock: tests exercise the same module the firmware does.
monkeypatch.setitem(
sys.modules,
"qrcode",
@@ -158,15 +171,6 @@ def wonder_mv(monkeypatch, mp_modules):
reset_krux_modules()
-@pytest.fixture
-def bit(monkeypatch, mp_modules):
- import sys
-
- monkeypatch.setitem(sys.modules, "board", board_bit())
- monkeypatch.setitem(sys.modules, "pmu", None)
- reset_krux_modules()
-
-
@pytest.fixture
def wonder_k(monkeypatch, mp_modules):
import sys
@@ -193,7 +197,6 @@ def embed_fire(monkeypatch, mp_modules):
"cube",
"yahboom",
"wonder_mv",
- "bit",
"wonder_k",
]
)
### tests/pages/home_pages/test_home.py
@@ -941,6 +941,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -966,6 +967,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -992,6 +994,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from QR code
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1042,6 +1045,7 @@ def test_sign_psbt(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from SD card
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1230,6 +1234,7 @@ def test_psbt_warnings(mocker, m5stickv, tdata):
BUTTON_ENTER, # Load from SD card
BUTTON_ENTER, # Path mismatch ACK
BUTTON_ENTER, # PSBT Policy ACK
+ BUTTON_ENTER, # Unverified input amounts ACK
BUTTON_ENTER, # PSBT resume
BUTTON_ENTER, # output 1
BUTTON_ENTER, # output 2
@@ -1912,3 +1917,37 @@ def test_sign_spent_and_self(mocker, m5stickv, tdata):
),
]
)
+
+
+def test_unverified_amounts_warning(mocker, m5stickv):
+ """The warning must be shown and must abort signing when declined"""
+ from krux.pages.home_pages.home import Home
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ class FakeSigner:
+ def __init__(self, unverified):
+ self.unverified = unverified
+
+ def unverified_input_amounts(self):
+ return self.unverified
+
+ # Declined
+ ctx = create_ctx(mocker, [BUTTON_PAGE])
+ home = Home(ctx)
+ mocker.spy(ctx.display, "draw_centered_text")
+ assert home._unverified_amounts_psbt_warn(FakeSigner(True)) is False
+ shown = ctx.display.draw_centered_text.call_args[0][0]
+ assert "Unverified input amounts!" in shown
+ assert "The fee shown may be lower than the real fee." in shown
+
+ # Accepted
+ ctx = create_ctx(mocker, [BUTTON_ENTER])
+ home = Home(ctx)
+ assert home._unverified_amounts_psbt_warn(FakeSigner(True)) is True
+
+ # Nothing to warn about, no prompt consumed
+ ctx = create_ctx(mocker, [])
+ home = Home(ctx)
+ mocker.spy(ctx.display, "draw_centered_text")
+ assert home._unverified_amounts_psbt_warn(FakeSigner(False)) is True
+ assert ctx.display.draw_centered_text.call_count == 0
### tests/pages/test_datum_tool.py
@@ -25,9 +25,7 @@ def mock_file_operations(mocker):
def test_urobj_to_data(m5stickv, mocker):
"""Test that urobj_to_data returns flattened data from UR objects."""
from krux.pages.datum_tool import urobj_to_data
- from ur.ur import UR
- from urtypes.crypto.psbt import PSBT
- from urtypes.bytes import Bytes
+ from uUR import UR, Types
UR_BIP39_WORDS_BYTES = b"\xa2\x01\x8cfshieldegroupeerodeeawakedlockgsausagedcasheglaredwavedcreweflameeglove\x02ben"
MNEMONIC = "shield group erode awake lock sausage cash glare wave crew flame glove"
@@ -42,15 +40,15 @@ def test_urobj_to_data(m5stickv, mocker):
"expected": MULTISIG_DESCR,
},
{
- "control": UR("crypto-psbt", PSBT(P2PKH_PSBT_BYTES).to_cbor()),
+ "control": UR("crypto-psbt", Types.psbt_to_cbor(P2PKH_PSBT_BYTES)),
"expected": P2PKH_PSBT_BYTES,
},
{
- "control": UR("bytes", Bytes(MULTISIG_DESCR.encode()).to_cbor()),
+ "control": UR("bytes", Types.bytes_to_cbor(MULTISIG_DESCR.encode())),
"expected": MULTISIG_DESCR.encode(),
},
{
- "control": UR("bytes", Bytes(P2PKH_PSBT_BYTES).to_cbor()),
+ "control": UR("bytes", Types.bytes_to_cbor(P2PKH_PSBT_BYTES)),
"expected": P2PKH_PSBT_BYTES,
},
]
@@ -316,12 +314,11 @@ def test_datumtoolmenu_scan_qr_abort(m5stickv, mocker):
page = DatumToolMenu(ctx).run()
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
- from ur.ur import UR
- from urtypes.bytes import Bytes
+ from uUR import UR, Types
# scan UR-QR (for coverage), then back out of datum tool
MULTISIG_DESCR = "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))#t2zpj2eu"
- ur_obj = UR("bytes", Bytes(MULTISIG_DESCR.encode()).to_cbor())
+ ur_obj = UR("bytes", Types.bytes_to_cbor(MULTISIG_DESCR.encode()))
mocker.patch.object(QRCodeCapture, "qr_capture_loop", new=lambda self: (ur_obj, 2))
BTN_SEQUENCE = (
BUTTON_ENTER, # go Scan QR
### tests/pages/test_encryption_ui.py
@@ -41,7 +41,7 @@ def mock_file_operations(mocker):
"os.listdir",
new=mocker.MagicMock(return_value=["somefile", "otherfile"]),
)
- mocker.patch("builtins.open", mocker.mock_open(read_data="SEEDS_JSON"))
+ mocker.patch("builtins.open", mocker.mock_open(read_data=SEEDS_JSON))
def test_load_key_from_keypad(m5stickv, mocker):
@@ -289,6 +289,49 @@ def test_encrypt_save_error(m5stickv, mocker, mock_file_operations):
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+def test_encrypt_save_corrupted_file_preserved(m5stickv, mocker, mock_file_operations):
+ from krux.wallet import Wallet
+ from krux.krux_settings import Settings
+ from krux.input import BUTTON_ENTER
+ from krux.pages.encryption_ui import EncryptMnemonic
+ from krux.encryption import StorageCorruptedError
+ from krux.key import Key
+ from embit.networks import NETWORKS
+ from krux.themes import theme
+
+ BTN_SEQUENCE = (
+ [BUTTON_ENTER] # Confirm flash store
+ + [BUTTON_ENTER] # Yes, use fingerprint as ID
+ + [BUTTON_ENTER] # Confirm encryption ID
+ )
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ ctx.wallet = Wallet(Key(ECB_WORDS, False, NETWORKS["main"]))
+ Settings().encryption.version = "AES-ECB"
+ storage_ui = EncryptMnemonic(ctx)
+ mocker.patch(
+ "krux.pages.encryption_ui.EncryptionKey.encryption_key",
+ mocker.MagicMock(return_value=TEST_KEY),
+ )
+ # a corrupt seeds file must not be overwritten: store raises, UI warns
+ mocker.patch(
+ "krux.encryption.MnemonicStorage.store_encrypted_kef",
+ mocker.MagicMock(side_effect=StorageCorruptedError("seeds.json")),
+ )
+ storage_ui.encrypt_menu()
+
+ ctx.display.draw_centered_text.assert_has_calls(
+ [
+ mocker.call(
+ "Stored seeds file is corrupted and was preserved.\n"
+ "Encrypted mnemonic was not stored.",
+ theme.error_color,
+ )
+ ],
+ any_order=True,
+ )
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+
+
def test_encrypt_to_qrcode_ecb_ui(m5stickv, mocker):
from krux.wallet import Wallet
from krux.krux_settings import Settings
### tests/pages/test_fill_flash.py
@@ -123,8 +123,6 @@ def test_fill_flash_entropy_timeout_scenario(amigo, mocker):
def test_fill_flash_insufficient_entropy_scenario(amigo, mocker):
- # Test insufficient entropy scenario using mocker.patch instead of PropertyMock
- # Following @qlrd recommendation to avoid false positive assertions
from krux.pages.fill_flash import FillFlash
from krux.pages.capture_entropy import CameraEntropy, INSUFFICIENT_VARIANCE_TH
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
@@ -137,10 +135,21 @@ def test_fill_flash_insufficient_entropy_scenario(amigo, mocker):
ctx = create_ctx(mocker, btn_sequence)
fill_flash = FillFlash(ctx)
+
+ # Fake clock so the capture timeout elapses after a few frames, instead of
+ # spinning on the mocked camera for 25 real seconds and piling up mock calls
+ clock = [0]
+
+ def _fake_time():
+ clock[0] += 5
+ return clock[0]
+
+ mocker.patch("krux.pages.fill_flash.time.time", side_effect=_fake_time)
+
entropy_measurement = CameraEntropy(ctx)
- # Use mocker.patch.object instead of PropertyMock as per @qlrd recommendation
- # This avoids false positive assertions that can occur with PropertyMock
+ # Test insufficient entropy scenario using mocker.patch
+ # to avoid false positive assertions
mocker.patch.object(
entropy_measurement,
"rms_value",
### tests/pages/test_flash_tools.py
@@ -89,7 +89,6 @@ def test_tc_flash_hash(multiple_devices, mocker):
"cube": 208,
"yahboom": DOCK_FW_POS,
"wonder_mv": DOCK_FW_POS,
- "bit": DOCK_FW_POS,
"wonder_k": DOCK_FW_POS,
}
users_data_words_positions = {
@@ -99,7 +98,6 @@ def test_tc_flash_hash(multiple_devices, mocker):
"cube": 222,
"yahboom": DOCK_USER_POS,
"wonder_mv": DOCK_USER_POS,
- "bit": DOCK_USER_POS,
"wonder_k": DOCK_USER_POS,
}
fw_words_pos = fw_words_positions[board.config["type"]]
### tests/pages/test_login.py
@@ -10,6 +10,7 @@ def mocker_printer(mocker):
@pytest.fixture
def mock_retro_compatibility(mocker, amigo):
from krux.settings import CategorySetting
+ from krux.krux_settings import Settings
class MockDefaultWallet:
namespace = "settings.wallet"
@@ -24,6 +25,10 @@ def label(self, _):
"krux.krux_settings.DefaultWallet",
mocker.MagicMock(return_value=MockDefaultWallet()),
)
+ # Settings caches its namespace tree, which may already have been built
+ # (e.g. via krux.themes at import). Drop the cache so the next Settings()
+ # rebuilds with the patched DefaultWallet.
+ mocker.patch.object(Settings, "_instance", None)
################### Test menus
@@ -508,7 +513,7 @@ def test_load_12w_camera_qrcode_format_ur(m5stickv, mocker, mocker_printer):
from krux.qr import FORMAT_UR
from krux.pages.qr_capture import QRCodeCapture
import binascii
- from ur.ur import UR
+ from uUR import UR
BTN_SEQUENCE = (
# 1 press to proceed with the 12 words
@@ -1454,6 +1459,100 @@ def test_customization_while_loading_wallet(amigo, mocker):
assert "krux.pages.wallet_settings" in sys.modules
+def test_generated_mnemonic_wallet_options_return_to_summary(amigo, mocker):
+ from krux.pages import MENU_CONTINUE, MENU_EXIT
+ from krux.pages.login import Login
+ from krux.pages.wallet_settings import PassphraseEditor, WalletSettings
+ from krux.krux_settings import Settings
+
+ mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo daring"
+ Settings().security.hide_mnemonic = True
+
+ ctx = create_ctx(mocker, [])
+ login = Login(ctx)
+ discard_prompt = mocker.patch.object(login, "prompt", return_value=False)
+
+ passphrase_editor = mocker.patch.object(
+ PassphraseEditor,
+ "load_passphrase_menu",
+ return_value="secret",
+ )
+ wallet_settings = mocker.patch.object(
+ WalletSettings,
+ "customize_wallet",
+ side_effect=lambda key: (
+ key.network,
+ key.policy_type,
+ key.script_type,
+ key.account_index,
+ key.derivation,
+ ),
+ )
+
+ menu_selections = iter([1, 2, 2, 1, 0, 1, 1, 0])
+ menu_labels = []
+
+ class MenuStub:
+ def __init__(self, _ctx, menu, **_kwargs):
+ self.menu = menu + [("< Back", lambda: MENU_EXIT)]
+ menu_labels.append([label for label, _ in self.menu])
+
+ @property
+ def back_index(self):
+ return len(self.menu) - 1
+
+ def run_loop(self):
+ return next(menu_selections), MENU_CONTINUE
+
+ mocker.patch("krux.pages.login.Menu", MenuStub)
+
+ assert login._load_key_from_words(mnemonic.split(), new=True) == MENU_EXIT
+ assert menu_labels == [
+ ["Continue", "Wallet Options", "< Back"],
+ ["Passphrase", "Customize", "< Back"],
+ ["Continue", "Wallet Options", "< Back"],
+ ["Continue", "Wallet Options", "< Back"],
+ ["Passphrase", "Customize", "< Back"],
+ ["Continue", "Wallet Options", "< Back"],
+ ["Passphrase", "Customize", "< Back"],
+ ["Continue", "Wallet Options", "< Back"],
+ ]
+ discard_prompt.assert_called_once()
+ passphrase_editor.assert_called_once_with(mnemonic)
+ wallet_settings.assert_called_once()
+ assert ctx.wallet.key.passphrase == "secret"
+
+
+def test_loaded_mnemonic_keeps_direct_wallet_actions(amigo, mocker):
+ from krux.pages import MENU_CONTINUE, MENU_EXIT
+ from krux.pages.login import Login
+ from krux.krux_settings import Settings
+
+ mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo daring"
+ Settings().security.hide_mnemonic = True
+
+ ctx = create_ctx(mocker, [])
+ login = Login(ctx)
+ menu_labels = []
+
+ class MenuStub:
+ def __init__(self, _ctx, menu, **_kwargs):
+ self.menu = menu + [("< Back", lambda: MENU_EXIT)]
+ menu_labels.append([label for label, _ in self.menu])
+
+ @property
+ def back_index(self):
+ return len(self.menu) - 1
+
+ def run_loop(self):
+ return 0, MENU_CONTINUE
+
+ mocker.patch("krux.pages.login.Menu", MenuStub)
+
+ assert login._load_key_from_words(mnemonic.split()) == MENU_EXIT
+ assert menu_labels == [["Load Wallet", "Passphrase", "Customize", "< Back"]]
+
+
def test_about(mocker, multiple_devices):
from krux.pages.login import Login
import board
### tests/pages/test_page.py
@@ -38,7 +38,7 @@ def test_init(mocker, m5stickv, mock_page_cls):
def test_flash_text(mocker, m5stickv, mock_page_cls):
from krux.display import FLASH_MSG_TIME
- from krux.themes import WHITE, RED
+ from krux.themes import WHITE, RED, GREEN
ctx = mock_context(mocker)
mocker.patch("time.ticks_ms", new=lambda: 0)
@@ -57,6 +57,13 @@ def test_flash_text(mocker, m5stickv, mock_page_cls):
"Error", RED, FLASH_MSG_TIME, highlight_prefix=""
)
+ page.flash_success("Done")
+
+ assert ctx.display.flash_text.call_count == 3
+ ctx.display.flash_text.assert_called_with(
+ "Done", GREEN, FLASH_MSG_TIME, highlight_prefix=""
+ )
+
def test_prompt_m5stickv(mocker, m5stickv, mock_page_cls):
from krux.input import BUTTON_ENTER, BUTTON_PAGE
@@ -130,6 +137,31 @@ def test_display_qr_code(mocker, m5stickv, mock_page_cls):
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+def test_display_qr_code_propagates_real_errors(mocker, m5stickv, mock_page_cls):
+ """A non-StopIteration error from the QR generator must propagate and must
+ NOT trigger a silent generator restart.
+
+ Regression for narrowing the bare ``except`` to ``except StopIteration``.
+ With the old bare except, a real error was swallowed and ``to_qr_codes``
+ was called a second time; now it surfaces immediately.
+ """
+ from krux.qr import FORMAT_NONE
+
+ def boom(*args, **kwargs):
+ raise ValueError("bad qr data")
+ yield # pragma: no cover - makes boom a generator function
+
+ mocked = mocker.patch("krux.pages.to_qr_codes", side_effect=boom)
+ ctx = create_ctx(mocker, [])
+ page = mock_page_cls(ctx)
+
+ with pytest.raises(ValueError):
+ page.display_qr_codes(TEST_QR_DATA, FORMAT_NONE)
+
+ # The error surfaced on the first generator; no silent restart attempt.
+ assert mocked.call_count == 1
+
+
def test_display_qr_code_light_theme(mocker, m5stickv, mock_page_cls):
from krux.input import BUTTON_ENTER
from krux.qr import FORMAT_NONE
@@ -335,7 +367,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO = "amigo"
M5 = "m5stickv"
DOCK = "dock"
- BIT = "bit"
CUBE = "cube"
YAHBOOM = "yahboom"
WONDER_MV = "wonder_mv"
@@ -347,7 +378,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…opqrstuvwxyz",
M5: "0123456…tuvwxyz",
DOCK: "0123456789abc…nopqrstuvwxyz",
- BIT: "0123456789abc…nopqrstuvwxyz",
CUBE: "0123456789abc…nopqrstuvwxyz",
YAHBOOM: "0123456789abc…nopqrstuvwxyz",
WONDER_MV: "0123456789abc…nopqrstuvwxyz",
@@ -358,7 +388,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…nopqrstuvwxy",
M5: "0123456…stuvwxy",
DOCK: "0123456789abc…mnopqrstuvwxy",
- BIT: "0123456789abc…mnopqrstuvwxy",
CUBE: "0123456789abc…mnopqrstuvwxy",
YAHBOOM: "0123456789abc…mnopqrstuvwxy",
WONDER_MV: "0123456789abc…mnopqrstuvwxy",
@@ -369,7 +398,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…mnopqrstuvwx",
M5: "0123456…rstuvwx",
DOCK: "0123456789abc…lmnopqrstuvwx",
- BIT: "0123456789abc…lmnopqrstuvwx",
CUBE: "0123456789abc…lmnopqrstuvwx",
YAHBOOM: "0123456789abc…lmnopqrstuvwx",
WONDER_MV: "0123456789abc…lmnopqrstuvwx",
@@ -380,7 +408,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…lmnopqrstuvw",
M5: "0123456…qrstuvw",
DOCK: "0123456789abc…klmnopqrstuvw",
- BIT: "0123456789abc…klmnopqrstuvw",
CUBE: "0123456789abc…klmnopqrstuvw",
YAHBOOM: "0123456789abc…klmnopqrstuvw",
WONDER_MV: "0123456789abc…klmnopqrstuvw",
@@ -391,7 +418,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…ghijklmnopqr",
M5: "0123456…lmnopqr",
DOCK: "0123456789abc…fghijklmnopqr",
- BIT: "0123456789abc…fghijklmnopqr",
CUBE: "0123456789abc…fghijklmnopqr",
YAHBOOM: "0123456789abc…fghijklmnopqr",
WONDER_MV: "0123456789abc…fghijklmnopqr",
@@ -402,7 +428,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…fghijklmnopq",
M5: "0123456…klmnopq",
DOCK: "0123456789abcdefghijklmnopq",
- BIT: "0123456789abcdefghijklmnopq",
CUBE: "0123456789abcdefghijklmnopq",
YAHBOOM: "0123456789abcdefghijklmnopq",
WONDER_MV: "0123456789abcdefghijklmnopq",
@@ -413,7 +438,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789ab…efghijklmnop",
M5: "0123456…jklmnop",
DOCK: "0123456789abcdefghijklmnop",
- BIT: "0123456789abcdefghijklmnop",
CUBE: "0123456789abcdefghijklmnop",
YAHBOOM: "0123456789abcdefghijklmnop",
WONDER_MV: "0123456789abcdefghijklmnop",
@@ -424,7 +448,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdefghijklmno",
M5: "0123456…ijklmno",
DOCK: "0123456789abcdefghijklmno",
- BIT: "0123456789abcdefghijklmno",
CUBE: "0123456789abcdefghijklmno",
YAHBOOM: "0123456789abcdefghijklmno",
WONDER_MV: "0123456789abcdefghijklmno",
@@ -435,7 +458,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdefghijklmn",
M5: "0123456…hijklmn",
DOCK: "0123456789abcdefghijklmn",
- BIT: "0123456789abcdefghijklmn",
CUBE: "0123456789abcdefghijklmn",
YAHBOOM: "0123456789abcdefghijklmn",
WONDER_MV: "0123456789abcdefghijklmn",
@@ -446,7 +468,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdefghijklm",
M5: "0123456…ghijklm",
DOCK: "0123456789abcdefghijklm",
- BIT: "0123456789abcdefghijklm",
CUBE: "0123456789abcdefghijklm",
YAHBOOM: "0123456789abcdefghijklm",
WONDER_MV: "0123456789abcdefghijklm",
@@ -457,7 +478,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdefghij",
M5: "0123456…defghij",
DOCK: "0123456789abcdefghij",
- BIT: "0123456789abcdefghij",
CUBE: "0123456789abcdefghij",
YAHBOOM: "0123456789abcdefghij",
WONDER_MV: "0123456789abcdefghij",
@@ -468,7 +488,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdefg",
M5: "0123456…abcdefg",
DOCK: "0123456789abcdefg",
- BIT: "0123456789abcdefg",
CUBE: "0123456789abcdefg",
YAHBOOM: "0123456789abcdefg",
WONDER_MV: "0123456789abcdefg",
@@ -479,7 +498,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcdef",
M5: "0123456789abcdef",
DOCK: "0123456789abcdef",
- BIT: "0123456789abcdef",
CUBE: "0123456789abcdef",
YAHBOOM: "0123456789abcdef",
WONDER_MV: "0123456789abcdef",
@@ -490,7 +508,6 @@ def test_fit_to_line_text(mocker, multiple_devices, mock_page_cls):
AMIGO: "0123456789abcde",
M5: "0123456789abcde",
DOCK: "0123456789abcde",
- BIT: "0123456789abcde",
CUBE: "0123456789abcde",
YAHBOOM: "0123456789abcde",
WONDER_MV: "0123456789abcde",
@@ -639,3 +656,24 @@ def test_fit_to_line_not_crop_middle(mocker, multiple_devices, mock_page_cls):
formatted_text = page.fit_to_line(case[TXT], case[PREFIX], crop_middle=False)
assert len(formatted_text) <= max_chars_in_line
assert formatted_text == case[device_type]
+
+
+def test_has_sd_card_handles_errors_and_propagates_signals(
+ mocker, m5stickv, mock_page_cls
+):
+ """has_sd_card returns False on genuine SD errors, but must NOT swallow
+ BaseException-level signals like KeyboardInterrupt.
+
+ Regression for narrowing the bare except to `except Exception`.
+ """
+ ctx = create_ctx(mocker, [])
+ page = mock_page_cls(ctx)
+
+ # Genuine SD failure (OSError) -> reported as "no SD card".
+ mocker.patch("krux.pages.SDHandler", side_effect=OSError("no card"))
+ assert page.has_sd_card() is False
+
+ # Shutdown signal during the check -> propagates, not swallowed into False.
+ mocker.patch("krux.pages.SDHandler", side_effect=KeyboardInterrupt)
+ with pytest.raises(KeyboardInterrupt):
+ page.has_sd_card()
### tests/pages/test_qr_capture.py
@@ -15,8 +15,7 @@
def test_capture_qr_code(mocker, multiple_devices, tdata):
from krux.pages.qr_capture import QRCodeCapture
from krux.qr import FORMAT_PMOFN, FORMAT_UR
- from ur.ur import UR
- from urtypes.crypto.psbt import PSBT
+ from uUR import UR, Types
from krux.wdt import wdt
cases = [
@@ -54,7 +53,7 @@ def test_capture_qr_code(mocker, multiple_devices, tdata):
assert qr_code == case[1]
assert qr_format == FORMAT_PMOFN
elif isinstance(qr_code, UR):
- qr_data = PSBT.from_cbor(qr_code.cbor).data
+ qr_data = Types.psbt_from_cbor(qr_code.cbor)
assert qr_data == case[1]
assert qr_format == FORMAT_UR
@@ -263,10 +262,10 @@ def test_capture_qr_code_loop_duplicated_frames(mocker, m5stickv, tdata):
def test_qr_str_to_bytes(mocker, m5stickv):
from krux.pages.qr_capture import qr_str_to_bytes
- from ur.ur import UR
+ from uUR import UR
# return any non-string input as is
- for input_data in [b"already bytes", UR("a_ur_type", b"cbor bytes")]:
+ for input_data in [b"already bytes", UR("a-ur-type", b"cbor bytes")]:
assert qr_str_to_bytes(input_data) == input_data
# return ascii string as a str
### tests/pages/test_settings_page.py
@@ -633,10 +633,10 @@ def test_save_settings_on_sd(amigo, mocker, mocker_sd_card_ok):
ctx = create_ctx(mocker, BTN_SEQUENCE)
settings_page = SettingsPage(ctx)
- settings_page.flash_text = mocker.MagicMock()
+ settings_page.flash_success = mocker.MagicMock()
Settings().persist.location = SD_PATH
settings_page.settings()
- settings_page.flash_text.assert_has_calls(
+ settings_page.flash_success.assert_has_calls(
[
mocker.call("Settings stored on SD card.", duration=2500),
]
@@ -691,13 +691,13 @@ def test_leave_settings_without_changes(amigo, mocker):
for btn_sequence in BTN_SEQUENCES:
ctx = create_ctx(mocker, btn_sequence)
settings_page = SettingsPage(ctx)
- settings_page.flash_text = mocker.MagicMock()
+ settings_page.flash_success = mocker.MagicMock()
settings_page.settings()
persisted_to_flash_call = mocker.call(
"Settings stored internally on flash.", duration=2500
)
assert ctx.input.wait_for_button.call_count == len(btn_sequence)
- assert persisted_to_flash_call not in settings_page.flash_text.call_args_list
+ assert persisted_to_flash_call not in settings_page.flash_success.call_args_list
def test_leave_settings_with_changes(amigo, mocker, mocker_sd_card_ok):
@@ -718,12 +718,12 @@ def test_leave_settings_with_changes(amigo, mocker, mocker_sd_card_ok):
]
ctx = create_ctx(mocker, BTN_SEQUENCE)
settings_page = SettingsPage(ctx)
- settings_page.flash_text = mocker.MagicMock()
+ settings_page.flash_success = mocker.MagicMock()
# Leave settings without changes
settings_page.settings()
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
- settings_page.flash_text.assert_has_calls(
+ settings_page.flash_success.assert_has_calls(
[
mocker.call("Settings stored internally on flash.", duration=2500),
]
### tests/pages/test_stackbit.py
@@ -1,7 +1,8 @@
from .home_pages.test_home import tdata, create_ctx
-def test_export_mnemonic_stackbit(mocker, m5stickv, tdata):
+def test_export_mnemonic_stackbit_standard(mocker, m5stickv, tdata):
+ """Standard layout: 6 words per page, 4 pages for 24-word mnemonic"""
from krux.pages.home_pages.mnemonic_backup import MnemonicsView
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE
@@ -10,32 +11,34 @@ def test_export_mnemonic_stackbit(mocker, m5stickv, tdata):
Wallet(tdata.SINGLESIG_24_WORD_KEY),
None,
[
- BUTTON_PAGE,
- BUTTON_PAGE,
- BUTTON_ENTER, # Other
- BUTTON_PAGE,
- BUTTON_PAGE,
- BUTTON_ENTER, # Open Stackbit
- BUTTON_ENTER, # PG2
- BUTTON_ENTER, # PG3
- BUTTON_ENTER, # PG4
- BUTTON_ENTER, # Leave
- BUTTON_PAGE, # Go to "Back"
- BUTTON_PAGE,
- BUTTON_ENTER, # click on back to return Mnemonic Backup
- BUTTON_PAGE,
- BUTTON_ENTER, # click on back to return to home init screen
+ *([BUTTON_PAGE] * 2), # Go to "Other Formats"
+ BUTTON_ENTER, # Select "Other Formats"
+ *([BUTTON_PAGE] * 2), # Go to "Open Stackbit"
+ *(
+ [BUTTON_ENTER] * 6
+ ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu
+ BUTTON_ENTER, # Select "Back" from Stackbit submenu
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats
+ BUTTON_ENTER, # Select "Back" from Other Formats
+ BUTTON_PAGE, # Go to "Back" in mnemonic menu
+ BUTTON_ENTER, # Select "Back"
],
]
ctx = create_ctx(mocker, case[2], case[0], case[1])
mnemonics = MnemonicsView(ctx)
mocker.spy(mnemonics, "stackbit")
+ mocker.spy(mnemonics, "_stackbit_standard")
+ mocker.spy(mnemonics, "_stackbit_vertical_compact")
mnemonics.mnemonic()
mnemonics.stackbit.assert_called_once()
+ mnemonics._stackbit_standard.assert_called_once()
+ mnemonics._stackbit_vertical_compact.assert_not_called()
assert ctx.input.wait_for_button.call_count == len(case[2])
-def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata):
+def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata):
+ """Standard layout on Amigo: 6 words per page, 4 pages for 24-word mnemonic"""
from krux.pages.home_pages.mnemonic_backup import MnemonicsView
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE
@@ -44,28 +47,108 @@ def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata):
Wallet(tdata.SINGLESIG_24_WORD_KEY),
None,
[
- BUTTON_PAGE,
- BUTTON_PAGE,
- BUTTON_ENTER, # Other
- BUTTON_PAGE,
- BUTTON_PAGE,
- BUTTON_ENTER, # Open Stackbit
- BUTTON_ENTER, # PG2
- BUTTON_ENTER, # PG3
- BUTTON_ENTER, # PG4
- BUTTON_ENTER, # Leave
- BUTTON_PAGE, # Go to "Back"
- BUTTON_PAGE,
- BUTTON_ENTER, # click on back to return Mnemonic Backup
- BUTTON_PAGE,
- BUTTON_ENTER, # click on back to return to home init screen
+ *([BUTTON_PAGE] * 2), # Go to "Other Formats"
+ BUTTON_ENTER, # Select "Other Formats"
+ *([BUTTON_PAGE] * 2), # Go to "Open Stackbit"
+ *(
+ [BUTTON_ENTER] * 6
+ ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu
+ BUTTON_ENTER, # Select "Back" from Stackbit submenu
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats
+ BUTTON_ENTER, # Select "Back" from Other Formats
+ BUTTON_PAGE, # Go to "Back" in mnemonic menu
+ BUTTON_ENTER, # Select "Back"
],
]
ctx = create_ctx(mocker, case[2], case[0], case[1])
mnemonics = MnemonicsView(ctx)
mocker.spy(mnemonics, "stackbit")
+ mocker.spy(mnemonics, "_stackbit_standard")
mnemonics.mnemonic()
mnemonics.stackbit.assert_called_once()
+ mnemonics._stackbit_standard.assert_called_once()
+ assert ctx.input.wait_for_button.call_count == len(case[2])
+
+
+def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata):
+ """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic.
+
+ Amigo uses FONT_WIDTH=12, so 3 words/group would overflow the word name text.
+ The layout auto-selects 2 words/group → 4 words/page → 6 pages.
+ """
+ from krux.pages.home_pages.mnemonic_backup import MnemonicsView
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ case = [
+ Wallet(tdata.SINGLESIG_24_WORD_KEY),
+ None,
+ [
+ *([BUTTON_PAGE] * 2), # Go to "Other Formats"
+ BUTTON_ENTER, # Select "Other Formats"
+ *([BUTTON_PAGE] * 2), # Go to "Open Stackbit"
+ BUTTON_ENTER, # Select "Open Stackbit"
+ BUTTON_PAGE, # Go to "Vertical"
+ BUTTON_ENTER, # Select "Vertical"
+ *([BUTTON_ENTER] * 6), # Advance 6 pages
+ BUTTON_PAGE, # Go to "Back" in Stackbit submenu
+ BUTTON_ENTER, # Select "Back" from Stackbit submenu
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats
+ BUTTON_ENTER, # Select "Back" from Other Formats
+ BUTTON_PAGE, # Go to "Back" in mnemonic menu
+ BUTTON_ENTER, # Select "Back"
+ ],
+ ]
+ ctx = create_ctx(mocker, case[2], case[0], case[1])
+ mnemonics = MnemonicsView(ctx)
+ mocker.spy(mnemonics, "stackbit")
+ mocker.spy(mnemonics, "_stackbit_vertical")
+ mocker.spy(mnemonics, "_stackbit_vertical_default")
+ mnemonics.mnemonic()
+ mnemonics.stackbit.assert_called_once()
+ mnemonics._stackbit_vertical.assert_called_once()
+ mnemonics._stackbit_vertical_default.assert_called_once()
+ assert ctx.input.wait_for_button.call_count == len(case[2])
+
+
+def test_export_mnemonic_stackbit_vertical_compact(mocker, m5stickv, tdata):
+ """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic.
+
+ 2 words side-by-side × 3 rows, no word names or BIP39 codes.
+ """
+ from krux.pages.home_pages.mnemonic_backup import MnemonicsView
+ from krux.wallet import Wallet
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ case = [
+ Wallet(tdata.SINGLESIG_24_WORD_KEY),
+ None,
+ [
+ *([BUTTON_PAGE] * 2), # Go to "Other Formats"
+ BUTTON_ENTER, # Select "Other Formats"
+ *([BUTTON_PAGE] * 2), # Go to "Open Stackbit"
+ BUTTON_ENTER, # Select "Open Stackbit"
+ BUTTON_PAGE, # Go to "Vertical"
+ BUTTON_ENTER, # Select "Vertical"
+ *([BUTTON_ENTER] * 4), # Advance 4 pages
+ BUTTON_PAGE, # Go to "Back" in Stackbit submenu
+ BUTTON_ENTER, # Select "Back" from Stackbit submenu
+ *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats
+ BUTTON_ENTER, # Select "Back" from Other Formats
+ BUTTON_PAGE, # Go to "Back" in mnemonic menu
+ BUTTON_ENTER, # Select "Back"
+ ],
+ ]
+ ctx = create_ctx(mocker, case[2], case[0], case[1])
+ mnemonics = MnemonicsView(ctx)
+ mocker.spy(mnemonics, "stackbit")
+ mocker.spy(mnemonics, "_stackbit_vertical")
+ mocker.spy(mnemonics, "_stackbit_vertical_compact")
+ mnemonics.mnemonic()
+ mnemonics.stackbit.assert_called_once()
+ mnemonics._stackbit_vertical.assert_called_once()
+ mnemonics._stackbit_vertical_compact.assert_called_once()
assert ctx.input.wait_for_button.call_count == len(case[2])
### tests/shared_mocks.py
@@ -665,29 +665,6 @@ def board_wonder_mv():
)
-def board_bit():
- return mock.MagicMock(
- config={
- "type": "bit",
- "lcd": {"height": 240, "width": 320, "invert": 0, "lcd_type": 0},
- "sdcard": {"sclk": 27, "mosi": 28, "miso": 26, "cs": 29},
- "board_info": {
- "BOOT_KEY": 16,
- "LED_R": 13,
- "LED_G": 12,
- "LED_B": 14,
- "MIC0_WS": 19,
- "MIC0_DATA": 20,
- "MIC0_BCK": 18,
- },
- "krux": {
- "pins": {"BUTTON_A": 22, "BUTTON_B": 21, "BUTTON_C": 16},
- "display": {"touch": False, "font": [8, 16], "font_wide": [16, 16]},
- },
- }
- )
-
-
def board_wonder_k():
return mock.MagicMock(
config={
@@ -946,30 +923,6 @@ def mock_context(mocker):
),
)
- elif board.config["type"] == "bit":
- return mocker.MagicMock(
- input=mocker.MagicMock(
- touch=None,
- enter_event=mocker.MagicMock(return_value=False),
- page_event=mocker.MagicMock(return_value=False),
- page_prev_event=mocker.MagicMock(return_value=False),
- touch_event=mocker.MagicMock(return_value=False),
- ),
- display=mocker.MagicMock(
- font_width=8,
- font_height=16,
- total_lines=20, # 320 / 16
- width=mocker.MagicMock(return_value=DOCK_WIDTH),
- height=mocker.MagicMock(return_value=DOCK_HEIGHT),
- usable_width=mocker.MagicMock(return_value=DOCK_USABLE_WIDTH),
- usable_pixels_in_line=mocker.MagicMock(return_value=DOCK_USABLE_WIDTH),
- ascii_chars_per_line=mocker.MagicMock(return_value=DOCK_IN_LINE),
- to_lines=mocker.MagicMock(return_value=[""]),
- max_menu_lines=mocker.MagicMock(return_value=9),
- draw_hcentered_text=mocker.MagicMock(return_value=1),
- ),
- )
-
elif board.config["type"] == "wonder_k":
return mocker.MagicMock(
input=mocker.MagicMock(
### tests/test_camera.py
@@ -17,7 +17,6 @@ def test_initialize_sensors(mocker, multiple_devices):
from krux.camera import (
Camera,
OV7740_ID,
- OV5642_ID,
OV2640_ID,
GC0328_ID,
GC2145_ID,
@@ -26,7 +25,6 @@ def test_initialize_sensors(mocker, multiple_devices):
SENSORS_LIST = [
(OV7740_ID, "config_ov_7740"),
(OV2640_ID, "config_ov_2640"),
- (OV5642_ID, None),
(GC0328_ID, None),
(GC2145_ID, "config_gc_2145"),
]
@@ -50,7 +48,7 @@ def test_initialize_sensors(mocker, multiple_devices):
krux.camera.sensor.set_vflip.reset_mock()
- if board.config["type"] in ("cube", "wonder_k") or c.cam_id == OV5642_ID:
+ if board.config["type"] in ("cube", "wonder_k"):
krux.camera.sensor.set_hmirror.assert_called_with(1)
else:
krux.camera.sensor.set_hmirror.assert_not_called()
@@ -68,16 +66,10 @@ def test_initialize_sensors(mocker, multiple_devices):
krux.camera.sensor.set_pixformat.reset_mock()
krux.camera.sensor.set_framesize.assert_called()
- if board.config["type"] != "bit":
- assert (
- krux.camera.sensor.set_framesize.call_args.args[0]._extract_mock_name()
- == "mock.QVGA"
- )
- else:
- assert (
- krux.camera.sensor.set_framesize.call_args.args[0]._extract_mock_name()
- == "mock.CIF"
- )
+ assert (
+ krux.camera.sensor.set_framesize.call_args.args[0]._extract_mock_name()
+ == "mock.QVGA"
+ )
krux.camera.sensor.set_framesize.reset_mock()
@@ -139,7 +131,6 @@ def test_toggle_mode(mocker, m5stickv):
from krux.camera import (
Camera,
OV7740_ID,
- OV5642_ID,
OV2640_ID,
GC0328_ID,
GC2145_ID,
@@ -148,7 +139,7 @@ def test_toggle_mode(mocker, m5stickv):
ZOOMED_MODE,
)
- SENSORS_LIST = [OV7740_ID, OV5642_ID, OV2640_ID, GC0328_ID, GC2145_ID]
+ SENSORS_LIST = [OV7740_ID, OV2640_ID, GC0328_ID, GC2145_ID]
for sensor_id in SENSORS_LIST:
mocker.patch("krux.camera.sensor.get_id", lambda: sensor_id)
@@ -167,29 +158,16 @@ def test_toggle_mode(mocker, m5stickv):
def test_snapshot(mocker, multiple_devices):
import krux
- import board
from krux.camera import Camera
- if board.config["type"] == "bit":
- image = mocker.MagicMock(
- lens_corr=mocker.MagicMock(), rotation_corr=mocker.MagicMock()
- )
- mock_snapshot = mocker.MagicMock(return_value=image)
- else:
- mock_snapshot = mocker.MagicMock()
-
- mocker.patch("krux.camera.sensor.snapshot", side_effect=mock_snapshot)
+ mocker.patch("krux.camera.sensor.snapshot", side_effect=mocker.MagicMock())
c = Camera()
c.initialize_sensor()
c.snapshot()
krux.camera.sensor.snapshot.assert_called()
- if board.config["type"] == "bit":
- image.lens_corr.assert_called_with(strength=1.1)
- image.rotation_corr.assert_called_with(z_rotation=180)
-
def test_stop_sensor(mocker, multiple_devices):
import krux
### tests/test_display.py
@@ -843,7 +843,8 @@ def test_draw_hcentered_text_on_inverted_display(mocker, amigo):
def test_draw_infobox(mocker, amigo):
from krux.display import Display, DEFAULT_PADDING, FONT_HEIGHT, FONT_WIDTH
- from krux.themes import WHITE, BLACK, DARKGREY
+ from krux.krux_settings import Settings
+ from krux.themes import WHITE, BLACK, THEMES, theme
mocker.patch("krux.display.lcd", new=mocker.MagicMock())
mocker.patch("krux.display.lcd.string_width_px", side_effect=string_width_px)
@@ -853,23 +854,39 @@ def test_draw_infobox(mocker, amigo):
mocker.spy(d, "fill_rectangle")
mocker.spy(d, "draw_string")
- d.draw_hcentered_text("Hello world", DEFAULT_PADDING, WHITE, BLACK, info_box=True)
-
- d.fill_rectangle.assert_called_with(
- DEFAULT_PADDING - 3,
- DEFAULT_PADDING - 1,
- d.width() - 2 * DEFAULT_PADDING + 6,
- FONT_HEIGHT + 2,
- DARKGREY,
- FONT_WIDTH,
- )
- d.draw_string.assert_called_with(
- (d.width() - len("Hello world") * FONT_WIDTH) // 2,
- DEFAULT_PADDING,
- "Hello world",
- WHITE,
- DARKGREY,
+ cases = (
+ "Dark",
+ "Light",
+ "Orange",
+ "CypherPink",
+ "CypherPunk",
)
+ for theme_name in cases:
+ Settings().appearance.theme = theme_name
+ theme.update()
+ info_bg_color = THEMES[theme_name]["disabled"]
+
+ d.fill_rectangle.reset_mock()
+ d.draw_string.reset_mock()
+ d.draw_hcentered_text(
+ "Hello world", DEFAULT_PADDING, WHITE, BLACK, info_box=True
+ )
+
+ d.fill_rectangle.assert_called_with(
+ DEFAULT_PADDING - 3,
+ DEFAULT_PADDING - 1,
+ d.width() - 2 * DEFAULT_PADDING + 6,
+ FONT_HEIGHT + 2,
+ info_bg_color,
+ FONT_WIDTH,
+ )
+ d.draw_string.assert_called_with(
+ (d.width() - len("Hello world") * FONT_WIDTH) // 2,
+ DEFAULT_PADDING,
+ "Hello world",
+ WHITE,
+ info_bg_color,
+ )
def test_draw_centered_text(mocker, m5stickv):
### tests/test_encryption.py
@@ -612,3 +612,281 @@ def test_customize_pbkdf2_iterations_create_and_decode(m5stickv):
plaintext = decryptor.decrypt(cpl, version)
words = bip39.mnemonic_from_bytes(plaintext)
assert words == TEST_WORDS
+
+
+# ---------------------------------------------------------------------------
+# Mnemonic-storage file-load error handling.
+#
+# The four read/load fallbacks below catch only the file/JSON errors they
+# expect (OSError, ValueError), matching the OSError convention already used in
+# sd_card.py. The behaviour for a missing/unreadable file or malformed JSON is
+# unchanged ("storage starts empty" / "first store still writes"); the change
+# is that an *unexpected* error is no longer silently swallowed -- it now
+# propagates, so real bugs stop hiding. Each "propagates_unexpected_error" test
+# is the one that fails on the old bare-except code.
+# ---------------------------------------------------------------------------
+
+
+# --- __init__ SD load (self.stored_sd) ---
+
+
+def test_init_sd_load_propagates_unexpected_error(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=RuntimeError("unexpected"))
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ with pytest.raises(RuntimeError):
+ MnemonicStorage()
+
+
+def test_init_sd_load_oserror_starts_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError("no card"))
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ assert storage.stored_sd == {}
+
+
+def test_init_sd_load_malformed_json_starts_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ sd = mocker.MagicMock()
+ sd.read.return_value = "not valid json {{{"
+ sdhandler = mocker.MagicMock()
+ sdhandler.return_value.__enter__.return_value = sd
+ mocker.patch("krux.encryption.SDHandler", new=sdhandler)
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ assert storage.stored_sd == {}
+
+
+# --- __init__ flash load (self.stored) ---
+
+
+def test_init_flash_load_propagates_unexpected_error(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ # SD load fails with an expected error so only the flash load can raise.
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ mocker.patch("krux.encryption.open", side_effect=RuntimeError("unexpected"))
+ with pytest.raises(RuntimeError):
+ MnemonicStorage()
+
+
+def test_init_flash_load_oserror_starts_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ mocker.patch("krux.encryption.open", side_effect=OSError("missing"))
+ storage = MnemonicStorage()
+ assert storage.stored == {}
+
+
+def test_init_flash_load_malformed_json_starts_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ with patch(
+ "krux.encryption.open", new=mocker.mock_open(read_data="not valid json {{{")
+ ):
+ storage = MnemonicStorage()
+ assert storage.stored == {}
+
+
+# --- store_encrypted_kef SD read-before-write ---
+
+
+def test_store_sd_read_propagates_unexpected_error(
+ m5stickv, mocker, mock_file_operations
+):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage
+
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ mocker.patch("krux.sd_card.SDHandler.read", side_effect=RuntimeError("unexpected"))
+ with patch("krux.sd_card.open", new=mocker.mock_open(read_data="{}")):
+ with pytest.raises(RuntimeError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=True)
+
+
+def test_store_sd_read_oserror_still_writes(m5stickv, mocker, mock_file_operations):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage
+
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ mocker.patch("krux.sd_card.SDHandler.read", side_effect=OSError("missing"))
+ with patch("krux.sd_card.open", new=mocker.mock_open(read_data="{}")) as m:
+ success = storage.store_encrypted_kef(
+ "KEFecbID", KEF_ENVELOPE_ECB, sd_card=True
+ )
+ assert success is True
+ m().write.assert_called_once_with(KEF_ECBENTROPY_ONLY_JSON)
+
+
+def test_store_sd_read_malformed_json_raises_and_preserves(
+ m5stickv, mocker, mock_file_operations
+):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage, StorageCorruptedError
+
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ mocker.patch("krux.sd_card.SDHandler.read", return_value="not valid json {{{")
+ with patch("krux.sd_card.open", new=mocker.mock_open(read_data="{}")) as m:
+ with pytest.raises(StorageCorruptedError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=True)
+ # existing (corrupt-but-recoverable) file must not be overwritten
+ m().write.assert_not_called()
+
+
+def test_store_sd_read_non_dict_json_raises_and_preserves(
+ m5stickv, mocker, mock_file_operations
+):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage, StorageCorruptedError
+
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ mocker.patch("krux.sd_card.SDHandler.read", return_value="[1, 2, 3]")
+ with patch("krux.sd_card.open", new=mocker.mock_open(read_data="{}")) as m:
+ with pytest.raises(StorageCorruptedError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=True)
+ m().write.assert_not_called()
+
+
+# --- store_encrypted_kef flash read-before-write ---
+
+
+def test_store_flash_read_propagates_unexpected_error(m5stickv, mocker):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage
+
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ write_handle = mocker.mock_open()
+ mocker.patch(
+ "krux.encryption.open",
+ side_effect=[RuntimeError("unexpected"), write_handle.return_value],
+ )
+ with pytest.raises(RuntimeError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=False)
+
+
+def test_store_flash_read_oserror_still_writes(m5stickv, mocker):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage
+
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ write_handle = mocker.mock_open()
+ mocker.patch(
+ "krux.encryption.open",
+ side_effect=[OSError("missing"), write_handle.return_value],
+ )
+ success = storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=False)
+ assert success is True
+ write_handle().write.assert_called_once_with(KEF_ECBENTROPY_ONLY_JSON)
+
+
+def test_store_flash_read_malformed_json_raises_and_preserves(m5stickv, mocker):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage, StorageCorruptedError
+
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ read_handle = mocker.mock_open(read_data="not valid json {{{")
+ write_handle = mocker.mock_open()
+ open_mock = mocker.patch(
+ "krux.encryption.open",
+ side_effect=[read_handle.return_value, write_handle.return_value],
+ )
+ with pytest.raises(StorageCorruptedError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=False)
+ # file opened for read only; never opened for write (no truncation)
+ assert open_mock.call_count == 1
+ write_handle().write.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# decrypt() must not crash on a missing id or non-dict storage.
+#
+# storage.get(id) returns None for an unknown id. decrypt() should return None
+# instead of raising AttributeError when there is no stored entry.
+# ---------------------------------------------------------------------------
+
+
+def test_decrypt_unknown_id_returns_none(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ # both stores are empty; an unknown id must return None, not crash
+ assert storage.decrypt("any-key", "no-such-id", sd_card=False) is None
+ assert storage.decrypt("any-key", "no-such-id", sd_card=True) is None
+
+
+def test_decrypt_non_dict_storage_returns_none(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ # valid JSON that is not an object -> loaded as-is; decrypt must not crash
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="[1, 2, 3]")):
+ storage = MnemonicStorage()
+ assert storage.stored == [1, 2, 3]
+ assert storage.decrypt("any-key", "any-id", sd_card=False) is None
+
+
+# list_mnemonics() returns [] for non-dict storage instead of iterating it
+# (a list would yield junk ids; a non-iterable like null would raise).
+
+
+def test_list_mnemonics_non_dict_storage_returns_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="[1, 2, 3]")):
+ storage = MnemonicStorage()
+ assert storage.stored == [1, 2, 3]
+ assert storage.list_mnemonics(sd_card=False) == []
+
+
+def test_list_mnemonics_non_iterable_storage_returns_empty(m5stickv, mocker):
+ from krux.encryption import MnemonicStorage
+
+ mocker.patch("krux.encryption.SDHandler", side_effect=OSError)
+ # JSON "null" loads to None, which is not iterable -> must not raise
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="null")):
+ storage = MnemonicStorage()
+ assert storage.stored is None
+ assert storage.list_mnemonics(sd_card=False) == []
+
+
+# store_encrypted_kef() raises before opening "w" on a non-dict flash file,
+# so the existing (recoverable) data is never truncated.
+
+
+def test_store_flash_read_non_dict_json_raises_and_preserves(m5stickv, mocker):
+ from krux.krux_settings import Settings
+ from krux.encryption import MnemonicStorage, StorageCorruptedError
+
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data="{}")):
+ storage = MnemonicStorage()
+ Settings().encryption.version = "AES-ECB"
+ read_handle = mocker.mock_open(read_data="[1, 2, 3]")
+ write_handle = mocker.mock_open()
+ open_mock = mocker.patch(
+ "krux.encryption.open",
+ side_effect=[read_handle.return_value, write_handle.return_value],
+ )
+ with pytest.raises(StorageCorruptedError):
+ storage.store_encrypted_kef("KEFecbID", KEF_ENVELOPE_ECB, sd_card=False)
+ # file opened for read only; never opened for write (no truncation)
+ assert open_mock.call_count == 1
+ write_handle().write.assert_not_called()
### tests/test_format.py
@@ -22,3 +22,18 @@ def test_format_btc(m5stickv):
]
for case in cases:
assert format_btc(case[0]) == case[1]
+
+
+def test_format_btc_negative(m5stickv):
+ """A negative amount is the positive one with a minus sign.
+
+ Floor division and modulo round towards minus infinity, so splitting the
+ amount before taking the sign out used to render -1000 as -1.99 999 000.
+ """
+ from krux.format import format_btc, THOUSANDS_SEPARATOR
+
+ for amount in (1, 999, 1000, 100000000, 199999000, 2098989898989898):
+ assert format_btc(-amount) == "-" + format_btc(amount)
+
+ separator = THOUSANDS_SEPARATOR
+ assert format_btc(-1000) == "-0.00" + separator + "001" + separator + "000"
### tests/test_key.py
@@ -728,3 +728,18 @@ def test_classmethod_extract_fingerprint(mocker, m5stickv, tdata):
fingerprint = Key.extract_fingerprint("this is not a mnemonic", pretty=False)
assert fingerprint == ""
+
+
+def test_extract_fingerprint_propagates_base_exceptions(mocker, m5stickv):
+ """extract_fingerprint catches genuine errors (returns "") but must NOT
+ swallow BaseException-level signals like KeyboardInterrupt.
+
+ Regression for narrowing the bare except to ``except Exception``.
+ """
+ import pytest
+ from krux.key import Key
+
+ mocker.patch.object(Key, "extract_root", side_effect=KeyboardInterrupt)
+
+ with pytest.raises(KeyboardInterrupt):
+ Key.extract_fingerprint("any mnemonic", pretty=False)
### tests/test_power.py
@@ -15,7 +15,7 @@ def test_pmu(mocker, multiple_devices):
manager = PowerManager()
- if board.config["type"] in ("dock", "yahboom", "wonder_mv", "bit", "wonder_k"):
+ if board.config["type"] in ("dock", "yahboom", "wonder_mv", "wonder_k"):
assert manager.pmu is None
assert manager.has_battery() is False
else:
### tests/test_psbt.py
[binary or diff unavailable]
### tests/test_psbt_input_amounts.py
@@ -0,0 +1,451 @@
+"""Regression coverage for PSBT input amount verification.
+
+The amount shown on the review screen must be the same amount the signer
+commits to, and any previous transaction attached to an input must really
+hash to the outpoint being spent.
+"""
+
+import pytest
+from .shared_mocks import MockFile, mock_open
+
+TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+
+
+def _kv(key, value):
+ """Serializes a PSBT key/value pair"""
+ from embit import compact
+
+ return compact.to_bytes(len(key)) + key + compact.to_bytes(len(value)) + value
+
+
+def _root():
+ from embit import bip32, bip39
+ from embit.networks import NETWORKS
+
+ seed = bip39.mnemonic_to_seed(TEST_MNEMONIC)
+ return bip32.HDKey.from_seed(seed, version=NETWORKS["test"]["xprv"])
+
+
+def _key_at(root, path):
+ """Returns the public key and its derivation path record"""
+ from embit.bip32 import parse_path
+ from embit.psbt import DerivationPath
+
+ derivation = parse_path(path)
+ pubkey = root.derive(derivation).to_public().key
+ return pubkey, DerivationPath(root.my_fingerprint, derivation)
+
+
+def _wallet():
+ from embit.networks import NETWORKS
+ from krux.key import Key, TYPE_SINGLESIG
+ from krux.wallet import Wallet
+
+ return Wallet(Key(TEST_MNEMONIC, TYPE_SINGLESIG, NETWORKS["test"]))
+
+
+def test_rejects_fabricated_non_witness_utxo(m5stickv):
+ """A previous tx that does not hash to the outpoint must be refused.
+
+ Legacy sighashes do not commit to the input amount, so without this check
+ a fabricated previous tx yields a signature that is valid against the real
+ UTXO while the device displays an understated fee.
+ """
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ pubkey, derivation = _key_at(root, "m/44h/1h/0h/0/0")
+ script_pubkey = script.p2pkh(pubkey)
+
+ real_prev = Transaction(
+ vin=[TransactionInput(b"\x22" * 32, 0)],
+ vout=[TransactionOutput(100000000, script_pubkey)],
+ )
+ # Same scriptPubKey, understated value, so a different txid
+ fake_prev = Transaction(
+ vin=[TransactionInput(b"\x33" * 32, 0)],
+ vout=[TransactionOutput(20200, script_pubkey)],
+ )
+ assert fake_prev.txid() != real_prev.txid()
+
+ tx = Transaction(
+ vin=[TransactionInput(real_prev.txid(), 0)],
+ vout=[
+ TransactionOutput(20000, script.p2pkh(_key_at(root, "m/44h/1h/0h/0/7")[0]))
+ ],
+ )
+ psbt = PSBT(tx)
+ psbt.inputs[0].non_witness_utxo = fake_prev
+ psbt.inputs[0].bip32_derivations[pubkey] = derivation
+
+ with pytest.raises(ValueError, match="Previous txid"):
+ PSBTSigner(_wallet(), psbt.serialize(), FORMAT_NONE)
+
+
+def test_rejects_fabricated_non_witness_utxo_from_sdcard(mocker, m5stickv):
+ """The SD card fallback into compressed mode must not bypass the check"""
+ from embit import script
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ pubkey, derivation = _key_at(root, "m/44h/1h/0h/0/0")
+ script_pubkey = script.p2pkh(pubkey)
+
+ fake_prev = Transaction(
+ vin=[TransactionInput(b"\x33" * 32, 0)],
+ vout=[TransactionOutput(20200, script_pubkey)],
+ )
+ tx = Transaction(
+ vin=[TransactionInput(b"\x66" * 32, 0)],
+ vout=[TransactionOutput(20000, script_pubkey)],
+ )
+ partial_sig = _kv(b"\x02" + pubkey.sec(), b"\x30" * 71)
+ input_map = (
+ _kv(b"\x00", fake_prev.serialize())
+ + _kv(b"\x06" + pubkey.sec(), derivation.serialize())
+ + partial_sig
+ + partial_sig
+ + b"\x00"
+ )
+ raw = b"psbt\xff" + _kv(b"\x00", tx.serialize()) + b"\x00" + input_map + b"\x00"
+
+ mocker.patch("builtins.open", mock_open(MockFile(raw)))
+ with pytest.raises(ValueError, match="Previous txid"):
+ PSBTSigner(_wallet(), None, FORMAT_NONE, "dummy.psbt")
+
+
+def test_rejects_legacy_input_without_previous_tx(m5stickv):
+ """A legacy input carrying only a witness_utxo has an unverifiable amount"""
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ pubkey, derivation = _key_at(root, "m/44h/1h/0h/0/0")
+ script_pubkey = script.p2pkh(pubkey)
+
+ tx = Transaction(
+ vin=[TransactionInput(b"\x44" * 32, 0)],
+ vout=[TransactionOutput(20000, script_pubkey)],
+ )
+ psbt = PSBT(tx)
+ psbt.inputs[0].witness_utxo = TransactionOutput(20200, script_pubkey)
+ psbt.inputs[0].bip32_derivations[pubkey] = derivation
+
+ with pytest.raises(ValueError):
+ PSBTSigner(_wallet(), psbt.serialize(), FORMAT_NONE)
+
+
+def _segwit_psbt(root, input_value, output_value):
+ """Single input p2wpkh PSBT with the given declared amounts"""
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+
+ pubkey, derivation = _key_at(root, "m/84h/1h/0h/0/0")
+ tx = Transaction(
+ vin=[TransactionInput(b"\x99" * 32, 0)],
+ vout=[
+ TransactionOutput(
+ output_value, script.p2wpkh(_key_at(root, "m/84h/1h/0h/0/7")[0])
+ )
+ ],
+ )
+ psbt = PSBT(tx)
+ psbt.inputs[0].witness_utxo = TransactionOutput(input_value, script.p2wpkh(pubkey))
+ psbt.inputs[0].bip32_derivations[pubkey] = derivation
+ return psbt.serialize()
+
+
+def test_rejects_outputs_exceeding_inputs(m5stickv):
+ """A negative fee is impossible on chain and must not reach the review screen.
+
+ It would render as a small negative amount and fee_percent clamps to 0.1,
+ so the high fee warning would not fire either.
+ """
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ with pytest.raises(ValueError, match="outputs exceed inputs"):
+ PSBTSigner(_wallet(), _segwit_psbt(root, 99000, 100000), FORMAT_NONE)
+
+
+def test_accepts_zero_fee(m5stickv):
+ """A zero fee is unusual but valid, only a negative one is impossible"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ signer = PSBTSigner(_wallet(), _segwit_psbt(root, 100000, 100000), FORMAT_NONE)
+ assert isinstance(signer, PSBTSigner)
+
+
+def _compressed_psbt_with_contradicting_amounts(root, real_value, declared_value):
+ """Builds a PSBT that forces the compressed parse and lies in witness_utxo.
+
+ A duplicated PSBT_IN_PARTIAL_SIG key makes the uncompressed parse raise,
+ so PSBTSigner falls back to CompressMode.CLEAR_ALL. That mode streams the
+ previous transaction into _utxo, which is what the signer reads, while
+ witness_utxo carries the attacker's value.
+ """
+ from embit import script
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+
+ pubkey, derivation = _key_at(root, "m/84h/1h/0h/0/0")
+ script_pubkey = script.p2wpkh(pubkey)
+
+ prev_tx = Transaction(
+ vin=[TransactionInput(b"\x11" * 32, 0)],
+ vout=[TransactionOutput(real_value, script_pubkey)],
+ )
+ tx = Transaction(
+ vin=[TransactionInput(prev_tx.txid(), 0)],
+ vout=[
+ TransactionOutput(
+ 100000, script.p2wpkh(_key_at(root, "m/84h/1h/0h/0/7")[0])
+ )
+ ],
+ )
+ lying_utxo = TransactionOutput(declared_value, script_pubkey)
+ partial_sig = _kv(b"\x02" + pubkey.sec(), b"\x30" * 71)
+ input_map = (
+ _kv(b"\x00", prev_tx.serialize())
+ + _kv(b"\x01", lying_utxo.serialize())
+ + _kv(b"\x06" + pubkey.sec(), derivation.serialize())
+ + partial_sig
+ + partial_sig
+ + b"\x00"
+ )
+ raw = b"psbt\xff" + _kv(b"\x00", tx.serialize()) + b"\x00" + input_map + b"\x00"
+ return raw, tx, pubkey, script_pubkey
+
+
+def _two_input_psbt(root, taproot=False, with_prev_txs=False):
+ """Two input PSBT, optionally taproot, optionally carrying previous txs"""
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+
+ base = "m/86h/1h/0h" if taproot else "m/84h/1h/0h"
+ make = script.p2tr if taproot else script.p2wpkh
+
+ keys, prevs = [], []
+ for i in (0, 1):
+ pubkey, derivation = _key_at(root, "%s/0/%d" % (base, i))
+ keys.append((pubkey, derivation))
+ prevs.append(
+ Transaction(
+ vin=[TransactionInput(bytes([0xA0 + i]) * 32, 0)],
+ vout=[TransactionOutput(100000000, make(pubkey))],
+ )
+ )
+
+ tx = Transaction(
+ vin=[TransactionInput(prev.txid(), 0) for prev in prevs],
+ vout=[TransactionOutput(199990000, make(_key_at(root, "%s/0/7" % base)[0]))],
+ )
+ psbt = PSBT(tx)
+ for i, (pubkey, derivation) in enumerate(keys):
+ psbt.inputs[i].witness_utxo = prevs[i].vout[0]
+ if with_prev_txs:
+ psbt.inputs[i].non_witness_utxo = prevs[i]
+ if taproot:
+ psbt.inputs[i].taproot_bip32_derivations[pubkey] = ([], derivation)
+ psbt.inputs[i].taproot_internal_key = pubkey
+ else:
+ psbt.inputs[i].bip32_derivations[pubkey] = derivation
+ return psbt.serialize()
+
+
+def _taproot_wallet():
+ from embit.networks import NETWORKS
+ from krux.key import Key, TYPE_SINGLESIG, P2TR
+ from krux.wallet import Wallet
+
+ return Wallet(Key(TEST_MNEMONIC, TYPE_SINGLESIG, NETWORKS["test"], "", 0, P2TR))
+
+
+def test_warns_when_amounts_are_unverifiable(m5stickv):
+ """Two segwit v0 inputs with no previous transactions is the Path C setup"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ signer = PSBTSigner(_wallet(), _two_input_psbt(_root()), FORMAT_NONE)
+ assert signer.unverified_input_amounts() is True
+
+
+def test_no_warning_with_previous_transactions(m5stickv):
+ """Verified amounts cannot be understated, so there is nothing to warn about"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ raw = _two_input_psbt(_root(), with_prev_txs=True)
+ signer = PSBTSigner(_wallet(), raw, FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
+def test_no_warning_for_taproot(m5stickv):
+ """BIP341 hashes every input amount, so the two session trick cannot work"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ raw = _two_input_psbt(_root(), taproot=True)
+ signer = PSBTSigner(_taproot_wallet(), raw, FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
+def test_no_warning_for_single_input(m5stickv):
+ """A lie about the only input goes into its own sighash and breaks it"""
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ signer = PSBTSigner(_wallet(), _segwit_psbt(_root(), 100000, 90000), FORMAT_NONE)
+ assert signer.unverified_input_amounts() is False
+
+
+def test_displayed_amount_is_the_signed_amount(mocker, m5stickv):
+ """Display and sighash must read the same UTXO, even in compressed mode"""
+ from embit import ec, script
+ from embit.transaction import SIGHASH
+ from krux.format import format_btc
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ real_value = 1000000000
+ raw, tx, pubkey, script_pubkey = _compressed_psbt_with_contradicting_amounts(
+ root, real_value, 101000
+ )
+
+ mocker.patch("builtins.open", mock_open(MockFile(raw)))
+ signer = PSBTSigner(_wallet(), None, FORMAT_NONE, "dummy.psbt")
+
+ tx_input = signer.psbt.inputs[0]
+ # The lie is still in the PSBT, it just is not what gets displayed
+ assert tx_input.witness_utxo.value == 101000
+ assert tx_input.utxo.value == real_value
+
+ messages, fee_percent = signer.outputs()
+ assert format_btc(real_value) in messages[0]
+ # 9.999 BTC of a 0.001 BTC spend, the high fee warning must fire
+ assert fee_percent >= 10.0
+
+ signer.sign(trim=False)
+ signature = list(signer.psbt.inputs[0].partial_sigs.values())[0]
+ parsed = ec.Signature.parse(signature[:-1])
+ signed_over = tx.sighash_segwit(
+ 0,
+ script.p2pkh_from_p2wpkh(script_pubkey),
+ real_value,
+ sighash=SIGHASH.ALL,
+ )
+ assert pubkey.verify(parsed, signed_over)
+
+
+def test_compressed_parse_keeps_legacy_psbt_usable(mocker, m5stickv):
+ """Compressed mode stores the previous output in _utxo, not non_witness_utxo"""
+ from embit import script
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+ from krux.format import format_btc
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ pubkey, derivation = _key_at(root, "m/44h/1h/0h/0/0")
+ script_pubkey = script.p2pkh(pubkey)
+
+ prev_tx = Transaction(
+ vin=[TransactionInput(b"\x77" * 32, 0)],
+ vout=[TransactionOutput(100000000, script_pubkey)],
+ )
+ tx = Transaction(
+ vin=[TransactionInput(prev_tx.txid(), 0)],
+ vout=[TransactionOutput(20000, script_pubkey)],
+ )
+ partial_sig = _kv(b"\x02" + pubkey.sec(), b"\x30" * 71)
+ input_map = (
+ _kv(b"\x00", prev_tx.serialize())
+ + _kv(b"\x06" + pubkey.sec(), derivation.serialize())
+ + partial_sig
+ + partial_sig
+ + b"\x00"
+ )
+ raw = b"psbt\xff" + _kv(b"\x00", tx.serialize()) + b"\x00" + input_map + b"\x00"
+
+ mocker.patch("builtins.open", mock_open(MockFile(raw)))
+ signer = PSBTSigner(_wallet(), None, FORMAT_NONE, "dummy.psbt")
+
+ assert signer.psbt.inputs[0].non_witness_utxo is None
+ assert signer.psbt.inputs[0].utxo.value == 100000000
+ messages, _ = signer.outputs()
+ assert format_btc(100000000) in messages[0]
+
+
+@pytest.mark.xfail(
+ strict=True,
+ reason="Segwit inputs are not required to carry a previous transaction, so "
+ "their amounts stay unverified. Signing the same transaction twice, each "
+ "session declaring a different input truthfully, yields one valid signature "
+ "per input. Krux warns about this through unverified_input_amounts() but "
+ "still signs if the user proceeds. Rejecting instead would need previous "
+ "transactions on segwit inputs, which Sparrow deliberately omits for Krux "
+ "and the other airgapped signers in WalletModel.alwaysIncludeNonWitnessUtxo.",
+)
+def test_segwit_input_amounts_are_verified(m5stickv):
+ """Documents the residual exposure on multi input segwit transactions"""
+ from embit import script
+ from embit.psbt import PSBT
+ from embit.transaction import Transaction, TransactionInput, TransactionOutput
+ from krux.psbt import PSBTSigner
+ from krux.qr import FORMAT_NONE
+
+ root = _root()
+ pubkey_0, derivation_0 = _key_at(root, "m/84h/1h/0h/0/0")
+ pubkey_1, derivation_1 = _key_at(root, "m/84h/1h/0h/0/1")
+
+ tx = Transaction(
+ vin=[TransactionInput(b"\x44" * 32, 0), TransactionInput(b"\x55" * 32, 1)],
+ vout=[
+ TransactionOutput(
+ 99990000, script.p2wpkh(_key_at(root, "m/84h/1h/0h/0/7")[0])
+ )
+ ],
+ )
+
+ def build(value_0, value_1):
+ psbt = PSBT(tx)
+ psbt.inputs[0].witness_utxo = TransactionOutput(
+ value_0, script.p2wpkh(pubkey_0)
+ )
+ psbt.inputs[0].bip32_derivations[pubkey_0] = derivation_0
+ psbt.inputs[1].witness_utxo = TransactionOutput(
+ value_1, script.p2wpkh(pubkey_1)
+ )
+ psbt.inputs[1].bip32_derivations[pubkey_1] = derivation_1
+ return psbt.serialize()
+
+ def signatures(raw):
+ signer = PSBTSigner(_wallet(), raw, FORMAT_NONE)
+ signer.sign(trim=False)
+ return [list(inp.partial_sigs.values())[0] for inp in signer.psbt.inputs]
+
+ try:
+ truthful = signatures(build(100000000, 100000000))
+ session_a = signatures(build(100000000, 1000))
+ session_b = signatures(build(1000, 100000000))
+ except ValueError:
+ # Rejected at load, which is the outcome this test wants
+ return
+
+ # A device that verified segwit amounts would never produce these
+ assert session_a[0] != truthful[0]
+ assert session_b[1] != truthful[1]
### tests/test_qr.py
@@ -4,12 +4,11 @@
@pytest.fixture
def tdata(mocker):
from collections import namedtuple
- from ur.ur import UR
- from urtypes.crypto.psbt import PSBT
+ from uUR import UR, Types
TEST_DATA_BYTES = b'psbt\xff\x01\x00q\x02\x00\x00\x00\x01\xcf<X\xc3)\x82\xae P\x88\xd9\xbdI\xeb\x9b\x02\xac\xdfM=\xaev\xa5\x16\xc6\xb3\x06\xb1]\xe3\xa1N\x00\x00\x00\x00\x00\xfd\xff\xff\xff\x02|?]\x05\x00\x00\x00\x00\x16\x00\x14/4\xaa\x1c\xf0\nS\xb0U\xa2\x91\xa0:}E\xf0\xa6\x98\x8bR\x80\x96\x98\x00\x00\x00\x00\x00\x16\x00\x14\xe6j\xfe\xff\xc3\x83\x8eq\xf0\xa2{\x07\xe3\xb0\x0e\xdej\xe8\xe1`\x00\x00\x00\x00\x00\x01\x01\x1f\x00\xe1\xf5\x05\x00\x00\x00\x00\x16\x00\x14\xd0\xc4\xa3\xef\t\xe9\x97\xb6\xe9\x9e9~Q\x8f\xe3\xe4\x1a\x11\x8c\xa1"\x06\x02\xe7\xab%7\xb5\xd4\x9e\x97\x03\t\xaa\xe0n\x9eI\xf3l\xe1\xc9\xfe\xbb\xd4N\xc8\xe0\xd1\xcc\xa0\xb4\xf9\xc3\x19\x18s\xc5\xda\nT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00"\x02\x03]I\xec\xcdT\xd0\t\x9eCgbw\xc7\xa6\xd4b]a\x1d\xa8\x8a]\xf4\x9b\xf9Qzw\x91\xa7w\xa5\x18s\xc5\xda\nT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00'
TEST_DATA_B58 = "UUucvki6KWyS35DhetbWPw1DiaccbHKywScF96E8VUwEnN1gss947UasRfkNxtrkzCeHziHyMCuoiQ2mSYsbYXuV3YwYBZwFh1c6xtBAEK1aDgPwMgqf74xTzf3m4KH4iUU5nHTqroDpoRZR59meafTCUBChZ5NJ8MoUdKE6avyYdSm5kUb4npmFpMpJ9S3qd2RedHMoQFRiXK3jwdH81emAEsFYSW3Kb7caPcWjkza4S4EEWWbaggofGFmxE5gNNg4A4LNC2ZUGLsALZffNvg3yh3qg6rFxhkiyzWc44kx9Khp6Evm1j4Njh8kjifkngLTPFtX3uWNLAB1XrvpPMx6kkkhr7RnFVrA4JsDp5BwVGAXBoSBLTqweFevZ5"
- TEST_DATA_UR = UR("crypto-psbt", PSBT(TEST_DATA_BYTES).to_cbor())
+ TEST_DATA_UR = UR("crypto-psbt", Types.psbt_to_cbor(TEST_DATA_BYTES))
TEST_DATA_BBQR = b'psbt\xff\x01\x00R\x02\x00\x00\x00\x01\x9a\x9b\xe1\xca)\x10\\\x97t<\x0f\xd1\xeey\xc0\xe6\r"\x8aa\xc8\xec\xbft\xf9\xe7\xcf\xfa\x01\x19\x0c{\x01\x00\x00\x00\x00\xfd\xff\xff\xff\x01!&\x00\x00\x00\x00\x00\x00\x16\x00\x14\xae\xcd\x1e\xdc>\xffe\xaa \x9d\x02\x15\xe7=p\x90]\xc1hlX\x0b+\x00O\x01\x045\x87\xcf\x03\xdb\xde\xe7\x1b\x80\x00\x00\x00\xb3*S\x0b\x7f\xb7\xf7\xdb\x7fB\x9fcO\xa6\xf4%-P\xc6L\xea\xbd\xb5\xeaB\x97\xe4\x1e\x94\xf8\xae\x16\x02`\x9c%\xeaeN\x82\xec=\xf7\xd8wV\xb3\xc9"A\xe7i\xa7U\xfeoaw\x1e$Y\x00\xbb\x18\xa1\x10e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x01\x01\x1f\x10\'\x00\x00\x00\x00\x00\x00\x16\x00\x14I\x8cM\xa5\x8c\xbb\x9cZi\x88\xb82\xde\xb33l\xf4BT\xa0\x01\x03\x04\x01\x00\x00\x00"\x06\x03\xed\x17\xfd\x05\xafk\xcdQ\xf8\xddl\xf77\xac\x13j\xfa\x00h\x1f\xb2\xa9\xc3\xf5||\xad\xe8J\xf8\x1fa\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00'
TEST_DATA_BBQR_MULTI = b"psbt\xff\x01\x00\xc3\x02\x00\x00\x00\x03/#\xc5\xf6C##\xcb\x1b\x1e\x8e\x11-E\x00 \xdb*\xfa\x10\x1e\xd1N\xdea'm\x99J\xba\x96*\n\x00\x00\x00\x00\xfd\xff\xff\xff\xd2h\x80v\xf6<\x08\xa0k\x16\xce\x9f\xd9\n1\xbfF\x06\x81\x01\x0c\xae]\x0b\x11\x8a\xb5\xdfZ\xa6\xd3\xcf\x00\x00\x00\x00\x00\xfd\xff\xff\xff\xe9E\x99\x03\x1a\xf2\xe0n\xd7c7\xd3\xccx\xe5\xcc\x80\xbfu\xc2y\x19\x80\xfaK\x00\xaa\xdb\xd4\x00C\x9e\x00\x00\x00\x00\x00\xfd\xff\xff\xff\x02\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14N,\x8f\xf4\xae\xcc*/S\x03\x94\x8f\xdb~\xc9\xb7\xc1\x94\xeb\x97x\x00\x00\x00\x00\x00\x00\x00\x16\x00\x14\xfd\xfe\xbf\xcf\x13\xa7 \n\xe0\xa5\xca\x05\xad\xb0fSOphq\x00\x00\x00\x00O\x01\x045\x87\xcf\x03\xdb\xde\xe7\x1b\x80\x00\x00\x00\xb3*S\x0b\x7f\xb7\xf7\xdb\x7fB\x9fcO\xa6\xf4%-P\xc6L\xea\xbd\xb5\xeaB\x97\xe4\x1e\x94\xf8\xae\x16\x02`\x9c%\xeaeN\x82\xec=\xf7\xd8wV\xb3\xc9\"A\xe7i\xa7U\xfeoaw\x1e$Y\x00\xbb\x18\xa1\x10e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x01\x00\xfd\x88\x01\x02\x00\x00\x00\x01p\x89X\xbbU\x81\xa0\xc1(\xb1B\xb7\x02\x14\x17\xfa\\\xfe\x9e\xf6\xca\xb3\xe3T{$\xc0\xa1\xd1\x1c!\xc4\x08\x00\x00\x00\x00\xfd\xff\xff\xff\x0b\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14k\x0b\xa3\x1eb\x97\xcd\xe9=\x01\x86}{)\xd1\xb2\x05\xaf\x91l\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x1441\xdf\xbby\x91\x95\x98=(\xa8\xc7\x13\x8b1\xd7\x88\x15\x12\xe1\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14`7c\xbaI\xd36\xc5\x92\t)\xbc\xb4\xbfe6({\xee'\xe8\x03\x00\x00\x00\x00\x00\x00\x16\x00\x14y\xb9\x0cf\x17\xc2\xeb\xf1n\x18\xa3\xc7_9\x00+\xe8\xc2\xeb\x83\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14R\xf02\x18\xb2z|Mxp\xfcaS\x07\xac\xc9\xc0N\xc2\xdb\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\xde\xa6\xb0\x1a\xd4]#\xa6\xf8\xb9\xd7\xb1\xe7=\xb6\r\x1bu\xc47\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\x15'\xf3fl\xef\xb8\xbd\x00\xf9\x9e\xadeI\xac\xb6\xae\xdfJ3\x9c3+\x00\x00\x00\x00\x00\x16\x00\x14\x91[/\x02-\xe7z9\xe9=fnk\x8d\xc6\x98z\x94\x14O\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\xee\x86\xfc\x98C\x97,32\x1a\x97\x9b78\x8fu\xc8\xb7q\x8b\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\xf8\x9c.\x16\xfb{\xba\xacO\x9dN\xf9a\xf7\xe2!\x0f\xc9@\x86\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\xd9`;\xfe\xa1\xd0\xf7\x13\x15\xe7\xe5\x19_\xd3\xdcrG\xe7\xd9\x88;W'\x00\x01\x01\x1f\x10'\x00\x00\x00\x00\x00\x00\x16\x00\x14\xd9`;\xfe\xa1\xd0\xf7\x13\x15\xe7\xe5\x19_\xd3\xdcrG\xe7\xd9\x88\"\x06\x03\xa4\"z%\xc1F\xf2\xb3\x07\xa3\xe7G;\x9e\xe3v\x95\x1d\xcb\x0e\xadU\xf0\xed\x16\n\xc36e\xcb\x05\x98\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00q\x02\x00\x00\x00\x01\xb0f\x7f\xd1\xe3\nN>\xa7\xb2\x9d\xfa0\x93S>&\x0b\x125\xce\xbc\x85E\x94\xeb\xff\xc8\xcca\x80\x8b\x01\x00\x00\x00\x00\xfd\xff\xff\xff\x02\xe8\x03\x00\x00\x00\x00\x00\x00\x16\x00\x14u<\x0b0e\x14\xc0I\x18+\xfa\xa7\xe8\x9b\xaf\x93\x7f\x10\t\x88\xbf\xac/\x00\x00\x00\x00\x00\x16\x00\x14J\xad3#:\xe1\x81\xea\x90\xa8\xc4\xb6T\x84qc\xc5\x92\x01\xbf\xe8\xcd%\x00\x01\x01\x1f\xe8\x03\x00\x00\x00\x00\x00\x00\x16\x00\x14u<\x0b0e\x14\xc0I\x18+\xfa\xa7\xe8\x9b\xaf\x93\x7f\x10\t\x88\"\x06\x02\xd7\xb1PI\x10\xbbq'\x14Js\t\xde\xee\xde2\xe8\x8a\x06W\r\x96\xdbh1\x9e\xb7V\x05\xd5D\x12\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x00\x04\x00\x00\x00\x00\x01\x00R\x02\x00\x00\x00\x01\xfc\x11\xe3_\x96\xf5;\xb2\xe3\xccy\xfe2\xc8\xffh\x99\xdbM\x06\xf4iz[\xcd\x17\x87;tb\x1e\xb7\x00\x00\x00\x00\x00\xfd\xff\xff\xff\x01b\x07\x00\x00\x00\x00\x00\x00\x16\x00\x14\xbcZ\xe6\x1b+\xe5\xb6D\x05\xbf\x0e\xf6\x7fQ\xe2\xc5v\xd4\x97z}V'\x00\x01\x01\x1fb\x07\x00\x00\x00\x00\x00\x00\x16\x00\x14\xbcZ\xe6\x1b+\xe5\xb6D\x05\xbf\x0e\xf6\x7fQ\xe2\xc5v\xd4\x97z\"\x06\x02\xdf]\x7f\xd4t\x8b\x0e\x1b\nC\xa2X\x8as3\xa4'\x84I\xfc,R\xafJ\xe5\x05\x91\x83_\xc3\xab\x03\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x00\x1e\x00\x00\x00\x00\"\x02\x03\xaa\xe4r \x02Tb\xff53D\xb3_\x83\x028\xe21\x11\xd4oH\xd8B\x07\xfc\x8f(\x0b,5\xac\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00\x005\x00\x00\x00\x00\"\x02\x0246\xfb\xba\x95\x14\rX\x9c\x94\xc9\x05FW\x9d%\xe8\x1ebA\"ik\xf5\x01\x8f \x10\x1cN\xfd\x91\x18e\xfbC\xfeT\x00\x00\x80\x01\x00\x00\x80\x00\x00\x00\x80\x01\x00\x00\x00\x11\x00\x00\x00\x00"
@@ -76,7 +75,6 @@ def tdata(mocker):
def test_init(mocker, m5stickv):
- from ur.ur_decoder import URDecoder
from krux.qr import QRPartParser
parser = QRPartParser()
@@ -88,8 +86,7 @@ def test_init(mocker, m5stickv):
def test_parser(mocker, m5stickv, tdata):
- from ur.ur import UR
- from urtypes.crypto.psbt import PSBT
+ from uUR import UR, Types
from krux.qr import QRPartParser, FORMAT_NONE, FORMAT_PMOFN, FORMAT_UR, FORMAT_BBQR
cases = [
@@ -144,7 +141,7 @@ def test_parser(mocker, m5stickv, tdata):
res = parser.result()
if fmt == FORMAT_UR:
assert isinstance(res, UR)
- assert PSBT.from_cbor(res.cbor).data == tdata.TEST_DATA_BYTES
+ assert Types.psbt_from_cbor(res.cbor) == tdata.TEST_DATA_BYTES
elif fmt == FORMAT_BBQR:
assert isinstance(res, bytes)
if parser.total_count() > 1:
@@ -156,6 +153,42 @@ def test_parser(mocker, m5stickv, tdata):
assert res == tdata.TEST_DATA_B58
+def test_parser_ur_decoder_states(mocker, m5stickv, tdata):
+ """Transient UR decoder errors are ignored, terminal ones abort the parsing"""
+
+ import uUR
+ from krux.qr import QRPartParser
+
+ first_part = tdata.TEST_PARTS_FORMAT_MULTIPART_UR[0]
+
+ def stub_decoder(state):
+ """uUR.URDecoder is a static C type, so its methods cannot be patched.
+ qr.py resolves URDecoder from the module on every part it parses, so
+ swapping the module attribute for a stub reports the state we want."""
+
+ class StubURDecoder:
+ def __init__(self):
+ self.state = uUR.DECODER_PROCESSING
+
+ def receive_part(self, part):
+ self.state = state
+ return state
+
+ return StubURDecoder
+
+ # Transient errors are expected while scanning, parsing goes on
+ parser = QRPartParser()
+ mocker.patch.object(uUR, "URDecoder", stub_decoder(uUR.DECODER_ERR_INVALID_PART))
+ parser.parse(first_part)
+ assert not parser.is_complete()
+
+ for terminal_state in (uUR.DECODER_NO_RESULT, uUR.DECODER_ERR_INVALID_CHECKSUM):
+ parser = QRPartParser()
+ mocker.patch.object(uUR, "URDecoder", stub_decoder(terminal_state))
+ with pytest.raises(ValueError):
+ parser.parse(first_part)
+
+
def test_to_qr_codes(mocker, m5stickv, tdata):
from krux.qr import to_qr_codes, FORMAT_NONE, FORMAT_PMOFN, FORMAT_UR, FORMAT_BBQR
from krux.display import Display
@@ -172,7 +205,7 @@ def test_to_qr_codes(mocker, m5stickv, tdata):
# Test 320 pixels wide display
(FORMAT_NONE, tdata.TEST_DATA_B58, 320, 1),
(FORMAT_PMOFN, tdata.TEST_DATA_B58, 320, 3),
- (FORMAT_UR, tdata.TEST_DATA_UR, 320, 6),
+ (FORMAT_UR, tdata.TEST_DATA_UR, 320, 3),
(FORMAT_BBQR, BBQR_CODE_DATA, 320, 2),
]
for case in cases:
@@ -244,3 +277,96 @@ def test_parse_pmofn_rejects_invalid_index(m5stickv):
with pytest.raises(ValueError, match="Invalid pMofN part index"):
parse_pmofn_qr_part("p4of3 data")
+
+
+def test_parser_rejects_bbqr_header_mismatch(m5stickv):
+ """Parts must agree with the encoding and file type of the first part,
+ which is the one detect_format used to set up decoding"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$HP0200414243")
+
+ with pytest.raises(ValueError, match="BBQr header mismatch"):
+ parser.parse("B$ZU0201444546")
+
+ assert parser.parts == {0: "414243"}
+
+
+def test_parser_rejects_bbqr_total_mismatch(m5stickv):
+ """A part announcing a different total belongs to another stream"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$2P0300AAAAAAAA")
+
+ with pytest.raises(ValueError, match="BBQr part total mismatch"):
+ parser.parse("B$2P0100MZXW6YTB")
+
+ assert parser.total == 3
+ assert not parser.is_complete()
+
+
+def test_parser_rejects_conflicting_bbqr_part(m5stickv):
+ """The same index can be re-scanned, but not with different content"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$2P0200AAAAAAAA")
+ parser.parse("B$2P0200AAAAAAAA") # redundant scan of the same part is fine
+
+ with pytest.raises(ValueError, match="Conflicting BBQr part"):
+ parser.parse("B$2P0200MZXW6YTB")
+
+ assert parser.parts == {0: "AAAAAAAA"}
+
+
+def test_parser_rejects_oversized_bbqr_payload(m5stickv):
+ """Accumulated payload is bounded, a stream above it could not be decoded"""
+ from krux.qr import QRPartParser
+ from krux.bbqr import BBQR_MAX_PAYLOAD_LEN, int2base36
+
+ parser = QRPartParser()
+ part_size = 8192
+ parts = BBQR_MAX_PAYLOAD_LEN // part_size + 1
+ for index in range(parts - 1):
+ parser.parse(
+ "B$2P%s%s%s" % (int2base36(parts), int2base36(index), "A" * part_size)
+ )
+
+ with pytest.raises(ValueError, match="BBQr payload too big"):
+ parser.parse(
+ "B$2P%s%s%s" % (int2base36(parts), int2base36(parts - 1), "A" * part_size)
+ )
+
+
+def test_detect_format_propagates_base_exceptions(mocker, m5stickv):
+ """detect_format catches genuine parsing errors (returns FORMAT_NONE) but
+ must NOT swallow BaseException-level signals like KeyboardInterrupt.
+
+ Regression for narrowing the bare except to ``except Exception``.
+ """
+ from krux.qr import detect_format
+
+ class Boom:
+ def startswith(self, _):
+ raise KeyboardInterrupt
+
+ with pytest.raises(KeyboardInterrupt):
+ detect_format(Boom())
+
+
+def test_max_qr_bytes_caps_at_last_version(mocker, m5stickv):
+ """A width beyond the supported version range falls back to the largest
+ capacity (exercises the narrowed `except IndexError`)."""
+ from krux.qr import max_qr_bytes, QR_CAPACITY_BYTE
+
+ assert max_qr_bytes(200) == QR_CAPACITY_BYTE[-1]
+
+
+def test_detect_format_returns_none_on_undecodable_data(mocker, m5stickv):
+ """Genuine parse errors (e.g. undecodable bytes) are caught and reported as
+ FORMAT_NONE (exercises the narrowed `except Exception`)."""
+ from krux.qr import detect_format, FORMAT_NONE
+
+ assert detect_format(b"\xff\xfe\xfd") == (FORMAT_NONE, None)
### tests/test_settings.py
@@ -265,6 +265,145 @@ def test_store_get():
assert s.get(case[0], case[1], case[3]) == case[2]
+def test_store_get_malformed_namespace_returns_default():
+ """A non-dict intermediate namespace must return the default, not raise.
+
+ Covers BOTH a non-dict at the first level AND a non-dict reached after a
+ valid descent (proves traversal stays safe mid-walk). Only reachable via a
+ corrupted/hand-edited settings file; set() never creates this state.
+ Approved behavior change: graceful degradation.
+ """
+ from krux.settings import Store
+
+ s = Store()
+
+ # Case 1: non-dict at the very first level.
+ s.settings = {"settings": "not_a_dict"}
+ assert s.get("settings.i18n", "locale", "en-US") == "en-US"
+ assert s.settings == {"settings": "not_a_dict"} # no mutation
+
+ # Case 2: non-dict reached AFTER successfully descending a valid dict.
+ s.settings = {"settings": {"printer": "not_a_dict"}}
+ assert s.get("settings.printer.thermal", "baudrate", 9600) == 9600
+ assert s.settings == {"settings": {"printer": "not_a_dict"}} # no mutation
+
+
+def test_store_set_repairs_non_dict_namespace(mocker, m5stickv):
+ """Store.set must not crash when an intermediate namespace is a non-dict
+ (e.g. a corrupted file like {"settings": "broken"}); it replaces the bad
+ level with a dict, stores the value, and repairs the structure.
+ """
+ from krux.settings import Store
+
+ s = Store()
+ s.settings = {"settings": "broken"}
+
+ # Pre-fix this raised AttributeError: 'str' object has no attribute 'get'.
+ s.set("settings.appearance", "theme", "dark")
+
+ assert s.settings["settings"]["appearance"]["theme"] == "dark"
+ assert s.dirty is True
+ # Read-back through the repaired structure returns the stored value.
+ assert s.get("settings.appearance", "theme", "light") == "dark"
+
+
+def test_store_delete_survives_non_dict_namespace(mocker, m5stickv):
+ """Store.delete must not crash when an intermediate namespace is a non-dict."""
+ from krux.settings import Store
+
+ s = Store()
+ s.settings = {"settings": "broken"}
+
+ # Pre-fix this raised AttributeError walking into the string "broken".
+ s.delete("settings.appearance", "theme") # nothing to delete, must not raise
+ # The non-dict "broken" must no longer be present (repaired, then the empty
+ # levels cleaned up by delete's own pruning).
+ assert s.settings.get("settings") != "broken"
+
+
+def test_store_init_survives_non_dict_settings_namespace(mocker, m5stickv):
+ """Store.__init__ must not crash when the persisted 'settings' value is a
+ non-dict.
+
+ The loader only validates the top level is a dict, so a corrupted file like
+ {"settings": "not_a_dict"} passes load. The location read in __init__ walks
+ settings.persist.location and must degrade gracefully instead of raising
+ AttributeError. Reads through the malformed namespace return defaults.
+ """
+ stored_settings = '{"settings": "not_a_dict"}'
+ mocker.patch("builtins.open", mocker.mock_open(read_data=stored_settings))
+
+ from krux.settings import Store, FLASH_PATH
+
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+ assert store.get("settings.i18n", "locale", "en-US") == "en-US"
+
+
+def test_store_init_survives_bogus_persist_location(mocker, m5stickv):
+ """Store.__init__ must not crash on a bogus persist.location value.
+
+ The structure is well-formed but `location` holds an unexpected value
+ (non-string, or an unknown string). `_persisted_location` must return only a
+ known location (SD/flash) and otherwise fall back to the default, so the
+ `SD_PATH not in self.file_location` membership test never sees a non-string.
+ """
+ from krux.settings import Store, FLASH_PATH
+
+ # Non-string location (would raise "argument of type 'int' is not iterable").
+ mocker.patch(
+ "builtins.open",
+ mocker.mock_open(read_data='{"settings": {"persist": {"location": 123}}}'),
+ )
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+
+ # Unknown string location -> also falls back to flash.
+ mocker.patch(
+ "builtins.open",
+ mocker.mock_open(read_data='{"settings": {"persist": {"location": "xyz"}}}'),
+ )
+ store = Store() # must not raise
+ assert FLASH_PATH in store.file_location
+
+
+def test_store_get_recovers_from_malformed_persisted_namespace(mocker, m5stickv):
+ """End-to-end: a persisted settings file with a well-formed top level but a
+ non-dict nested namespace loads, and reads through it degrade gracefully.
+
+ The top level, `settings`, and `settings.persist` are well-formed (the
+ constructor reads `persist` to pick the file location), but the `i18n`
+ namespace is a string instead of a dict. get() must return the default
+ rather than raising. Covers the file -> __init__ -> get() chain, not just
+ direct Store.get() calls.
+ """
+ stored_settings = '{"settings": {"i18n": "not_a_dict"}}'
+ mocker.patch("builtins.open", mocker.mock_open(read_data=stored_settings))
+
+ from krux.settings import Store
+
+ store = Store()
+ assert store.settings == {"settings": {"i18n": "not_a_dict"}}
+ assert store.get("settings.i18n", "locale", "en-US") == "en-US"
+
+
+def test_store_get_deep_nesting():
+ """A 4-level namespace returns stored value when set, default when unset."""
+ from krux.settings import Store
+
+ s = Store()
+ ns = "settings.printer.thermal.adafruit"
+
+ # Unset -> default.
+ assert s.get(ns, "tx_pin", 35) == 35
+ # Getter must not populate the settings dict on a miss.
+ assert s.settings == {}
+
+ # Set then get returns the stored value, ignoring the default.
+ s.set(ns, "tx_pin", 21)
+ assert s.get(ns, "tx_pin", 35) == 21
+
+
def test_store_set():
from krux.settings import Store
### tests/test_themes.py
@@ -0,0 +1,145 @@
+import pytest
+
+
+@pytest.fixture
+def rgb565_to_rgb():
+ def _calc(color):
+ color = ((color & 0xFF) << 8) | ((color >> 8) & 0xFF)
+ return (
+ ((color >> 11) & 0x1F) * 255 / 31,
+ ((color >> 5) & 0x3F) * 255 / 63,
+ (color & 0x1F) * 255 / 31,
+ )
+
+ return _calc
+
+
+@pytest.fixture
+def relative_luminance_component():
+ def _calc(value):
+ value = value / 255
+ if value <= 0.03928:
+ return value / 12.92
+ return ((value + 0.055) / 1.055) ** 2.4
+
+ return _calc
+
+
+@pytest.fixture
+def relative_luminance(rgb565_to_rgb, relative_luminance_component):
+ def _calc(color):
+ red, green, blue = rgb565_to_rgb(color)
+ return (
+ 0.2126 * relative_luminance_component(red)
+ + 0.7152 * relative_luminance_component(green)
+ + 0.0722 * relative_luminance_component(blue)
+ )
+
+ return _calc
+
+
+@pytest.fixture
+def contrast_ratio(relative_luminance):
+ def _calc(color_a, color_b):
+ luminance_a = relative_luminance(color_a)
+ luminance_b = relative_luminance(color_b)
+ lighter = max(luminance_a, luminance_b)
+ darker = min(luminance_a, luminance_b)
+ return (lighter + 0.05) / (darker + 0.05)
+
+ return _calc
+
+
+def test_text_and_status_colors_meet_normal_text_contrast(amigo, contrast_ratio):
+ from krux.themes import THEMES
+
+ cases = (
+ ("Dark", "foreground", "background"),
+ ("Dark", "foreground", "info_background"),
+ ("Dark", "go", "background"),
+ ("Dark", "error", "background"),
+ ("Light", "foreground", "background"),
+ ("Light", "foreground", "info_background"),
+ ("Light", "go", "background"),
+ ("Light", "error", "background"),
+ ("Orange", "foreground", "background"),
+ ("Orange", "foreground", "info_background"),
+ ("Orange", "go", "background"),
+ ("Orange", "error", "background"),
+ ("CypherPink", "foreground", "background"),
+ ("CypherPink", "foreground", "info_background"),
+ ("CypherPink", "go", "background"),
+ ("CypherPink", "error", "background"),
+ ("CypherPunk", "foreground", "background"),
+ ("CypherPunk", "foreground", "info_background"),
+ ("CypherPunk", "go", "background"),
+ ("CypherPunk", "error", "background"),
+ )
+ for theme_name, foreground, background in cases:
+ palette = THEMES[theme_name]
+ assert contrast_ratio(palette[foreground], palette[background]) >= 4.5
+
+
+def test_frames_meet_non_text_contrast(amigo, contrast_ratio):
+ from krux.themes import THEMES
+
+ cases = (
+ "Dark",
+ "Light",
+ "Orange",
+ "CypherPink",
+ "CypherPunk",
+ )
+ for theme_name in cases:
+ palette = THEMES[theme_name]
+ assert contrast_ratio(palette["frame"], palette["background"]) >= 3
+
+
+def test_cypherpink_frame_keeps_theme_identity(amigo, contrast_ratio):
+ from krux.themes import THEMES
+
+ palette = THEMES["CypherPink"]
+
+ assert palette["frame"] != palette["disabled"]
+ assert contrast_ratio(palette["frame"], palette["background"]) >= 4.5
+
+
+def test_network_colors_use_light_theme_variants_only_on_light_theme(
+ amigo, contrast_ratio
+):
+ from krux.krux_settings import Settings
+ from krux.pages.utils import Utils
+ from krux.themes import DARKERGREEN, DARKERORANGE, GREEN, ORANGE, THEMES
+
+ cases = (
+ ("Dark", ORANGE, GREEN),
+ ("Light", DARKERORANGE, DARKERGREEN),
+ ("Orange", ORANGE, GREEN),
+ ("CypherPink", ORANGE, GREEN),
+ ("CypherPunk", ORANGE, GREEN),
+ )
+ for theme_name, main_color, test_color in cases:
+ Settings().appearance.theme = theme_name
+ background = THEMES[theme_name]["background"]
+
+ assert Utils.get_network_color("Mainnet") == main_color
+ assert Utils.get_network_color("Testnet") == test_color
+ assert contrast_ratio(main_color, background) >= 4.5
+ assert contrast_ratio(test_color, background) >= 4.5
+
+
+def test_amigo_uses_disabled_color_for_info_background(amigo):
+ from krux.krux_settings import Settings
+ from krux.themes import THEMES, Theme
+
+ cases = (
+ "Dark",
+ "Light",
+ "Orange",
+ "CypherPink",
+ "CypherPunk",
+ )
+ for theme_name in cases:
+ Settings().appearance.theme = theme_name
+
+ assert Theme().info_bg_color == THEMES[theme_name]["disabled"]
### tests/test_touch.py
@@ -7,8 +7,8 @@ def mock_settings(mocker):
"""Mock Settings to avoid dependency on hardware config"""
mock_settings_obj = mocker.MagicMock()
mock_settings_obj.hardware.touch.threshold = 40
- # Ensure hardware doesn't have display attribute to avoid coordinate flipping
- del mock_settings_obj.hardware.display
+ # Default to no coordinate flipping
+ mock_settings_obj.is_flipped_orientation.return_value = False
mock_settings_class = mocker.patch("krux.touch.Settings")
mock_settings_class.return_value = mock_settings_obj
return mock_settings_obj
### tests/test_wallet.py
@@ -1,12 +1,20 @@
import pytest
-from ur.ur_decoder import URDecoder
+import uUR
+
+
+def decode_single_part_ur(part):
+ """uUR's decoder is a state machine with no single-shot decode() helper,
+ so feed the lone part and hand back the assembled UR."""
+ decoder = uUR.URDecoder()
+ assert decoder.receive_part(part) == uUR.DECODER_OK
+ return decoder.result
@pytest.fixture
def tdata(mocker):
import binascii
from collections import namedtuple
- from ur.ur import UR
+ from uUR import UR
from krux.bbqr import encode_bbqr
from embit.networks import NETWORKS
from krux.key import (
@@ -1096,7 +1104,7 @@ def test_load_multisig(mocker, m5stickv, tdata):
},
),
]
- from ur.ur import UR
+ from uUR import UR
n = 0
for case in cases:
@@ -1669,7 +1677,7 @@ def test_provably_unspendable_non_deterministic_chain_code(mocker, m5stickv, tda
def test_parse_wallet_raises_errors(mocker, m5stickv, tdata):
from krux.wallet import parse_wallet
- from ur.ur import UR
+ from uUR import UR
cases = [
tdata.BLUEWALLET_MULTISIG_WALLET_DATA_MISSING_KEYS,
@@ -1772,6 +1780,70 @@ def test_parse_address_raises_errors(mocker, m5stickv, tdata):
parse_address(case)
+def test_parse_address_rejects_unknown_base58_version(m5stickv):
+ """A base58 address with a valid checksum but an unknown version byte must be
+ rejected. address_to_scriptpubkey returns None (no exception) for such an
+ address, so parse_address must check the returned Script, not only catch
+ errors.
+ """
+ from embit import base58
+ from krux.wallet import parse_address
+
+ # Valid base58check payload; version byte 0xFF matches no network p2pkh/p2sh
+ unknown_version_address = base58.encode_check(b"\xff" + b"\x00" * 20)
+ with pytest.raises(ValueError):
+ parse_address(unknown_version_address)
+
+
+def test_parse_address_propagates_keyboardinterrupt(mocker, m5stickv):
+ """KeyboardInterrupt must propagate: never swallowed by the bech32-uppercase
+ fallback, nor relabeled 'invalid address' by the final attempt.
+
+ parse_address imports address_to_scriptpubkey *inside* the function, so the
+ patch target is embit.script.address_to_scriptpubkey — there is no
+ krux.wallet.address_to_scriptpubkey to patch.
+ """
+ from krux.wallet import parse_address
+
+ mocker.patch("embit.script.address_to_scriptpubkey", side_effect=KeyboardInterrupt)
+
+ # Uppercase input exercises the bech32-uppercase fallback branch
+ with pytest.raises(KeyboardInterrupt):
+ parse_address("BC1QX2ZUDAY8D6J4UFH4DF6E9TTD06LNFMN2CUZ0VN")
+
+ # Mixed-case input skips that branch and exercises the final attempt
+ with pytest.raises(KeyboardInterrupt):
+ parse_address("bc1qx2zuday8d6j4ufh4df6e9ttd06lnfmn2cuz0vn")
+
+
+def test_parse_wallet_propagates_keyboardinterrupt(mocker, m5stickv):
+ """KeyboardInterrupt must propagate from each parse_wallet fallback: never
+ swallowed by the JSON or raw-descriptor fallbacks, nor relabeled 'invalid
+ wallet format' by the key-value fallback."""
+ import krux.wallet
+ from krux.wallet import parse_wallet
+
+ # JSON branch: valid JSON with a 'descriptor' key; the Descriptor.from_string
+ # call is interrupted.
+ mocker.patch.object(
+ krux.wallet.Descriptor, "from_string", side_effect=KeyboardInterrupt
+ )
+ with pytest.raises(KeyboardInterrupt):
+ parse_wallet('{"descriptor": "x"}')
+
+ # Key-value branch: parse_key_value_file is interrupted. (json.loads of a
+ # non-JSON string fails first and is correctly caught by the JSON branch.)
+ mocker.patch("krux.wallet.parse_key_value_file", side_effect=KeyboardInterrupt)
+ with pytest.raises(KeyboardInterrupt):
+ parse_wallet("invalid wallet format")
+
+ # Raw-descriptor branch: key-value returns nothing (so we fall through), and
+ # the Descriptor.from_string call is interrupted (still patched from above).
+ mocker.patch("krux.wallet.parse_key_value_file", return_value=(None, None))
+ with pytest.raises(KeyboardInterrupt):
+ parse_wallet("wpkh(tpubraw/0/*)")
+
+
def test_to_unambiguous_descriptor(mocker, m5stickv, tdata):
from embit.descriptor import Descriptor
from krux.wallet import to_unambiguous_descriptor
@@ -1843,7 +1915,7 @@ def test_parse_wallet_via_ur_output(mocker, m5stickv):
]
for i, QRDATUM in enumerate(QRDATA):
- wallet_data = URDecoder().decode(QRDATUM)
+ wallet_data = decode_single_part_ur(QRDATUM)
descriptor, label = parse_wallet(wallet_data)
assert str(descriptor) == DESCRIPTORS[i]
print(DESCRIPTORS[i])
@@ -1871,7 +1943,7 @@ def test_parse_wallet_via_ur_account(mocker, m5stickv):
]
for i, QRDATUM in enumerate(QRDATA):
- wallet_data = URDecoder().decode(QRDATUM)
+ wallet_data = decode_single_part_ur(QRDATUM)
descriptor, label = parse_wallet(wallet_data)
assert str(descriptor) == DESCRIPTORS[i]
print(DESCRIPTORS[i])
### uv.lock
[binary or diff unavailable]
### vendor/embit
@@ -1 +1 @@
-Subproject commit 8d7591229bd34de09aff3986b476bc99fe2b45aa
+Subproject commit fff7ffa43f6ce088c5ba22cb3877a122bf01dc96
### vendor/foundation-ur-py
@@ -1 +0,0 @@
-Subproject commit 746008ea0fcb231c0d79770a23a38ef517a0a341
### vendor/urtypes
@@ -1 +0,0 @@
-Subproject commit 4bf39ee3f6bf96b34f4722d5e46a3ffabfada22dWhy this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.