Merge pull request #502 from LedgerHQ/aro/fuzzing-framework
What changed, and why it matters
This commit adds a new developer-only fuzzing test framework to the Ledger Bitcoin app. It does not change how the app behaves on a real device; it only adds automated test infrastructure that feeds random or structured inputs to the app in a simulated environment to help find bugs. There is no indication this commit fixes or introduces a security vulnerability in production code.
No security action required. Review the fuzzing framework as normal developer tooling. If auditing, confirm that all FUZZING_BUILD_MODE_UNSAFE_FOR_PERF_TESTS changes are absent from production firmware builds.
Security signals we found
No security-relevant signal: commit is purely defensive testing infrastructure
Production code changes are minimal and fuzz-build-gated
No CVE, advisory, or vendor security disclosure referenced
No patch of a reported vulnerability
Evidence from the diff
The commit merges PR #502, which introduces an Absolution/libFuzzer-based fuzzing harness for the Ledger Bitcoin app. It adds ClusterFuzzLite CI configuration, CMake build files, fuzzing mocks (continuation host, os_io_rx_evt, PSBT/wallet/message models), a custom mutator, invariant definitions, a seed corpus, and documentation. The only production source files touched are minor compatibility edits in src/common/merkle.c, src/common/parser_ext.c, src/common/segwit_addr.c, src/handler/lib/check_merkle_tree_sorted.c, src/handler/lib/policy.c, src/handler/sign_psbt.h, and sign_psbt/*.c. These changes are gated behind FUZZING_BUILD_MODE_UNSAFE_FOR_PERF_TESTS or are trivial refactorings to support the fuzz build. No security bug is described or fixed.
Changed components
Ledger Bitcoin app fuzzing test suite.clusterfuzzlite CI configurationfuzzing/ directory (harness, mocks, models, invariants, manifest)Minor production source compatibility edits for fuzz buildsInspect captured patch +4181 / −57
### .clusterfuzzlite/Dockerfile
@@ -0,0 +1,17 @@
+FROM ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest AS app-builder
+
+# Base image with clang toolchain
+FROM gcr.io/oss-fuzz-base/base-builder:v1
+
+# Install additional package dependencies.
+RUN pip3 install --break-system-packages --no-cache-dir pillow>=3.4.0
+RUN apt update && apt install -y ninja-build libbsd-dev pkg-config
+
+COPY --from=app-builder /opt/flex-secure-sdk /ledger-secure-sdk
+
+# Copy the project's source code.
+COPY . /app
+
+# Working directory for build.sh
+WORKDIR /app
+COPY ./.clusterfuzzlite/build.sh $SRC/
### .clusterfuzzlite/build.sh
@@ -0,0 +1,5 @@
+#!/bin/bash -eu
+# ClusterFuzzLite build: delegate to the shared SDK script.
+export BOLOS_SDK=/ledger-secure-sdk
+export APP_DIR=/app
+exec "${BOLOS_SDK}/fuzzing/scripts/cfl-build.sh"
### .clusterfuzzlite/project.yaml
@@ -0,0 +1 @@
+language: c
### .github/workflows/clusterfuzzlite.yml
@@ -0,0 +1,22 @@
+name: ClusterFuzzLite fuzzing tests
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - master
+ - develop
+ schedule:
+ - cron: '0 13 * * 6' # 13:00 UTC every Saturday
+
+permissions: read-all
+
+jobs:
+ Fuzzing:
+ uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_clusterfuzz_tests.yml@v1
+ with:
+ exec_mode: ${{ github.event_name }}
+ secrets:
+ # Optional: absent in forks, and the workflow skips the upload without it.
+ codecov_token: ${{ secrets.CODECOV_TOKEN }}
### .gitignore
@@ -17,6 +17,14 @@ tests/.test_bitcoin
# Fuzzing
fuzzing/build/
+.fuzz-artifacts/
+# Per-build Absolution invariant snapshot (machine-local; see the SDK fuzzing docs).
+fuzzing/invariants/fuzz_globals.zon
+
+# Build artefacts
+compile_commands.json
+*.profraw
+*.profdata
# Editors
.idea/
### CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are in `dd-mm-yyyy` format.
+## [2.X.X] - XX-XX-2026
+
+### Added
+
+- Stateful, SDK-native fuzzing framework (Absolution-based) with semantic continuation host for `SIGN_PSBT` (developer tooling).
+
## [2.5.0] - 24-07-2026
### Changed
### fuzzing/CMakeLists.txt
@@ -0,0 +1,63 @@
+cmake_minimum_required(VERSION 3.20)
+
+project(
+ BitcoinFuzzer
+ VERSION 1.0
+ DESCRIPTION "App Bitcoin Fuzzer"
+ LANGUAGES C)
+
+if(NOT DEFINED BOLOS_SDK)
+ message(FATAL_ERROR "BOLOS_SDK must be defined, CMake will exit.")
+endif()
+
+# Consumed by ledger_fuzz_setup() below, so it must be set first.
+set(LEDGER_FUZZ_APP_UBSAN_IGNORELIST
+ "${CMAKE_SOURCE_DIR}/sanitizers/ubsan-ignorelist.txt")
+
+include(${BOLOS_SDK}/fuzzing/cmake/LedgerAppFuzz.cmake)
+ledger_fuzz_setup()
+
+set(APP_SOURCE_DIR ${CMAKE_SOURCE_DIR}/..)
+file(GLOB_RECURSE C_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/mock/*.c" "${APP_SOURCE_DIR}/src/*.c")
+
+# The client-command host is the app's own unit-test mock_dispatcher, not a second
+# implementation: same four commands, same Merkle code (src/common/merkle.c), same
+# SHA-256 (cx_hash_sha256).
+list(APPEND C_SOURCES "${APP_SOURCE_DIR}/unit-tests/libs/mock_dispatcher.c")
+# mock_dispatcher_add_psbt() calls psbt_parse(); the fuzz harness never uses that
+# entry point, but it lives in the same translation unit so the symbol is required.
+list(APPEND C_SOURCES "${APP_SOURCE_DIR}/unit-tests/libs/psbt_parse.c")
+list(FILTER C_SOURCES EXCLUDE REGEX ".*/debug-helpers/.*")
+
+# Force-include debug-helpers/debug.h on app + mock sources (matching the
+# production Makefile) so app headers relying on bool/size_t compile. Scoped
+# to these sources only — the generated fuzzer.c keeps its minimal includes.
+# The -fsanitize=function carve-out for the generic callback typedefs lives in
+# sanitizers/ubsan-ignorelist.txt, scoped to the files that own the idiom.
+set_source_files_properties(
+ ${C_SOURCES}
+ PROPERTIES
+ COMPILE_OPTIONS "-include;${APP_SOURCE_DIR}/src/debug-helpers/debug.h"
+)
+
+# Add every directory containing a header so app-internal includes resolve
+# without tracking the source layout here.
+ledger_fuzz_collect_include_dirs(APP_INCLUDE_DIRS ${APP_SOURCE_DIR}/src)
+
+# Enable the app's UI auto-approve (display.c) so the harness never blocks on confirmations.
+set(_fuzz_compile_defs HAVE_AUTOAPPROVE_FOR_PERF_TESTS=1)
+message(STATUS "Bitcoin fuzz build: single structured harness")
+
+ledger_fuzz_add_app_target(
+ NAME fuzz_app
+ INVARIANT ${CMAKE_SOURCE_DIR}/invariants/fuzz_globals.zon
+ SOURCES
+ ${C_SOURCES}
+ INCLUDE_DIRECTORIES
+ ${APP_INCLUDE_DIRS}
+ ${CMAKE_SOURCE_DIR}/mock/
+ ${APP_SOURCE_DIR}/unit-tests/libs/
+ ${CMAKE_SOURCE_DIR}
+ COMPILE_DEFINITIONS
+ ${_fuzz_compile_defs}
+)
### fuzzing/README.md
@@ -0,0 +1,287 @@
+# Bitcoin Fuzzing
+
+This app integrates the fuzzing framework shipped with `ledger-secure-sdk`.
+It uses the SDK's framework-driven entry
+(`fuzz_harness_entry`) for APDU routing — the Bitcoin-specific pieces are
+per-lane weighted `fuzz_commands[]` maps (via the SDK's
+`FUZZ_PICK_COMMAND_RAW` / `FUZZ_PICK_COMMAND_STRUCTURED` override hooks),
+app-side `fuzz_app_reset` / `fuzz_app_dispatch` callbacks, and a
+continuation-host model required because several handlers pull extra data
+through `os_io_rx_evt()`.
+
+## Run it
+
+See the [SDK Fuzzing Framework documentation](https://ledgerhq.github.io/ledger-secure-sdk/)
+for full campaign and coverage instructions. Quick start from the dev container:
+
+```bash
+export BOLOS_SDK=/opt/flex-secure-sdk
+"$BOLOS_SDK"/fuzzing/scripts/app-campaign.sh --app-dir "$(pwd)" bitcoin-run
+```
+
+- **`bitcoin-run`** is the campaign name (optional); outputs go to
+ `.fuzz-artifacts/bitcoin-run/` under the app root. Omit for a UTC timestamp.
+- Defaults: **`FUZZ_TIME=90`**, **`WORKERS=min(2, nproc)`**.
+- To carry a prior run's corpus forward, promote it with
+ `corpus.py promote <run>/targets/fuzz_app/corpus fuzzing/base-corpus.zip`; it
+ then loads on every later run and its `.compat-key` is checked against the build.
+
+The promoted seed corpus at `fuzzing/base-corpus.zip` (with its
+`fuzzing/base-corpus.compat-key` sidecar) is folded into the bootstrap corpus
+automatically by `app-campaign.sh` when it is compatible. Set
+**`BASE_CORPUS_ZIP=`** to skip it if the checked-in corpus is stale for your
+layout.
+
+## Single harness
+
+Bitcoin builds one fuzzing harness configuration. Two small fuzz-only repairs
+are gated behind `FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION`: a wallet-policy
+repeated-pubkey bypass (the crypto mock returns a constant pubkey) and a relaxed
+cross-chain swap output-count check (so the OP_RETURN parser stays reachable).
+UI confirmations are auto-approved separately via the app's own
+`HAVE_AUTOAPPROVE_FOR_PERF_TESTS` flag (`display.c`).
+MuSig signing runs against the SDK crypto mock, so its consistency checks reject
+the mocked values early; descriptor parsing is still fully exercised.
+
+## What the harness actually does
+
+The input still has the normal shape:
+
+```text
+[ Absolution prefix | tail ]
+```
+
+The tail begins with the framework's 4 control bytes (lane, command, `P1`,
+`P2`), followed by Bitcoin's own 16-byte header — the builder entropy
+`psbt_entropy`, declared to the framework as `FUZZ_APP_HEADER_LEN` and copied
+out of the input in `fuzz_entry()`. The APDU payload starts after it, so
+`fuzz_tail_ptr[0]` is a stable base for the builders.
+
+Bitcoin overrides only the raw-lane command picker, so each lane draws from its
+own weighted map (see `harness/fuzz_dispatcher.c`):
+
+```
+#define FUZZ_PICK_COMMAND_RAW(data, size) \
+ (&btc_raw_commands[(data)[1] % btc_raw_n_commands])
+```
+
+The structured lane keeps the framework default over `fuzz_commands[]`.
+`fuzz_app_dispatch()` calls `fuzz_use_structured_lane()` to decide whether to
+synthesise a structured payload — the framework sets the lane before
+`fuzz_app_reset()` runs, so the app tracks nothing itself.
+
+The prefix owns app state; the input owns routing and almost all structured
+builder content:
+
+- `G_swap_state` is driven directly by Absolution (domain-constrained).
+- Continuation replies are served by the semantic host from the scenario
+ built for the current iteration (not prefix-driven).
+- Control byte 0 decides raw vs structured lane, byte 1 picks the command slot.
+- `psbt_entropy[2]` co-selects the `SIGN_PSBT` subtype and the wallet
+ descriptor template; in both cases it is combined with a payload slot (below),
+ so header and payload share the choice.
+- The tail's dense slots carry the values the builders materialize into APDUs:
+ PSBT amounts / sequences / locktime / tx version / counts, wallet template
+ and address controls, message length / path bits, and the fault knobs.
+- `fuzz_mock_ui_reject`, `fuzz_mock_nbgl_reject` are Absolution-driven control
+ globals (see `invariants/domain-overrides.txt`).
+
+### Tail layout
+
+Dense 64-byte builder slots occupy `fuzz_tail[0..N_SLOTS*64-1]`.
+The last 4 bytes of the tail carry fault knobs:
+
+| Offset (from end) | Field | Description |
+|---:|---|---|
+| `-4` | `fault[0] & 0x07` | Fault kind (see `BTC_FAULT_*` in `mocks.h`) |
+| `-3` | `fault[1]` | Target index (round for cont faults, input for builder) |
+| `-2` | `fault[2]` | Parameter 0 |
+| `-1` | `fault[3]` | Parameter 1 |
+
+| Kind | Name | Effect | Site |
+|---:|---|---|---|
+| 0 | `CLEAN` | No fault (default for seeds) | — |
+| 1 | `WRONG_HMAC` | Corrupt wallet HMAC after tree sealed | `psbt_model.c` |
+| 2 | `SIGHASH_OVR` | Override sighash on target input | `psbt_model.c` |
+| 3 | `AMOUNT_XOR` | XOR target input amount | `psbt_model.c` |
+| 4 | `SEQ_LOCK` | Set conflicting sequence/locktime | `psbt_model.c` |
+| 5 | `OUTPUT_AMT` | Set output amount to 0xFFFFFFFFFF | `psbt_model.c` |
+| 6 | `CONT_TRUNCATE` | Truncate targeted continuation reply | `fuzz_dispatcher.c` |
+| 7 | `CONT_FLIP` | Flip bytes in targeted continuation reply | `fuzz_dispatcher.c` |
+
+Seeds set `fault[0]=0` (clean). libFuzzer discovers that mutating the
+last 4 bytes activates different fault paths.
+
+### Field-aware custom mutator
+
+For structured inputs, ~30% of mutations apply a domain-aware operation
+(boundary amounts, special sequences/sighash, slot swap/zero, fault knob
+mutation) on top of the generic split mutator.
+
+## Raw vs structured lanes
+
+The framework decides the lane from the input's first byte against
+`FUZZ_STRUCTURED_LANE_THRESHOLD`. Bitcoin then draws from a lane-specific
+weighted command map so the fuzzer stops wasting iterations on commands
+that are dead-on-arrival in the wrong lane.
+
+### Raw lane (`btc_raw_commands[]`, 4 slots)
+
+Commands that make sense with arbitrary `data[]` bytes as the APDU:
+
+| Slots | Command | Share |
+|---:|---|---:|
+| 3 | `GET_EXTENDED_PUBKEY` | 75% |
+| 1 | `GET_MASTER_FINGERPRINT` | 25% |
+
+### Structured lane (`fuzz_commands[]`, 16 slots)
+
+Commands that need continuation traffic or semantic construction. Payload
+bytes are synthesised by `fuzz_app_dispatch` from the restored state + builder
+entropy. `SIGN_PSBT` carries the bulk of the budget; its internal subtype
+(default / registered / rawtx / musig round 1 & 2) is decoded from
+`(slot0[62] + psbt_entropy[2]) & 0x0F`.
+
+| Slots | Command | Share |
+|---:|---|---:|
+| 8 | `SIGN_PSBT` | 50% |
+| 2 | `REGISTER_WALLET` | 12.5% |
+| 2 | `GET_WALLET_ADDRESS` | 12.5% |
+| 2 | `SIGN_MESSAGE` | 12.5% |
+| 1 | `FUZZ_INS_SWAP_CHECK` (0xF1) | 6.25% |
+| 1 | `FUZZ_INS_SWAP_HELPERS` (0xF2) | 6.25% |
+
+### SIGN_PSBT subtype map (`(slot0[62] + psbt_entropy[2]) & 0x0F`, 16 slots)
+
+| Slots | Subtype | Share |
+|---:|---|---:|
+| 6 | default | 37.5% |
+| 4 | registered | 25% |
+| 4 | rawtx | 25% |
+| 1 | musig round 1 | 6.25% |
+| 1 | musig round 2 | 6.25% |
+
+Decoded by `pm_decode_subtype_slot()` (`mock/psbt_model.c`); the tail slot and
+the prefix byte both have per-iteration leverage over which PSBT scenario the
+builder emits.
+
+## Descriptor templates
+
+`mock/wallet_model.c` holds a descriptor catalog selected per iteration from
+`psbt_entropy[2] ^ slot0[template-seed]`. Row order is append-only because
+`generate-seed-corpus.py` and disruption tests reference rows by index.
+Every row uses the V2 key-expression suffix (`@N/**`) when needed; the
+builder auto-promotes V1 → V2 when the descriptor exceeds the V1 length
+cap.
+
+| Rows | Group | Purpose |
+|---:|---|---|
+| 0-3 | Single-key simple | `wpkh` / `pkh` / `sh(wpkh)` / `tr` — canonical address paths |
+| 4-7 | Multisig | `wsh(multi)`, `wsh(sortedmulti)`, `tr(A,pk(B))`, `sh(multi)` |
+| 8-18 | Miniscript fragments | `and_v`, `or_b`, `or_i`, `andor`, `multi_a`, `sortedmulti_a`, `thresh`, `older`/`after`, hash-preimage locks |
+| 19-28 | Wrapper-focused | `or_d`, `and_b`, `t:`, `dv:`, `and_n`, `u:`, `l:` on nested fragments |
+| 29-41 | Token-focused | `c:pk_k`, `c:pk_h`, `0`/`1` branches, `n:`/`j:`/`l:`/`u:` on bare `pk`, `hash160`/`ripemd160`/`hash256` |
+| 42-51 | Mixed / multipath | `sh(wsh(...))` combos, `<a;b>/*` multipath descriptors |
+| 52-55 | MuSig | `musig(a,b)`, `musig(a,b,c)`, MuSig + tapscript combinations |
+
+Per-key derivation path is `[00000000/<purpose>'/<BIP44_COIN_TYPE>'/<keyidx>']` with
+the purpose drawn from the template's `purposes[]` array, so each `@N`
+resolves to a distinct xpub.
+
+## Continuation host flow
+
+Several handlers (`sign_psbt`, `register_wallet`, `sign_message`, …) pull
+extra bytes through `os_io_rx_evt()` mid-dispatch. The fuzz harness owns a
+single-host chain that serves those rounds off the pre-built semantic
+state:
+
+```
+handler → os_io_rx_evt (mock/fuzz_os_io_rx_evt.c)
+ → fuzz_continuation_host->handle_ccmd
+ → btc_handle_ccmd (harness/fuzz_dispatcher.c)
+ → mock_dispatcher_handle_ccmd (unit-tests/libs/mock_dispatcher.c)
+ → wraps reply in the 6-byte SEPH envelope → returned to handler
+```
+
+Steps:
+
+1. `fuzz_app_reset` zeros `fuzz_continuation_idx` and deactivates the host
+ so raw-lane iterations never feed continuation replies.
+2. Structured-lane builders call `btc_activate_host()` after populating
+ `g_btc_host` with the preimages and Merkle trees the scenario needs.
+ That host is the app's own unit-test `mock_dispatcher`, not a second
+ implementation: same four client commands, the same Merkle code from
+ `src/common/merkle.c`, and the same `cx_hash_sha256`.
+3. Each `os_io_rx_evt` tick increments `fuzz_continuation_idx`. When the
+ running round matches `btc_fault_target` and `btc_fault_kind` is
+ `CONT_TRUNCATE` or `CONT_FLIP`, `btc_handle_ccmd` mutates that one
+ reply (see the fault knob table above). All other rounds return the
+ protocol-correct reply.
+4. After `FUZZ_MAX_CONTINUATIONS` rounds the mock returns the canonical
+ empty frame, which causes the handler to bail out cleanly instead of
+ spinning forever on a buggy builder.
+
+The SDK fuzzing documentation (<https://ledgerhq.github.io/ledger-secure-sdk/>)
+covers the shared harness and generated-file contract. The continuation-host
+specifics used by Bitcoin are documented in this README and the local mock
+sources.
+
+## Files
+
+| Path | Purpose |
+|---|---|
+| `fuzz-manifest.toml` | Coverage list, dictionary, seed strategy |
+| `CMakeLists.txt` | Build config for the single fuzz harness |
+| `harness/fuzz_dispatcher.c` | `fuzz_commands[]` table, `fuzz_app_reset/dispatch`, synthetic-INS routes, tail-driven chaos window |
+| `unit-tests/libs/mock_dispatcher.c` | Continuation-host replies for Merkle and preimage traffic (shared with the unit tests) |
+| `mock/psbt_model.c` | Structured `SIGN_PSBT` scenarios, early/late faults, tail-driven chaos budget |
+| `mock/wallet_model.c` | Structured wallet registration and address scenarios |
+| `mock/message_model.c` | Structured `SIGN_MESSAGE` scenarios |
+| `mock/fuzz_continuation_host.h` / `mock/fuzz_os_io_rx_evt.c` | Continuation host interface + SEPH envelope mock |
+| `mock/fuzz_varint.h` | Shared varint + little-endian fixed-size writers |
+| `invariants/zero-symbols.txt` | Globals removed from the prefix |
+| `invariants/domain-overrides.txt` | Valid enum and state domains |
+
+## Getting started
+
+Minimal onboarding checklist for editing the harness. Each item points at
+the single source of truth; avoid duplicating that information across
+files.
+
+1. **Add a new APDU command** — register it in
+ `harness/fuzz_dispatcher.c`: extend `fuzz_commands[]` (structured lane)
+ or `btc_raw_commands[]` (raw lane), then add a `command_descriptor_t`
+ entry in `FUZZ_COMMAND_DESCRIPTORS` and a dedicated `build_*_payload`
+ plus a case in `build_structured_payload()`. Keep synthetic INS values
+ above `0xF0` so they never collide with app INSes.
+2. **Add a new PSBT builder fault** — extend the `BTC_FAULT_*`
+ definitions in `mock/mocks.h`, then add the apply branch to
+ `pm_apply_pre_tree_fault` (if it must propagate through Merkle
+ preimages) or `pm_apply_post_wallet_fault` (if it only lives
+ in the APDU header). Every kind must be target-visible in bytes the
+ app actually consumes.
+3. **Add a new descriptor template** — append one row to the `TEMPLATES`
+ array in `mock/wallet_model.c` (ordering is append-only). Pick the
+ right group (see the table above) and fill in the correct `n_keys` /
+ `purposes[]` so `build_key_info` derives distinct xpubs.
+4. **Adjust structured mutation pressure** — tune `btc_field_aware_tweak`
+ in `harness/fuzz_dispatcher.c` and keep the README's slot/fault tables
+ in sync when you add new targeted operations.
+5. **Adjust fuzz-only repairs** — keep repairs gated with
+ `#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION` and document any new one in
+ the source site that applies it.
+6. **Promote corpus inputs** — re-promote after any change that alters the
+ Absolution prefix layout (new globals, SDK version bump, harness version
+ bump) or after significant harness changes that open new code paths.
+ Run a campaign, then pack the merged corpus with
+ `$BOLOS_SDK/fuzzing/scripts/corpus.py promote <corpus-dir>
+ fuzzing/base-corpus.zip`. The zip and its `.compat-key` sidecar are
+ auto-loaded by `app-campaign.sh` when compatible with the current build.
+7. **Change tracked coverage files** — edit
+ `fuzz-manifest.toml`'s `[coverage].key_files` array; the campaign
+ script re-runs `llvm-cov show` against that list.
+8. **Add / remove an Absolution-driven global** — declare it in `mock/mocks.h`
+ and constrain it in `invariants/zero-symbols.txt` or
+ `invariants/domain-overrides.txt`. There is no layout to update: the
+ campaign rediscovers the prefix and passes its size to the build, and
+ nothing in the app knows where a global sits inside it.
### fuzzing/base-corpus.compat-key
@@ -0,0 +1 @@
+37576bc4e65fe733e28596b0a15b60f499cde259757998d0f78bf6415b6980bb
### fuzzing/base-corpus.zip
[binary or diff unavailable]
### fuzzing/fuzz-manifest.toml
@@ -0,0 +1,335 @@
+# Bitcoin fuzz manifest.
+#
+# Bitcoin uses a custom harness and a custom seed generator. The promoted base
+# corpus lives in fuzzing/base-corpus.zip (with a fuzzing/base-corpus.compat-key
+# sidecar) and is loaded automatically by the SDK scripts when compatible.
+
+[target]
+fuzzer = "fuzz_app"
+harness_version = "6"
+
+# Harness input bytes above the Absolution prefix that this harness actually reads:
+# 4 control (lane, command, P1, P2)
+# + 16 app header (psbt_entropy)
+# + 32 * 64 scenario slots
+# + 4 fault knobs (FUZZ_TAIL_FAULT_SIZE, read from the last 4 bytes)
+# Anything beyond this is never read, so mutating it cannot change behaviour.
+#
+# The slot ceiling is 32, not FUZZ_TAIL_N_SLOTS (16): message_model reads one slot
+# per message chunk up to MSG_MAX_CHUNKS = 32, i.e. slots 16..31 that the declared
+# slot count does not cover. That inconsistency is real and tracked separately (the
+# field-aware mutator cannot reach those slots either); the budget must cover what is
+# actually read, so it is sized on 32.
+tail_budget = 2072
+
+[coverage]
+key_files = [
+ "src/handler/sign_psbt.c",
+ "src/handler/lib/policy.c",
+ "src/handler/lib/psbt_parse_rawtx.c",
+ "src/common/wallet.c",
+ "src/handler/get_wallet_address.c",
+ "src/handler/sign_psbt/txhashes.c",
+ "src/common/segwit_addr.c",
+ "src/swap/handle_check_address.c",
+ "src/swap/handle_swap_sign_transaction.c",
+ "src/handler/register_wallet.c",
+ "src/handler/sign_message.c",
+ "src/handler/sign_psbt/amount_from_psbt.c",
+ "src/handler/sign_psbt/extract_bip32_derivation.c",
+ "src/common/script.c",
+ "src/handler/sign_psbt/musig_signing.c",
+ "src/musig/musig.c",
+ "src/handler/get_extended_pubkey.c",
+ "src/handler/get_master_fingerprint.c",
+ "src/handler/lib/stream_merkleized_map_value.c",
+ "src/musig/musig_sessions.c",
+]
+exclude_regexes = [
+ '.*ledger-secure-sdk.*',
+ '.*fuzz_dispatcher\.c',
+ '.*fuzzer\.c',
+ '.*fuzzing/mock/.*',
+ '.*src/main\.c',
+ '.*src/ui/menu_nbgl\.c',
+ '.*src/ui/display_nbgl\.c',
+ '.*src/boilerplate/io\.c',
+ '.*src/common/format\.c',
+]
+
+[dictionary]
+# libFuzzer magic-byte tokens, grouped by domain. Tokens are zero-cost (they
+# do not grow the prefix) and only help when instrumentation sees a near-match.
+tokens = [
+ # ── APDU CLAs/INSs ───────────────────────────────────────────────────
+ { name = "cla_app", value = "\\xE1" },
+ { name = "get_extended_pubkey", value = "\\xE1\\x00\\x00\\x01" },
+ { name = "register_wallet", value = "\\xE1\\x02\\x00\\x01" },
+ { name = "get_wallet_address", value = "\\xE1\\x03\\x00\\x01" },
+ { name = "sign_psbt", value = "\\xE1\\x04\\x00\\x01" },
+ { name = "get_master_fingerprint", value = "\\xE1\\x05\\x00\\x01" },
+ { name = "sign_message", value = "\\xE1\\x10\\x00\\x01" },
+
+ # ── Wallet-policy descriptors (top-level) ────────────────────────────
+ { name = "descriptor_wpkh", value = "wpkh(@0/**)" },
+ { name = "descriptor_tr", value = "tr(@0/**)" },
+ { name = "descriptor_sh_wpkh", value = "sh(wpkh(@0/**))" },
+ { name = "descriptor_sh", value = "sh(" },
+ { name = "descriptor_wsh", value = "wsh(" },
+ { name = "descriptor_wsh_multi", value = "wsh(multi(" },
+ { name = "descriptor_musig", value = "musig(" },
+ { name = "descriptor_sortedmulti", value = "sortedmulti(" },
+ { name = "descriptor_sortedmulti_a", value = "sortedmulti_a(" },
+ { name = "descriptor_multi", value = "multi(" },
+ { name = "descriptor_multi_a", value = "multi_a(" },
+ { name = "descriptor_thresh", value = "thresh(" },
+ { name = "descriptor_after", value = "after(" },
+ { name = "descriptor_older", value = "older(" },
+ { name = "descriptor_sha256", value = "sha256(" },
+ { name = "descriptor_hash256", value = "hash256(" },
+ { name = "descriptor_ripemd160", value = "ripemd160(" },
+ { name = "descriptor_hash160", value = "hash160(" },
+ { name = "descriptor_pk", value = "pk(" },
+ { name = "descriptor_pkh", value = "pkh(" },
+ { name = "descriptor_pk_k", value = "pk_k(" },
+ { name = "descriptor_pk_h", value = "pk_h(" },
+ { name = "descriptor_and_v", value = "and_v(" },
+ { name = "descriptor_and_b", value = "and_b(" },
+ { name = "descriptor_and_n", value = "and_n(" },
+ { name = "descriptor_or_b", value = "or_b(" },
+ { name = "descriptor_or_c", value = "or_c(" },
+ { name = "descriptor_or_d", value = "or_d(" },
+ { name = "descriptor_or_i", value = "or_i(" },
+ { name = "descriptor_andor", value = "andor(" },
+
+ # ── Miniscript wrappers (single-char prefixes) ───────────────────────
+ # wallet.c parse_wrappers() matches these one char at a time, then `:`.
+ { name = "wrap_a", value = "a:" },
+ { name = "wrap_s", value = "s:" },
+ { name = "wrap_c", value = "c:" },
+ { name = "wrap_t", value = "t:" },
+ { name = "wrap_d", value = "d:" },
+ { name = "wrap_v", value = "v:" },
+ { name = "wrap_j", value = "j:" },
+ { name = "wrap_n", value = "n:" },
+ { name = "wrap_l", value = "l:" },
+ { name = "wrap_u", value = "u:" },
+ { name = "wrap_vc", value = "vc:" },
+ { name = "wrap_sc", value = "sc:" },
+ { name = "wrap_tv", value = "tv:" },
+ { name = "wrap_dv", value = "dv:" },
+ { name = "wrap_jc", value = "jc:" },
+ { name = "wrap_sj", value = "sj:" },
+ { name = "wrap_vdv", value = "vdv:" },
+
+ # ── Miniscript leaf numerals (no-paren) ──────────────────────────────
+ # `0` and `1` are valid no-paren miniscript fragments that reach
+ # compute_miniscript_policy_ext_info via l:/u: wrappers.
+ { name = "ms_zero", value = "0)" },
+ { name = "ms_one", value = "1)" },
+ { name = "wrap_l_zero", value = "l:0" },
+ { name = "wrap_u_zero", value = "u:0" },
+
+ # ── Common miniscript sub-expressions ────────────────────────────────
+ # Full sub-expressions that mutators can splice in to reach
+ # is_miniscript_sane (requires wsh(...) wrapper around them).
+ { name = "ms_and_v_vpk_pk", value = "and_v(v:pk(@0),pk(@1))" },
+ { name = "ms_or_b_pk_spk", value = "or_b(pk(@0),s:pk(@1))" },
+ { name = "ms_or_i_pk_pk", value = "or_i(pk(@0),pk(@1))" },
+ { name = "ms_andor_pk_pk_pk", value = "andor(pk(@0),pk(@1),pk(@2))" },
+ { name = "ms_thresh", value = "thresh(2,pk(@0),s:pk(@1),s:pk(@2))" },
+ { name = "ms_older_v_pk", value = "and_v(v:older(10),pk(@0))" },
+ { name = "ms_after_v_pk", value = "and_v(v:after(100),pk(@0))" },
+ { name = "ms_sha256", value = "and_v(v:sha256(e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855),pk(@0))" },
+
+ # ── Key expressions & BIP32 paths ────────────────────────────────────
+ { name = "key_ref_0", value = "@0" },
+ { name = "key_ref_1", value = "@1" },
+ { name = "key_ref_2", value = "@2" },
+ { name = "key_ref_3", value = "@3" },
+ { name = "key_ref_4", value = "@4" },
+ { name = "wildcard", value = "/**" },
+ { name = "bip32_separator", value = "/" },
+ { name = "bip32_hardened", value = "'" },
+ { name = "bip32_hardened_h", value = "h" },
+ { name = "bip32_step_0", value = "/0" },
+ { name = "bip32_step_1", value = "/1" },
+ { name = "bip32_step_2", value = "/2" },
+ { name = "bip32_step_0h", value = "/0h" },
+ { name = "bip32_step_1h", value = "/1h" },
+ { name = "path_purpose_44h", value = "44'" },
+ { name = "path_purpose_48h", value = "48'" },
+ { name = "path_purpose_49h", value = "49'" },
+ { name = "path_purpose_84h", value = "84'" },
+ { name = "path_purpose_86h", value = "86'" },
+ { name = "xpub_tb_prefix", value = "tpub" },
+ { name = "xpub_bc_prefix", value = "xpub" },
+ { name = "key_origin_start", value = "[" },
+ { name = "key_origin_end", value = "]" },
+
+ # ── Wallet-name charset sentinels ────────────────────────────────────
+ # Boundary bytes for is_policy_name_acceptable (edge spaces, <0x20, >0x7E).
+ { name = "name_space", value = " " },
+ { name = "name_leading_space", value = " A" },
+ { name = "name_trailing_space", value = "A " },
+ { name = "name_ctrl_byte", value = "\\x1F" },
+ { name = "name_tilde", value = "~" },
+
+ # ── Varints & TLV length escapes ─────────────────────────────────────
+ # Only the boundary/multi-byte escapes matter; small varints 1-4 are
+ # mutation-trivial and already present as PSBT keytype bytes.
+ { name = "varint_fc_boundary", value = "\\xFC" },
+ { name = "varint_fd", value = "\\xFD" },
+ { name = "varint_fe", value = "\\xFE" },
+ { name = "varint_ff", value = "\\xFF" },
+
+ # ── PSBT framing ─────────────────────────────────────────────────────
+ { name = "psbt_magic", value = "psbt\\xff" },
+ { name = "psbt_separator", value = "\\x00" },
+ { name = "psbt_global_xpub", value = "\\x01" },
+ { name = "psbt_global_unsigned_tx", value = "\\x00" },
+ { name = "psbt_global_tx_version", value = "\\x02" },
+ { name = "psbt_global_input_count", value = "\\x04" },
+ { name = "psbt_global_output_count", value = "\\x05" },
+ { name = "psbt_global_tx_modifiable", value = "\\x06" },
+ { name = "psbt_global_version", value = "\\xFB" },
+ { name = "psbt_in_non_witness_utxo", value = "\\x00" },
+ { name = "witness_utxo", value = "\\x01" },
+ { name = "psbt_in_partial_sig", value = "\\x02" },
+ { name = "psbt_in_sighash", value = "\\x03" },
+ { name = "psbt_in_redeem_script", value = "\\x04" },
+ { name = "psbt_in_witness_script", value = "\\x05" },
+ { name = "bip32_derivation", value = "\\x06" },
+ { name = "previous_txid", value = "\\x0E" },
+ { name = "output_index", value = "\\x0F" },
+ { name = "sequence", value = "\\x10" },
+ { name = "psbt_in_req_time_locktime", value = "\\x11" },
+ { name = "psbt_in_req_height_locktime", value = "\\x12" },
+ { name = "tap_key_sig", value = "\\x13" },
+ { name = "tap_script_sig", value = "\\x14" },
+ { name = "tap_leaf_script", value = "\\x15" },
+ { name = "tap_bip32_derivation", value = "\\x16" },
+ { name = "tap_internal_key", value = "\\x17" },
+ { name = "tap_merkle_root", value = "\\x18" },
+ { name = "musig2_participants", value = "\\x1A" },
+ { name = "musig2_pub_nonce", value = "\\x1B" },
+ { name = "musig2_partial_sig", value = "\\x1C" },
+ { name = "psbt_proprietary", value = "\\xFC" },
+ { name = "psbt_out_redeem_script", value = "\\x00" },
+ { name = "psbt_out_witness_script", value = "\\x01" },
+ { name = "psbt_out_bip32_derivation", value = "\\x02" },
+ { name = "psbt_out_amount", value = "\\x03" },
+ { name = "psbt_out_script", value = "\\x04" },
+ { name = "psbt_out_tap_internal_key", value = "\\x05" },
+ { name = "psbt_out_tap_tree", value = "\\x06" },
+ { name = "psbt_out_tap_bip32", value = "\\x07" },
+
+ # ── SIGHASH flags ────────────────────────────────────────────────────
+ # execute_swap_checks() and txhashes use sighash bytes for branching.
+ { name = "sighash_all", value = "\\x01" },
+ { name = "sighash_none", value = "\\x02" },
+ { name = "sighash_single", value = "\\x03" },
+ { name = "sighash_anyonecanpay_all", value = "\\x81" },
+ { name = "sighash_anyonecanpay_none", value = "\\x82" },
+ { name = "sighash_anyonecanpay_single", value = "\\x83" },
+ { name = "sighash_default_tap", value = "\\x00" },
+
+ # ── Bitcoin script opcodes ───────────────────────────────────────────
+ # script.c recognizes several opcodes when computing descriptors.
+ { name = "op_0", value = "\\x00" },
+ { name = "op_pushdata1", value = "\\x4C" },
+ { name = "op_pushdata2", value = "\\x4D" },
+ { name = "op_pushdata4", value = "\\x4E" },
+ { name = "op_1negate", value = "\\x4F" },
+ { name = "op_1", value = "\\x51" },
+ { name = "op_2", value = "\\x52" },
+ { name = "op_3", value = "\\x53" },
+ { name = "op_16", value = "\\x60" },
+ { name = "op_nop", value = "\\x61" },
+ { name = "op_return", value = "\\x6A" },
+ { name = "op_drop", value = "\\x75" },
+ { name = "op_dup", value = "\\x76" },
+ { name = "op_equal", value = "\\x87" },
+ { name = "op_equalverify", value = "\\x88" },
+ { name = "op_ripemd160", value = "\\xA6" },
+ { name = "op_sha1", value = "\\xA7" },
+ { name = "op_sha256", value = "\\xA8" },
+ { name = "op_hash160", value = "\\xA9" },
+ { name = "op_hash256", value = "\\xAA" },
+ { name = "op_checksig", value = "\\xAC" },
+ { name = "op_checksigverify", value = "\\xAD" },
+ { name = "op_checkmultisig", value = "\\xAE" },
+ { name = "op_checkmultisigverify", value = "\\xAF" },
+ { name = "op_checklocktimeverify", value = "\\xB1" },
+ { name = "op_checksequenceverify", value = "\\xB2" },
+
+ # ── Script push lengths ──────────────────────────────────────────────
+ { name = "push20_p2pkh", value = "\\x14" },
+ { name = "push32_p2wsh", value = "\\x20" },
+ { name = "push33_pubkey_compressed", value = "\\x21" },
+ { name = "push65_pubkey_uncompressed", value = "\\x41" },
+
+ # ── P2TR / witness version prefixes ──────────────────────────────────
+ # Common 2-byte witness program prefixes that head witness scripts.
+ { name = "witver_p2wpkh", value = "\\x00\\x14" },
+ { name = "witver_p2wsh", value = "\\x00\\x20" },
+ { name = "witver_p2tr", value = "\\x51\\x20" },
+ { name = "taproot_leaf_version", value = "\\xC0" },
+
+ # ── P2PKH/P2SH standard wrapper prefixes (COIN_* in handle_check_*) ──
+ { name = "script_p2pkh_prefix", value = "\\x76\\xA9\\x14" },
+ { name = "script_p2pkh_suffix", value = "\\x88\\xAC" },
+ { name = "script_p2sh_prefix", value = "\\xA9\\x14" },
+ { name = "script_p2sh_suffix", value = "\\x87" },
+
+ # ── Bech32/Bech32m HRPs (segwit_addr.c) ──────────────────────────────
+ # Fuzz build uses testnet "tb"; "bc"/"bcrt" exercise the HRP predicate.
+ { name = "bech32_hrp_tb", value = "tb" },
+ { name = "bech32_hrp_bc", value = "bc" },
+ { name = "bech32_hrp_bcrt", value = "bcrt" },
+ { name = "bech32_separator", value = "1" },
+ { name = "bech32_addr_tb_p2wpkh", value = "tb1q" },
+ { name = "bech32_addr_tb_p2tr", value = "tb1p" },
+ { name = "bech32_addr_bc_p2wpkh", value = "bc1q" },
+ { name = "bech32_addr_bc_p2tr", value = "bc1p" },
+ { name = "bech32_addr_bcrt_p2wpkh", value = "bcrt1q" },
+ { name = "bech32_charset_prefix", value = "qpzry9x8" },
+
+ # ── Swap APDU payload tokens (handle_check_address) ──────────────────
+ # P2 byte selects format: P2PKH=0x00, P2SH=0x01, P2WPKH=0x02,
+ # P2WSH=0x03, TAPROOT=0x04.
+ { name = "swap_p2_p2pkh", value = "\\x00" },
+ { name = "swap_p2_p2sh", value = "\\x01" },
+ { name = "swap_p2_p2wpkh", value = "\\x02" },
+ { name = "swap_p2_p2wsh", value = "\\x03" },
+ { name = "swap_p2_taproot", value = "\\x04" },
+
+ # ── sign_message framing (BIP-137) ───────────────────────────────────
+ # Note: newline encoded as \x0A because libFuzzer's dictionary parser
+ # does not support C-style escapes other than \", \\, and \xHH.
+ { name = "magic_bitcoin_signed_message", value = "Bitcoin Signed Message:\\x0A" },
+]
+
+[seeds]
+ins = [0x00, 0x02, 0x03, 0x04, 0x05, 0x10]
+
+[seeds.generic]
+enabled = false
+
+[seeds.custom]
+# These seeds are the floor under base-corpus.zip, not an addition to it. The compat key
+# rejects the corpus on any prefix-size, invariant or harness-version change, and from a
+# bare start it takes over 400 000 executions to reach psbt_parse_rawtx.c by chance --
+# measured at 0.00%. Measured, 300s cold campaigns (noise floor 0.60pp):
+#
+# corpus only (2654 inputs) 55.62%
+# corpus + seeds (2684) 55.18% -- inside the noise floor
+# seeds only (30) 54.03%
+#
+# They cost nothing measurable while the corpus is valid, and carry the run when it is
+# not, so they stay on rather than being toggled around corpus promotions.
+enabled = true
+script = "scripts/generate-seed-corpus.py"
+
+[mocks]
+override_sources = []
### fuzzing/harness/fuzz_dispatcher.c
@@ -0,0 +1,869 @@
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/types.h>
+
+#include "commands.h"
+#include "constants.h"
+#include "crypto.h"
+#include "dispatcher.h"
+#include "fuzz_bip32.h"
+#include "os_utils.h" // U4LE_ENCODE
+#include "fuzz_continuation_host.h"
+#include "handle_swap_sign_transaction.h"
+#include "handlers.h"
+#include "menu.h"
+#include "message_model.h"
+#include "mocks.h"
+#include "policy.h"
+#include "psbt_model.h"
+#include "mock_dispatcher.h"
+#include "swap_entrypoints.h"
+#include "swap_globals.h"
+#include "swap_utils.h"
+#include "wallet_model.h"
+#include "write.h"
+
+extern uint16_t G_output_len;
+
+extern uint8_t fuzz_mock_ui_reject;
+extern uint8_t*
+ ___src_swap_handle_swap_sign_transaction_c_G_swap_sign_return_value_address;
+
+/* Synthetic INS sentinels for swap-library entry points that do not go
+ * through apdu_dispatcher(). Chosen to not collide with any real Bitcoin
+ * INS (0x00/0x02/0x03/0x04/0x05/0x10). */
+#define FUZZ_INS_SWAP_CHECK 0xF1
+#define FUZZ_INS_SWAP_HELPERS 0xF2
+
+/* Fault knobs read from the last 4 bytes of the fuzz tail each iteration. */
+uint8_t btc_fault_kind = BTC_FAULT_CLEAN;
+uint8_t btc_fault_target = 0;
+uint8_t btc_fault_param[2] = {0, 0};
+
+/* Raw-lane command map. The raw lane carries no builder payload, so it is the right
+ * place for the specs that exercise the dispatcher's own front door: apdu_dispatcher()
+ * rejects an unknown CLA, an unknown INS, an unexpected INS_CONTINUE and an
+ * out-of-range P2 before any handler runs, and every one of those branches was
+ * unreachable while both command tables held nothing but valid CLA_APP commands. */
+static const fuzz_command_spec_t btc_raw_commands[6] = {
+ /* p2_max above CURRENT_PROTOCOL_VERSION so dispatcher.c's SW_WRONG_P1P2 is
+ * reachable; fuzz_clamp_p otherwise clamps P2 into the accepted range. */
+ {.cla = CLA_APP,
+ .ins = GET_EXTENDED_PUBKEY,
+ .p2_max = CURRENT_PROTOCOL_VERSION + 1,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = GET_MASTER_FINGERPRINT,
+ .p2_max = CURRENT_PROTOCOL_VERSION},
+ /* Unknown INS under a valid CLA -> SW_INS_NOT_SUPPORTED. */
+ {.cla = CLA_APP, .ins = 0x99, .flags = FUZZ_CMD_HAS_DATA},
+ /* Unknown CLA -> SW_CLA_NOT_SUPPORTED. */
+ {.cla = 0xE0, .ins = SIGN_PSBT, .flags = FUZZ_CMD_HAS_DATA},
+ /* INS_CONTINUE with no interrupted command in flight -> SW_BAD_STATE. */
+ {.cla = CLA_FRAMEWORK, .ins = INS_CONTINUE, .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = GET_EXTENDED_PUBKEY,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+};
+static const size_t btc_raw_n_commands =
+ sizeof(btc_raw_commands) / sizeof(btc_raw_commands[0]);
+
+/* The 16 bytes after the framework control bytes are this app's builder entropy
+ * (psbt_entropy), so the payload the builders see starts after them. */
+#define FUZZ_APP_HEADER_LEN PSBT_ENTROPY_SIZE
+
+/* This app adds slot-0 scenario-control mutation on top of the framework mutator. */
+#define FUZZ_APP_CUSTOM_MUTATOR
+
+/* The two lanes drive structurally different entry paths, so each gets its own
+ * command table. The command byte is data[1] (see fuzz_defs.h). */
+#define FUZZ_PICK_COMMAND_RAW(data, size) \
+ (&btc_raw_commands[(data)[1] % btc_raw_n_commands])
+
+/* This app copies its entropy header out of the input before dispatching. */
+#define FUZZ_APP_CUSTOM_ENTRY
+
+#include "fuzz_harness.h"
+
+/* Structured-lane command map (16 slots): slot count == weight. */
+const fuzz_command_spec_t fuzz_commands[16] = {
+ /* SIGN_PSBT x 7 (43.75%) */
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ /* GET_EXTENDED_PUBKEY x 1 (6.25%) — also picked in the raw lane; a
+ * structured slot drives the display/derivation path more often. */
+ {.cla = CLA_APP,
+ .ins = GET_EXTENDED_PUBKEY,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ /* REGISTER_WALLET x 2 (12.5%) */
+ {.cla = CLA_APP,
+ .ins = REGISTER_WALLET,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = REGISTER_WALLET,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ /* GET_WALLET_ADDRESS x 2 (12.5%) */
+ {.cla = CLA_APP,
+ .ins = GET_WALLET_ADDRESS,
+ .p1_max = 1,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = GET_WALLET_ADDRESS,
+ .p1_max = 1,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ /* SIGN_MESSAGE x 2 (12.5%) */
+ {.cla = CLA_APP,
+ .ins = SIGN_MESSAGE,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ {.cla = CLA_APP,
+ .ins = SIGN_MESSAGE,
+ .p2_max = CURRENT_PROTOCOL_VERSION,
+ .flags = FUZZ_CMD_HAS_DATA},
+ /* FUZZ_INS_SWAP_CHECK x 1 (6.25%) */
+ {.cla = CLA_APP, .ins = FUZZ_INS_SWAP_CHECK, .flags = FUZZ_CMD_HAS_DATA},
+ /* FUZZ_INS_SWAP_HELPERS x 1 (6.25%) */
+ {.cla = CLA_APP, .ins = FUZZ_INS_SWAP_HELPERS, .flags = FUZZ_CMD_HAS_DATA},
+};
+FUZZ_COMMAND_COUNT();
+
+static const command_descriptor_t FUZZ_COMMAND_DESCRIPTORS[] = {
+ {.cla = CLA_APP,
+ .ins = GET_EXTENDED_PUBKEY,
+ .handler = (command_handler_t)handler_get_extended_pubkey},
+ {.cla = CLA_APP,
+ .ins = GET_WALLET_ADDRESS,
+ .handler = (command_handler_t)handler_get_wallet_address},
+ {.cla = CLA_APP,
+ .ins = REGISTER_WALLET,
+ .handler = (command_handler_t)handler_register_wallet},
+ {.cla = CLA_APP,
+ .ins = SIGN_PSBT,
+ .handler = (command_handler_t)handler_sign_psbt},
+ {.cla = CLA_APP,
+ .ins = GET_MASTER_FINGERPRINT,
+ .handler = (command_handler_t)handler_get_master_fingerprint},
+ {.cla = CLA_APP,
+ .ins = SIGN_MESSAGE,
+ .handler = (command_handler_t)handler_sign_message},
+};
+
+bool get_address_from_compressed_public_key(
+ unsigned char format, unsigned char* compressed_pub_key,
+ unsigned short payToAddressVersion, unsigned short payToScriptHashVersion,
+ const char* native_segwit_prefix, char* address,
+ unsigned char max_address_length);
+
+static size_t build_serialized_swap_path(uint32_t purpose, uint32_t change,
+ uint32_t index, uint8_t* out,
+ size_t out_len,
+ uint32_t words[static 5]) {
+ if (out_len < 1 + 5 * sizeof(uint32_t)) return 0;
+
+ words[0] = 0x80000000UL | purpose;
+ words[1] = 0x80000000UL | 1UL;
+ words[2] = 0x80000000UL;
+ words[3] = change;
+ words[4] = index;
+
+ out[0] = 5;
+ for (size_t i = 0; i < 5; i++)
+ write_u32_be(out + 1 + (i * sizeof(uint32_t)), 0, words[i]);
+ return 1 + 5 * sizeof(uint32_t);
+}
+
+static int run_swap_check_address(const uint8_t* data, size_t size) {
+ uint8_t address_parameters[1 + 1 + 5 * sizeof(uint32_t)];
+ uint32_t path_words[5];
+ unsigned char compressed_public_key[33];
+ check_address_parameters_t params;
+ char address[MAX_ADDRESS_LENGTH_STR + 1];
+ unsigned char format;
+ uint32_t purpose, change, index;
+ size_t path_len;
+
+ switch ((psbt_entropy[8] + (size > 0 ? data[0] : 0)) % 4) {
+ case 0:
+ format = 0x00;
+ purpose = 44;
+ break;
+ case 1:
+ format = 0x01;
+ purpose = 49;
+ break;
+ case 2:
+ format = 0x02;
+ purpose = 84;
+ break;
+ default:
+ format = 0x04;
+ purpose = 86;
+ break;
+ }
+
+ change = psbt_entropy[11] & 1U;
+ index = psbt_entropy[12] % 32U;
+ path_len = build_serialized_swap_path(
+ purpose, change, index, address_parameters + 1,
+ sizeof(address_parameters) - 1, path_words);
+ if (path_len == 0) return 0;
+
+ if (CX_OK != crypto_get_compressed_pubkey_at_path(
+ path_words, sizeof(path_words) / sizeof(path_words[0]),
+ compressed_public_key, NULL))
+ return 0;
+
+ if (!get_address_from_compressed_public_key(
+ format, compressed_public_key, COIN_P2PKH_VERSION,
+ COIN_P2SH_VERSION, COIN_NATIVE_SEGWIT_PREFIX, address,
+ sizeof(address)))
+ return 0;
+
+ memset(¶ms, 0, sizeof(params));
+ address_parameters[0] = format;
+ params.address_parameters = address_parameters;
+ params.address_parameters_length = (uint8_t)(1 + path_len);
+
+ uint8_t check_variant = size > 1 ? data[1] : 0;
+ if (check_variant % 8 == 0) {
+ params.address_to_check = NULL;
+ } else if (check_variant % 8 == 1 && address[0] != '\0') {
+ address[0] ^= 0x42;
+ params.address_to_check = address;
+ } else {
+ params.address_to_check = address;
+ }
+
+ (void)swap_handle_check_address(¶ms);
+ return 0;
+}
+
+static int run_swap_helpers(const uint8_t* data, size_t size) {
+ uint8_t amount[8] = {0};
+ uint8_t fees[8] = {0};
+ uint8_t extra_id[33] = {0};
+ get_printable_amount_parameters_t printable;
+ create_transaction_parameters_t tx_params;
+ char destination_address[] = "tb1qfuzzqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
+
+ memset(&printable, 0, sizeof(printable));
+ memset(&tx_params, 0, sizeof(tx_params));
+
+ amount[7] = size > 0 ? data[0] : 0x2A;
+ amount[6] = psbt_entropy[13];
+ fees[7] = size > 1 ? data[1] : 0x05;
+
+ size_t amount_len = 1 + (psbt_entropy[14] % sizeof(amount));
+ size_t fee_len = 1 + (psbt_entropy[15] % sizeof(fees));
+
+ printable.amount = amount + (sizeof(amount) - amount_len);
+ printable.amount_length = (uint8_t)amount_len;
+ printable.is_fee = (psbt_entropy[10] & 1) != 0;
+ (void)swap_handle_get_printable_amount(&printable);
+
+ {
+ uint8_t raw_id = size > 2 ? data[2] : psbt_entropy[9];
+ if (raw_id % 4 == 0) {
+ extra_id[0] = 2; /* SWAP_MODE_CROSSCHAIN */
+ } else if (raw_id % 4 == 3) {
+ extra_id[0] = 1; /* triggers SWAP_MODE_ERROR */
+ } else {
+ extra_id[0] = 0; /* SWAP_MODE_STANDARD */
+ }
+ }
+ tx_params.amount = amount + (sizeof(amount) - amount_len);
+ tx_params.amount_length = (uint8_t)amount_len;
+ tx_params.fee_amount = fees + (sizeof(fees) - fee_len);
+ tx_params.fee_amount_length = (uint8_t)fee_len;
+ tx_params.destination_address = destination_address;
+ tx_params.destination_address_extra_id = (char*)extra_id;
+ (void)swap_copy_transaction_parameters(&tx_params);
+ return 0;
+}
+
+/* ── Field-aware mutator for structured PSBT tail slots ─────────── */
+
+/* Slot 0 is reserved scenario-control space in all three builders; everything from
+ * slot 1 on is tape content. So an app-specific operator is only worth having when it
+ * targets a slot-0 field that a builder actually reads. These are all of them:
+ *
+ * 32..39 GET_WALLET_ADDRESS (wallet_model.c: WM_ADDR_*)
+ * 52..63 SIGN_PSBT (psbt_model.c: PM_SLOT0_*)
+ *
+ * Offsets 0..31 and 40..51 have no reader, so an operator writing a constant there
+ * splices it into the middle of a count|klen|key|vlen|value stream at a position no
+ * decoder visits as a boundary. libFuzzer's own InsertByte/EraseBytes/CopyPart do
+ * that better, because they re-frame every entry downstream. */
+enum {
+ BTC_S0_ADDR_DISPLAY_OFF = 32,
+ BTC_S0_ADDR_IS_CHANGE_OFF = 33,
+ BTC_S0_ADDR_INDEX_OFF = 34,
+ BTC_S0_ADDR_USE_REGISTERED_OFF = 38,
+ BTC_S0_ADDR_FLIP_HMAC_OFF = 39,
+ BTC_S0_TX_VERSION_OFF = 52,
+ BTC_S0_LOCKTIME_OFF = 56,
+ BTC_S0_N_INPUTS_OFF = 60,
+ BTC_S0_N_OUTPUTS_OFF = 61,
+ BTC_S0_SUBTYPE_OFF = 62,
+ BTC_S0_DESC_SEED_OFF = 63,
+};
+
+enum {
+ BTC_GET_XPUB_DISPLAY_OFF = 0,
+ BTC_GET_XPUB_PATH_SEED_OFF = 1,
+ BTC_GET_XPUB_FALLBACK_CTRL_OFF = 8,
+ BTC_GET_XPUB_FALLBACK_DISPLAY_OFF = 11,
+};
+
+
+
+/* The hardened boundary at 0x80000000 is the one that matters:
+ * is_path_safe_for_pubkey_export() rejects a hardened address index. */
+static const uint32_t SPECIAL_ADDRESS_INDICES[] = {
+ 0, 1, 0x7FFFFFFFUL, 0x80000000UL, 0xFFFFFFFFUL,
+};
+
+static const uint32_t SPECIAL_TX_VERSIONS[] = {
+ 0, 1, 2, 3, 0xFFFFFFFFUL,
+};
+
+static const uint32_t SPECIAL_LOCKTIMES[] = {
+ 0, 1, 500000000UL, 840000UL, 0xFFFFFFFFUL,
+};
+
+
+/* Builder slots start at fuzz_tail_ptr[0] — that is, after the framework
+ * control bytes and this app's entropy header. The mutator works in
+ * whole-input coordinates, so it has to skip both to land on slot 0 where the
+ * seed generator and the builders expect it. */
+#define BTC_TAIL_OFF ((size_t) FUZZ_CTRL_LEN + FUZZ_APP_HEADER_LEN)
+
+static size_t btc_structured_slot_count(size_t size, size_t prefix_size) {
+ size_t base = prefix_size + BTC_TAIL_OFF;
+ if (size <= base + FUZZ_TAIL_SLOT_SIZE) {
+ return 0;
+ }
+
+ size_t n_full_slots = (size - base) / FUZZ_TAIL_SLOT_SIZE;
+ if (n_full_slots > FUZZ_TAIL_N_SLOTS) {
+ n_full_slots = FUZZ_TAIL_N_SLOTS;
+ }
+ return n_full_slots;
+}
+
+static uint8_t *btc_mut_slot(uint8_t *data, size_t prefix_size, size_t slot_idx) {
+ return data + prefix_size + BTC_TAIL_OFF + slot_idx * FUZZ_TAIL_SLOT_SIZE;
+}
+
+static uint8_t *btc_mut_fault_region(uint8_t *data, size_t size) {
+ return data + size - FUZZ_TAIL_FAULT_SIZE;
+}
+
+
+
+
+static uint32_t btc_pick_special_address_index(unsigned int seed) {
+ return SPECIAL_ADDRESS_INDICES[(seed >> 12) %
+ (sizeof(SPECIAL_ADDRESS_INDICES) / sizeof(SPECIAL_ADDRESS_INDICES[0]))];
+}
+
+static uint32_t btc_pick_special_tx_version(unsigned int seed) {
+ return SPECIAL_TX_VERSIONS[(seed >> 12) %
+ (sizeof(SPECIAL_TX_VERSIONS) / sizeof(SPECIAL_TX_VERSIONS[0]))];
+}
+
+static uint32_t btc_pick_special_locktime(unsigned int seed) {
+ return SPECIAL_LOCKTIMES[(seed >> 16) %
+ (sizeof(SPECIAL_LOCKTIMES) / sizeof(SPECIAL_LOCKTIMES[0]))];
+}
+
+static void btc_swap_slots(uint8_t *lhs, uint8_t *rhs) {
+ uint8_t tmp[FUZZ_TAIL_SLOT_SIZE];
+
+ memcpy(tmp, lhs, FUZZ_TAIL_SLOT_SIZE);
+ memcpy(lhs, rhs, FUZZ_TAIL_SLOT_SIZE);
+ memcpy(rhs, tmp, FUZZ_TAIL_SLOT_SIZE);
+}
+
+static void btc_field_aware_tweak(uint8_t *data, size_t size,
+ size_t prefix_size, unsigned int seed) {
+ size_t n_full_slots = btc_structured_slot_count(size, prefix_size);
+ if (n_full_slots == 0) return;
+
+ size_t slot_idx = seed % n_full_slots;
+ uint8_t *slot = btc_mut_slot(data, prefix_size, slot_idx);
+
+ uint8_t *slot0 = btc_mut_slot(data, prefix_size, 0);
+
+ /* Weighted, not uniform. The three slot-0 field operators are the only ones
+ * libFuzzer cannot supply itself -- it has no way to know that tail byte 62 picks
+ * a sign mode -- so they get three quarters of the budget. The two coarse
+ * whole-slot operators get a sixteenth each: at slot 1 and beyond a slot is tape
+ * content, and zeroing or transposing 64 bytes of it destroys more structure than
+ * it explores. */
+ unsigned int op;
+ switch ((seed >> 8) & 0x0F) {
+ case 0: case 1: case 2: case 3: op = 0; break; /* scenario shape */
+ case 4: case 5: case 6: case 7: op = 1; break; /* version/locktime */
+ case 8: case 9: case 10: case 11: op = 2; break; /* address fields */
+ case 12: case 13: op = 5; break; /* fault knobs */
+ case 14: op = 3; break; /* transpose a slot */
+ default: op = 4; break; /* zero a slot */
+ }
+ switch (op) {
+ case 0:
+ /* SIGN_PSBT scenario shape. */
+ slot0[BTC_S0_N_INPUTS_OFF] = (uint8_t)((seed >> 12) & 0x07);
+ slot0[BTC_S0_N_OUTPUTS_OFF] = (uint8_t)((seed >> 15) & 0x07);
+ slot0[BTC_S0_SUBTYPE_OFF] = (uint8_t)((seed >> 18) & 0x0F);
+ slot0[BTC_S0_DESC_SEED_OFF] = (uint8_t)((seed >> 22) & 0x0F);
+ break;
+ case 1:
+ /* The two transaction-level fields with interesting boundaries: a
+ * locktime either side of the 500000000 height/time split, and a version
+ * the app may or may not accept. */
+ U4LE_ENCODE(slot0 + BTC_S0_TX_VERSION_OFF, 0,
+ btc_pick_special_tx_version(seed));
+ U4LE_ENCODE(slot0 + BTC_S0_LOCKTIME_OFF, 0,
+ btc_pick_special_locktime(seed));
+ break;
+ case 2:
+ /* GET_WALLET_ADDRESS fields. The address index is the one place a
+ * boundary value matters: it crosses into the hardened range at
+ * 0x80000000, which is_path_safe_for_pubkey_export() rejects. */
+ U4LE_ENCODE(slot0 + BTC_S0_ADDR_INDEX_OFF, 0,
+ btc_pick_special_address_index(seed));
+ slot0[BTC_S0_ADDR_DISPLAY_OFF] ^= (uint8_t)((seed >> 12) & 0x01);
+ slot0[BTC_S0_ADDR_IS_CHANGE_OFF] ^= (uint8_t)((seed >> 13) & 0x01);
+ slot0[BTC_S0_ADDR_USE_REGISTERED_OFF] ^= (uint8_t)((seed >> 14) & 0x01);
+ slot0[BTC_S0_ADDR_FLIP_HMAC_OFF] ^= (uint8_t)((seed >> 15) & 0x01);
+ break;
+ case 3: {
+ /* Structure-agnostic block permutation: moving a 64-byte run re-frames
+ * whatever the tape decodes after it, which is exactly the kind of edit
+ * a length-prefixed stream responds to. */
+ size_t other = (seed >> 12) % n_full_slots;
+ if (other == slot_idx && n_full_slots > 1)
+ other = (other + 1) % n_full_slots;
+ btc_swap_slots(slot, btc_mut_slot(data, prefix_size, other));
+ break;
+ }
+ case 4:
+ memset(slot, 0, FUZZ_TAIL_SLOT_SIZE);
+ break;
+ case 5:
+ /* Continuation faults. Only kinds 6 and 7 do anything (mocks.h), so pick
+ * between those two and write all four bytes, so the per-kind parameters
+ * vary too. */
+ if (size >= prefix_size + BTC_TAIL_OFF + FUZZ_TAIL_FAULT_SIZE) {
+ uint8_t *fault = btc_mut_fault_region(data, size);
+ fault[0] = (uint8_t)(BTC_FAULT_CONT_TRUNCATE + ((seed >> 12) & 0x01));
+ fault[1] = (uint8_t)((seed >> 16) & 0xFF);
+ fault[2] = (uint8_t)((seed >> 20) & 0xFF);
+ fault[3] = (uint8_t)((seed >> 24) & 0xFF);
+ }
+ break;
+ }
+}
+
+/* App-specific mutation on top of the framework mutator. Structured inputs get a
+ * slot-0 scenario-control tweak 30% of the time; everything else is left to
+ * fuzz_custom_mutator, whose generic byte operators are the right family for a
+ * length-prefixed tape because they re-frame every entry downstream. The framework
+ * resolves the prefix size, so nothing here needs a prefix layout offset. */
+size_t LLVMFuzzerCustomMutator(uint8_t* data, size_t size, size_t max_size,
+ unsigned int seed) {
+ size_t new_size = fuzz_custom_mutator(data, size, max_size, seed);
+ size_t prefix_size = fuzz_prefix_size();
+
+ if (new_size <= prefix_size + BTC_TAIL_OFF) return new_size;
+
+ if (data[prefix_size] > FUZZ_STRUCTURED_LANE_THRESHOLD) {
+ if (((seed >> 16) % 100) < 30) {
+ btc_field_aware_tweak(data, new_size, prefix_size, seed);
+ }
+ }
+ return new_size;
+}
+
+/* BIP-45 and BIP-48 are listed so is_path_safe_for_pubkey_export() sees all
+ * case labels for the purpose switch. BIP-48 needs a fourth hardened
+ * script_type component in {1,2}, which the generic fuzz_bip32_build() does
+ * not emit; patch_bip48_path() fixes that up after building. */
+static const uint32_t BTC_PURPOSES[] = {44, 45, 48, 49, 84, 86};
+static const fuzz_bip32_config_t btc_bip32_cfg = {
+ .purposes = BTC_PURPOSES,
+ .n_purposes = sizeof(BTC_PURPOSES) / sizeof(BTC_PURPOSES[0]),
+ .coin_type = 0x80000000UL,
+ .max_account = 3,
+ .max_depth = 5,
+};
+
+/* For BIP-48, extend the path to 4 components and force the fourth hardened
+ * component to script_type 1' or 2'; otherwise is_path_safe_for_pubkey_export()
+ * rejects the path before reaching the BIP-48 script_type check. */
+/* Takes both the written length (to read the purpose safely) and the buffer capacity
+ * (to know whether a 4th component can be appended). Do not write buf[0] before the
+ * capacity check: an APDU declaring depth 4 with a stale 4th component is not
+ * reproducible from its own input file. */
+static void patch_bip48_path(uint8_t* buf, size_t path_bytes, size_t cap,
+ const uint8_t* ctrl, size_t ctrl_len) {
+ /* Need the depth byte plus a full first component before reading the purpose. */
+ if (path_bytes < 5) return;
+ uint8_t depth = buf[0];
+ if (depth < 1) return;
+ uint32_t purpose_raw = ((uint32_t)buf[1] << 24) | ((uint32_t)buf[2] << 16) |
+ ((uint32_t)buf[3] << 8) | (uint32_t)buf[4];
+ if ((purpose_raw & 0x7FFFFFFF) != 48) return;
+
+ /* The 4th component occupies buf[13..16]. */
+ if (cap < 17) return;
+ if (depth < 4) {
+ buf[0] = 4;
+ }
+ uint8_t script_type = (ctrl_len > 3 && (ctrl[3] & 1)) ? 2 : 1;
+ uint32_t script_comp = 0x80000000UL | script_type;
+ uint8_t* p = buf + 1 + 3 * 4;
+ p[0] = (uint8_t)(script_comp >> 24);
+ p[1] = (uint8_t)(script_comp >> 16);
+ p[2] = (uint8_t)(script_comp >> 8);
+ p[3] = (uint8_t)(script_comp);
+}
+
+static int btc_handle_ccmd(void* ctx, const uint8_t* request,
+ size_t request_len, uint8_t* response,
+ size_t* response_len) {
+ mock_dispatcher_t* h = (mock_dispatcher_t*)ctx;
+
+ int is_target_round =
+ fuzz_continuation_idx == (int)(btc_fault_target & 0x0F);
+
+ /* The old wrapper also had a "return a well-formed but wrong reply" mode, but
+ * every call passed a disruption value that disabled it, so it never fired.
+ * mock_dispatcher's tamper hook is the place for that if it is ever wanted. */
+ size_t response_cap = *response_len; /* contract: in = capacity, out = length */
+ *response_len = 0;
+ int rc = mock_dispatcher_handle_ccmd(h, request, request_len, response,
+ response_cap, response_len);
+ if (rc < 0) return rc;
+
+ if (is_target_round && *response_len > 0) {
+ switch (btc_fault_kind) {
+ case BTC_FAULT_CONT_TRUNCATE:
+ if (*response_len > 2) {
+ *response_len =
+ 1 + (btc_fault_param[0] % (*response_len - 1));
+ }
+ break;
+ case BTC_FAULT_CONT_FLIP: {
+ size_t n_flips = 1 + (btc_fault_param[0] % 4);
+ for (size_t fi = 0; fi < n_flips; fi++) {
+ size_t pos =
+ (btc_fault_param[1] + fi * 7) % *response_len;
+ response[pos] ^= btc_fault_param[0];
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ return 0;
+}
+
+static fuzz_continuation_host_t btc_continuation_host = {
+ .handle_ccmd = btc_handle_ccmd,
+ .ctx = NULL,
+ .active = false,
+};
+
+/* The client-command host. Zero-initialised in BSS, then reset per input;
+ * mock_dispatcher_init() is not needed because the fuzz path drives the real
+ * dispatcher and never uses mock->dc. */
+static mock_dispatcher_t g_btc_host;
+
+static void btc_activate_host(void) {
+ btc_continuation_host.ctx = &g_btc_host;
+ btc_continuation_host.active = true;
+ fuzz_continuation_host = &btc_continuation_host;
+}
+
+static void btc_deactivate_host(void) {
+ btc_continuation_host.active = false;
+ fuzz_continuation_host = &btc_continuation_host;
+}
+
+static size_t build_sign_psbt_payload(uint8_t* out, size_t cap);
+static size_t build_get_wallet_address_payload(uint8_t* out, size_t cap);
+static size_t build_register_wallet_payload(uint8_t* out, size_t cap);
+static size_t build_sign_message_payload(uint8_t* out, size_t cap);
+static size_t build_get_extended_pubkey_payload(uint8_t* out, size_t cap);
+
+typedef size_t (*btc_payload_builder_t)(uint8_t* out, size_t cap);
+
+typedef struct {
+ uint8_t ins;
+ btc_payload_builder_t build_payload;
+} btc_payload_route_t;
+
+static const btc_payload_route_t BTC_PAYLOAD_ROUTES[] = {
+ {.ins = SIGN_PSBT, .build_payload = build_sign_psbt_payload},
+ {.ins = GET_WALLET_ADDRESS, .build_payload = build_get_wallet_address_payload},
+ {.ins = REGISTER_WALLET, .build_payload = build_register_wallet_payload},
+ {.ins = SIGN_MESSAGE, .build_payload = build_sign_message_payload},
+ {.ins = GET_EXTENDED_PUBKEY, .build_payload = build_get_extended_pubkey_payload},
+};
+
+static uint8_t g_swap_return_dummy;
+
+static void btc_read_fault_knobs(void) {
+ btc_fault_kind = BTC_FAULT_CLEAN;
+ btc_fault_target = 0;
+ btc_fault_param[0] = 0;
+ btc_fault_param[1] = 0;
+
+ if (!fuzz_use_structured_lane() || fuzz_tail_ptr == NULL ||
+ fuzz_tail_len < FUZZ_TAIL_FAULT_SIZE) {
+ return;
+ }
+
+ const uint8_t *f = fuzz_tail_ptr + fuzz_tail_len - FUZZ_TAIL_FAULT_SIZE;
+ btc_fault_kind = f[0] & 0x07;
+ btc_fault_target = f[1];
+ btc_fault_param[0] = f[2];
+ btc_fault_param[1] = f[3];
+}
+
+static btc_payload_builder_t btc_find_payload_builder(uint8_t ins) {
+ size_t n_routes = sizeof(BTC_PAYLOAD_ROUTES) / sizeof(BTC_PAYLOAD_ROUTES[0]);
+
+ for (size_t i = 0; i < n_routes; i++) {
+ if (BTC_PAYLOAD_ROUTES[i].ins == ins) {
+ return BTC_PAYLOAD_ROUTES[i].build_payload;
+ }
+ }
+ return NULL;
+}
+
+static size_t build_structured_payload(uint8_t ins, uint8_t* out, size_t cap) {
+ btc_payload_builder_t build_payload = btc_find_payload_builder(ins);
+
+ /* GET_MASTER_FINGERPRINT accepts an empty APDU; no builder. */
+ if (build_payload == NULL) {
+ return 0;
+ }
+ return build_payload(out, cap);
+}
+
+/* Structured-lane payload builders: return bytes written to `out` (0 to
+ * short-circuit). Builders needing continuation traffic activate the host. */
+
+static size_t commit_apdu(uint8_t* out, size_t cap, const uint8_t* apdu,
+ size_t apdu_len) {
+ if (apdu_len == 0) return 0;
+ size_t n = apdu_len > cap ? cap : apdu_len;
+ memcpy(out, apdu, n);
+ btc_activate_host();
+ return n;
+}
+
+static const uint8_t *tail_slot_data(size_t *out_len) {
+ if (fuzz_tail_ptr && fuzz_tail_len > 0) {
+ *out_len = fuzz_tail_len;
+ return fuzz_tail_ptr;
+ }
+ *out_len = 0;
+ return NULL;
+}
+
+static size_t build_sign_psbt_payload(uint8_t* out, size_t cap) {
+ static psbt_scenario_t sc;
+ size_t sd_len;
+ const uint8_t* sd = tail_slot_data(&sd_len);
+ if (pm_build_scenario(&sc, &g_btc_host, psbt_entropy,
+ PSBT_ENTROPY_SIZE, sd, sd_len) != 0) {
+ return 0;
+ }
+ return commit_apdu(out, cap, sc.apdu, sc.apdu_len);
+}
+
+static size_t build_wallet_scenario(wallet_scenario_t* sc) {
+ size_t sd_len;
+ const uint8_t* sd = tail_slot_data(&sd_len);
+ if (wm_build_scenario(sc, &g_btc_host, psbt_entropy, PSBT_ENTROPY_SIZE,
+ sd, sd_len) != 0) {
+ return 0;
+ }
+ return sc->apdu_len;
+}
+
+static size_t build_register_wallet_payload(uint8_t* out, size_t cap) {
+ static wallet_scenario_t sc;
+ if (build_wallet_scenario(&sc) == 0) return 0;
+ return commit_apdu(out, cap, sc.apdu, sc.apdu_len);
+}
+
+static size_t build_get_wallet_address_payload(uint8_t* out, size_t cap) {
+ static wallet_scenario_t sc;
+ size_t sd_len;
+ const uint8_t* sd = tail_slot_data(&sd_len);
+ if (build_wallet_scenario(&sc) == 0) return 0;
+ if (wm_build_get_address_apdu(&sc, sd, sd_len) != 0) {
+ return 0;
+ }
+ return commit_apdu(out, cap, sc.apdu, sc.apdu_len);
+}
+
+static size_t build_sign_message_payload(uint8_t* out, size_t cap) {
+ static msg_scenario_t sc;
+ size_t sd_len;
+ const uint8_t* sd = tail_slot_data(&sd_len);
+ if (mm_build_scenario(&sc, &g_btc_host, psbt_entropy,
+ PSBT_ENTROPY_SIZE, sd, sd_len) != 0) {
+ return 0;
+ }
+ return commit_apdu(out, cap, sc.apdu, sc.apdu_len);
+}
+
+static const uint8_t *btc_get_pubkey_path_seed(const uint8_t *slot_data,
+ size_t slot_data_len,
+ size_t *seed_len) {
+ if (slot_data_len > BTC_GET_XPUB_PATH_SEED_OFF) {
+ *seed_len = slot_data_len - BTC_GET_XPUB_PATH_SEED_OFF;
+ return slot_data + BTC_GET_XPUB_PATH_SEED_OFF;
+ }
+
+ *seed_len = (PSBT_ENTROPY_SIZE > BTC_GET_XPUB_FALLBACK_CTRL_OFF) ?
+ (PSBT_ENTROPY_SIZE - BTC_GET_XPUB_FALLBACK_CTRL_OFF) : 0;
+ return psbt_entropy + BTC_GET_XPUB_FALLBACK_CTRL_OFF;
+}
+
+static uint8_t btc_get_pubkey_display_flag(const uint8_t *slot_data,
+ size_t slot_data_len) {
+ if (slot_data_len > BTC_GET_XPUB_DISPLAY_OFF) {
+ return slot_data[BTC_GET_XPUB_DISPLAY_OFF] & 1U;
+ }
+ return psbt_entropy[BTC_GET_XPUB_FALLBACK_DISPLAY_OFF] & 1U;
+}
+
+static size_t build_get_extended_pubkey_payload(uint8_t* out, size_t cap) {
+ if (cap < 2) return 0;
+
+ size_t sd_len;
+ const uint8_t* sd = tail_slot_data(&sd_len);
+ size_t path_seed_len;
+ const uint8_t* path_seed = btc_get_pubkey_path_seed(sd, sd_len, &path_seed_len);
+
+ out[0] = btc_get_pubkey_display_flag(sd, sd_len);
+
+ size_t path_bytes = fuzz_bip32_build(
+ &btc_bip32_cfg, path_seed, path_seed_len, out + 1, cap - 1);
+ if (path_bytes == 0) return 0;
+
+ patch_bip48_path(out + 1, path_bytes, cap - 1, path_seed, path_seed_len);
+
+ /* Recompute path_bytes in case patch_bip48_path promoted depth. */
+ path_bytes = 1 + (size_t)out[1] * 4;
+ return 1 + path_bytes;
+}
+
+void fuzz_app_reset(void) {
+ fuzz_continuation_idx = 0;
+ btc_deactivate_host();
+ G_output_len = 0;
+
+ /* G_swap_state is Absolution-driven in the prefix; the return-value pointer
+ * cannot be, so always point it at a static dummy. This keeps the real
+ * store in finalize_exchange_sign_transaction() reachable and removes the
+ * need for a production NULL guard. */
+ g_swap_return_dummy = 0;
+ ___src_swap_handle_swap_sign_transaction_c_G_swap_sign_return_value_address =
+ &g_swap_return_dummy;
+}
+
+void fuzz_app_dispatch(void* cmd_v) {
+ command_t* cmd = (command_t*)cmd_v;
+
+ if (cmd->ins == FUZZ_INS_SWAP_CHECK) {
+ (void)run_swap_check_address(cmd->data, cmd->lc);
+ return;
+ }
+ if (cmd->ins == FUZZ_INS_SWAP_HELPERS) {
+ (void)run_swap_helpers(cmd->data, cmd->lc);
+ return;
+ }
+
+ btc_read_fault_knobs();
+
+ /* Only SIGN_PSBT can be swap-capable; strip the stale swap flag from the
+ * prefix for every other route so it does not leak into handlers that have
+ * no business seeing it. The return-value pointer stays wired to the dummy
+ * (fuzz_app_reset) so any finalize path has a valid store target. */
+ if (cmd->ins != SIGN_PSBT) {
+ G_called_from_swap = 0;
+ }
+
+ static uint8_t structured_buf[512];
+ if (fuzz_use_structured_lane()) {
+ size_t payload_len = build_structured_payload(cmd->ins, structured_buf,
+ sizeof(structured_buf));
+ if (payload_len == 0) {
+ /* The builder could not produce a payload. Skip the iteration rather
+ * than dispatch with cmd->data still pointing at the raw tail: the app
+ * would parse unrelated bytes as a structured payload, die on a random
+ * Merkle root, and count as coverage. A skipped iteration is visible as
+ * lower throughput; a fallthrough is invisible. */
+ return;
+ }
+ cmd->lc = (uint8_t)(payload_len > 255 ? 255 : payload_len);
+ cmd->data = structured_buf;
+ }
+
+ apdu_dispatcher(
+ FUZZ_COMMAND_DESCRIPTORS,
+ sizeof(FUZZ_COMMAND_DESCRIPTORS) / sizeof(FUZZ_COMMAND_DESCRIPTORS[0]),
+ ui_menu_main, cmd);
+}
+
+int fuzz_entry(const uint8_t* data, size_t size) {
+ /* Builder entropy is this app's own header, right after the framework
+ * control bytes. */
+ if (size >= (size_t) FUZZ_CTRL_LEN + PSBT_ENTROPY_SIZE) {
+ memcpy(psbt_entropy, data + FUZZ_CTRL_LEN, PSBT_ENTROPY_SIZE);
+ }
+ return fuzz_harness_entry(data, size);
+}
### fuzzing/invariants/domain-overrides.txt
@@ -0,0 +1,52 @@
+# Domain overrides for the Bitcoin fuzzer: constrain enum/state fields to
+# valid values so Absolution explores meaningful combinations instead of
+# invalid byte patterns. Applied on top of the synced invariant by
+# invariant.py. Syntax and how/when to edit this file: see the "Invariants"
+# page of the SDK Fuzzing Framework docs.
+#
+# Format:
+# GLOBAL.field = values \xHH [\xHH ...] (enumerated values)
+# GLOBAL.field = top (fully fuzzable)
+# GLOBAL. = values ... (flat global, unnamed field)
+
+# G_swap_state (swap_globals.h): booleans/enums constrained; large comparison
+# fields stay `top` so execute_swap_checks() pass/fail paths are explored.
+# Note: called_from_swap is NOT a field here — see G_called_from_swap below.
+G_swap_state.should_exit = values \x00 \x01
+G_swap_state.mode = values \x00 \x01 \xFF
+G_swap_state.amount = top
+G_swap_state.fees = top
+# NOT `top`. swap_checks.c:212 runs strlen() on this char[65]; 65 uniform bytes
+# contain no NUL with probability (255/256)^65 = 77%, so strlen walks into
+# should_exit/mode/payin_extra_id and off the end of the 120-byte object. In
+# production handle_swap_sign_transaction.c:65 fills it from the exchange app's
+# fixed 65-byte slot and it is always terminated, so an over-read found here would
+# be a finding no production caller can produce. Keep the bytes fuzzer-driven but
+# bounded to values that always terminate.
+G_swap_state.destination_address = values \x00 \x31 \x62 \x7A
+G_swap_state.payin_extra_id = top
+
+# G_called_from_swap (SDK swap_utils.h): standalone byte gating swap paths in
+# sign_psbt.c/get_wallet_address.c/io_ext.c/main.c. Keep 0/1 for both paths.
+G_called_from_swap. = values \x00 \x01
+# G_swap_response_ready gates the response short-circuit in io.c.
+G_swap_response_ready. = values \x00 \x01
+
+# G_was_processing_screen_shown (dispatcher.c): gates UX spinner / termination.
+G_was_processing_screen_shown. = values \x00 \x01
+
+# g_ux_flow_ended / g_ux_flow_response (display.c): UI termination loop and
+# approval result returned from io_ui_process().
+g_ux_flow_ended. = values \x00 \x01
+g_ux_flow_response. = values \x00 \x01
+
+# UI verdict driving io_seproxyhal_io_heartbeat(): 0 = approve, 1 = reject,
+# same polarity as the SDK's fuzz_mock_nbgl_reject.
+fuzz_mock_ui_reject. = values \x00 \x01
+
+# g_xpub_derived (psbt_model.c): unlock both to explore cached and fresh paths.
+g_xpub_derived. = values \x00 \x01
+
+# SDK mock toggles: unlock both to explore success and failure paths.
+fuzz_mock_nbgl_reject. = values \x00 \x01
+fuzz_mock_crypto_fail. = values \x00 \x01
### fuzzing/invariants/zero-symbols.txt
@@ -0,0 +1,86 @@
+# Bitcoin-specific symbols to zero out of the Absolution prefix (in addition
+# to the framework SDK list). Zeroing removes a symbol from the prefix
+# entirely (fixed 0), reclaiming bytes from globals that do not gate parsing.
+
+# App runtime state — overwritten during dispatch, not branch-gating.
+G_output_len
+G_dispatcher_state
+G_dispatcher_context
+g_ui_state
+G_sign_psbt_cache
+pairs
+
+# Pointers overwritten by the harness every iteration (random bytes crash).
+G_processing_screen_text
+G_swap_sign_return_value_address@handle_swap_sign_transaction.c
+
+# SDK swap return-value pointer: only dereferenced under #ifdef HAVE_SWAP in
+# io.c, which is not compiled in this build. Zero it to a defined NULL state.
+G_swap_signing_return_value_address@lib_standard_app
+
+# Timeout bookkeeping (io_ext.c): set by the SDK ticker, gates no parser
+# branch (bounded by G_is_timeout_active, which starts false).
+G_ticks@io_ext.c
+G_interruption_timeout_start_tick@io_ext.c
+G_processing_timeout_start_tick@io_ext.c
+G_is_timeout_active@io_ext.c
+
+# Stack canary (constant, never mutated).
+
+# Crypto context (internal to the mock, no app branches).
+G_cx
+
+# UI display state — set by display functions, no pre-condition branches.
+confirmed_status@display_nbgl.c
+rejected_status@display_nbgl.c
+n_pairs@display_nbgl.c
+pairList@display_nbgl.c
+g_current_streaming_index@display.c
+nbTicks@ux.c
+pos@ux.c
+
+# Linker/OS symbols (meaningless to mutate).
+_bss
+_ebss
+
+# Mock/harness state — programmatically controlled or reset each iteration.
+# g_btc_host is defined in harness/fuzz_dispatcher.c, which is not in
+# absolution_targets.txt -- absolution never models it, so no pin is needed.
+# (Left documented rather than as a dead selector: the ~18 MB mock_dispatcher
+# struct it holds would dominate the prefix if that TU were ever added.)
+g_active_mock
+fuzz_continuation_host
+fuzz_continuation_idx
+
+# Mangled aliases of file-static booleans overridden via their canonical
+# names (g_ux_flow_ended, g_ux_flow_response in display.c).
+___src_ui_display_c_g_ux_flow_ended
+___src_ui_display_c_g_ux_flow_response
+
+# PSBT model mock state.
+g_derived_xpub
+g_pm_desc_idx
+pm_force_sign_mode
+
+# G_swap_state is NOT zeroed — Absolution drives it directly (field-level
+# domains in domain-overrides.txt).
+
+# NBGL settings page state. menu_nbgl.c writes settingsSwitches[] and
+# initSettingPage before any read, so the 19 prefix bytes Absolution spends
+# sampling them cannot change behaviour -- and .text/.subText are `const char *`
+# fields, i.e. wild pointers if ever dereferenced.
+settingsSwitches
+initSettingPage
+
+# psbt_entropy and the fault knobs are re-read from the input by
+# fuzz_dispatcher.c (memcpy at the top of fuzz_entry, btc_read_fault_knobs)
+# *after* sample_invariant() runs, so the 20 prefix bytes spent sampling them
+# are discarded before the app sees them. The input still drives both.
+psbt_entropy
+btc_fault_kind
+btc_fault_target
+btc_fault_param
+
+# pm_derive_mock_xpub()'s cache flag. Its only reader has no call site, and a
+# sampled value can claim a derivation happened when g_derived_xpub is unset.
+g_xpub_derived
### fuzzing/macros/add_macros.txt
[binary or diff unavailable]
### fuzzing/macros/exclude_macros.txt
[binary or diff unavailable]
### fuzzing/mock/fuzz_continuation_host.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define FUZZ_MAX_CONTINUATIONS 512
+
+// Continuation-host hook: os_io_rx_evt()'s mock dispatches app data requests
+// here and wraps the reply in the SDK envelope.
+typedef struct {
+ // Receives the raw request and fills response (in: capacity, out: length);
+ // returns 0 on success, negative to terminate.
+ int (*handle_ccmd)(void *ctx,
+ const uint8_t *request, size_t request_len,
+ uint8_t *response, size_t *response_len);
+ void *ctx;
+ bool active;
+} fuzz_continuation_host_t;
+
+extern fuzz_continuation_host_t *fuzz_continuation_host;
+
+/* Reset to 0 per input by the harness; incremented per round by os_io_rx_evt mock. */
+extern int fuzz_continuation_idx;
### fuzzing/mock/fuzz_os_io_rx_evt.c
@@ -0,0 +1,77 @@
+#include "fuzz_continuation_host.h"
+#include <string.h>
+#include <stdbool.h>
+
+extern unsigned char G_io_tx_buffer[];
+extern uint16_t G_output_len;
+
+fuzz_continuation_host_t *fuzz_continuation_host = NULL;
+int fuzz_continuation_idx = 0;
+
+/* 255, the APDU payload limit the client-command protocol and mock_dispatcher both
+ * size their replies against (mock_dispatcher.c: max_payload = 255 - varint_len - 1).
+ *
+ * mock_dispatcher.c rejects rather than truncates an over-capacity reply, so a value
+ * short of 255 costs every preimage near the limit and with it the whole chunked
+ * path: spilling into the element queue needs a preimage of 252+ bytes.
+ *
+ * The envelope is 6 + 255 = 261 bytes against G_io_rx_buffer's OS_IO_SEPH_BUFFER_SIZE
+ * + 1 = 273, so the truncation branch below stays unreachable. */
+#define FUZZ_CCMD_PAYLOAD_MAX 255
+#define FUZZ_CCMD_ENVELOPE_HDR 6
+
+int os_io_rx_evt(unsigned char *buffer, unsigned short buffer_max_length,
+ unsigned int *timeout_ms, bool check_se_event) {
+ uint8_t payload[FUZZ_CCMD_PAYLOAD_MAX];
+ size_t payload_len = 0;
+ size_t total_len;
+
+ (void) timeout_ms;
+ (void) check_se_event;
+
+ if (fuzz_continuation_idx >= FUZZ_MAX_CONTINUATIONS) {
+ buffer[0] = 0x10;
+ buffer[1] = 0x00;
+ return 2;
+ }
+
+ if (!fuzz_continuation_host || !fuzz_continuation_host->active) {
+ buffer[0] = 0x10;
+ buffer[1] = 0x00;
+ return 2;
+ }
+
+ size_t tx_len = (size_t) G_output_len;
+ if (tx_len > 260) tx_len = 260;
+
+ payload_len = sizeof(payload);
+ int rc = fuzz_continuation_host->handle_ccmd(
+ fuzz_continuation_host->ctx,
+ G_io_tx_buffer, tx_len,
+ payload, &payload_len);
+
+ fuzz_continuation_idx++;
+
+ if (rc < 0 || payload_len > FUZZ_CCMD_PAYLOAD_MAX) {
+ buffer[0] = 0x10;
+ buffer[1] = 0x00;
+ return 2;
+ }
+
+ total_len = FUZZ_CCMD_ENVELOPE_HDR + payload_len;
+ if (total_len > buffer_max_length) {
+ payload_len = buffer_max_length - FUZZ_CCMD_ENVELOPE_HDR;
+ total_len = buffer_max_length;
+ }
+
+ buffer[0] = 0x10;
+ buffer[1] = 0xF8;
+ buffer[2] = 0x01;
+ buffer[3] = 0x00;
+ buffer[4] = 0x00;
+ buffer[5] = (uint8_t) payload_len;
+ if (payload_len > 0)
+ memcpy(&buffer[6], payload, payload_len);
+
+ return (int) total_len;
+}
### fuzzing/mock/fuzz_varint.h
@@ -0,0 +1,31 @@
+#pragma once
+/*
+ * Bitcoin CompactSize varint. Protocol-specific, so it stays here; the generic
+ * endian helpers come from the SDK's os_utils.h.
+ */
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "os_utils.h" // U4LE_ENCODE, U8LE_ENCODE
+
+static inline size_t fuzz_write_varint(uint8_t *out, uint64_t v) {
+ if (v < 0xFD) {
+ out[0] = (uint8_t) v;
+ return 1;
+ }
+ if (v <= 0xFFFF) {
+ out[0] = 0xFD;
+ out[1] = (uint8_t) (v & 0xFF);
+ out[2] = (uint8_t) ((v >> 8) & 0xFF);
+ return 3;
+ }
+ if (v <= 0xFFFFFFFF) {
+ out[0] = 0xFE;
+ U4LE_ENCODE(out + 1, 0, (uint32_t) v);
+ return 5;
+ }
+ out[0] = 0xFF;
+ U8LE_ENCODE(out + 1, 0, v);
+ return 9;
+}
### fuzzing/mock/message_model.c
@@ -0,0 +1,122 @@
+/* SIGN_MESSAGE scenarios.
+ *
+ * Two roles, kept apart:
+ *
+ * content -- path length and every path component, the declared message
+ * length, the chunk count and each chunk's length and bytes. All of
+ * it comes off the tape. Nothing here decides a value.
+ * commitment -- build the chunk Merkle tree and serialize the APDU, so the host
+ * can answer for whatever was committed.
+ *
+ */
+
+#include "message_model.h"
+#include "fuzz_varint.h"
+#include "mocks.h"
+
+#include "write.h"
+
+#include <string.h>
+
+/* Cursor over the harness input; reads past the end yield 0, so a short input
+ * degrades to a small message rather than to no message. */
+typedef struct {
+ const uint8_t *p;
+ size_t len;
+ size_t off;
+} mm_tape_t;
+
+static uint8_t mm_u8(mm_tape_t *t) {
+ return (t->off < t->len) ? t->p[t->off++] : 0;
+}
+
+static uint32_t mm_u32(mm_tape_t *t) {
+ uint32_t v = 0;
+ for (int i = 0; i < 4; i++) {
+ v = (v << 8) | mm_u8(t);
+ }
+ return v;
+}
+
+int mm_build_scenario(msg_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len) {
+ (void) entropy;
+ (void) entropy_len;
+
+ memset(sc, 0, sizeof(*sc));
+ mock_dispatcher_reset(host);
+
+ /* Slot 0 is reserved scenario-control space in all three builders, so the tape
+ * starts after it here too. */
+ mm_tape_t tape = {
+ .p = slot_data ? slot_data + FUZZ_TAIL_SLOT_SIZE : NULL,
+ .len = (slot_data_len > FUZZ_TAIL_SLOT_SIZE) ? slot_data_len - FUZZ_TAIL_SLOT_SIZE : 0,
+ .off = 0,
+ };
+
+ size_t max_steps = sizeof(sc->bip32_path) / 4;
+ size_t served = (size_t) (mm_u8(&tape) % (max_steps + 1));
+ for (size_t i = 0; i < served; i++) {
+ write_u32_be(sc->bip32_path, i * 4, mm_u32(&tape));
+ }
+ /* The declared length is its own tape byte, not the number of components served,
+ * so it can outrun them: that reaches both sign_message.c:57's
+ * MAX_BIP32_PATH_STEPS rejection and the truncation cases. Honest seven times in
+ * eight, so the happy path stays reachable. */
+ sc->bip32_path_len = ((mm_u8(&tape) & 0x07u) == 0x07u)
+ ? mm_u8(&tape)
+ : (uint8_t) served;
+ sc->bip32_path_served = served;
+
+ /* Chunk count and each chunk's length are independent of the declared message
+ * length below, which is the whole point: they are allowed to disagree. */
+ sc->n_chunks = (int) (mm_u8(&tape) % (MSG_MAX_CHUNKS + 1));
+ size_t bytes_served = 0;
+ for (int i = 0; i < sc->n_chunks; i++) {
+ size_t chunk_len = (size_t) (mm_u8(&tape) % (MSG_CHUNK_SIZE + 1));
+ for (size_t j = 0; j < chunk_len; j++) {
+ sc->chunks[i][j] = mm_u8(&tape); /* raw bytes, no printable filter */
+ }
+ sc->chunk_lens[i] = chunk_len;
+ bytes_served += chunk_len;
+ }
+
+ /* Declared length: usually what was actually served, so the happy path stays
+ * reachable, but sometimes an unrelated 32-bit value so the length checks and
+ * the chunk-count arithmetic in sign_message.c see a mismatch. */
+ sc->message_length = ((mm_u8(&tape) & 0x07u) == 0x07u) ? mm_u32(&tape) : (uint64_t) bytes_served;
+
+ int tree_idx = mock_dispatcher_tree_begin(host);
+ if (tree_idx < 0) return -1;
+ for (int i = 0; i < sc->n_chunks; i++) {
+ if (mock_dispatcher_tree_add_leaf(host, tree_idx, sc->chunks[i],
+ sc->chunk_lens[i]) < 0) {
+ return -1;
+ }
+ }
+ mock_dispatcher_tree_end(host, tree_idx, sc->merkle_root);
+
+ uint8_t *p = sc->apdu;
+ uint8_t *end = sc->apdu + sizeof(sc->apdu);
+
+ /* Declare the tape's length, serialize the components actually written. When they
+ * disagree the app reads a payload shorter than its own header claims, which is
+ * the truncation case sign_message.c has to survive. Copying declared*4 would run
+ * off sc->bip32_path, which holds only 8 components. */
+ *p++ = sc->bip32_path_len;
+ if (p + sc->bip32_path_served * 4 + 9 + 32 > end) return -1;
+ memcpy(p, sc->bip32_path, sc->bip32_path_served * 4);
+ p += sc->bip32_path_served * 4;
+
+ p += fuzz_write_varint(p, sc->message_length);
+
+ memcpy(p, sc->merkle_root, 32);
+ p += 32;
+
+ sc->apdu_len = (size_t) (p - sc->apdu);
+ return 0;
+}
### fuzzing/mock/message_model.h
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "mock_dispatcher.h"
+
+#define MSG_MAX_CHUNKS 32
+#define MSG_CHUNK_SIZE 64
+
+typedef struct {
+ uint8_t bip32_path[8 * 4];
+ size_t bip32_path_served; /* components actually written, may be < the
+ * length the APDU declares */
+ uint8_t bip32_path_len;
+
+ uint64_t message_length;
+ int n_chunks;
+ uint8_t chunks[MSG_MAX_CHUNKS][MSG_CHUNK_SIZE];
+ /* Per-chunk length, independent of message_length. Deriving one from the other is
+ * what made the app's non-final-chunk length check unreachable. */
+ size_t chunk_lens[MSG_MAX_CHUNKS];
+
+ uint8_t merkle_root[32];
+
+ uint8_t apdu[256];
+ size_t apdu_len;
+} msg_scenario_t;
+
+int mm_build_scenario(msg_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len);
### fuzzing/mock/mocks.c
@@ -0,0 +1,22 @@
+#include "mocks.h"
+
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+extern bool ___src_ui_display_c_g_ux_flow_ended;
+extern bool ___src_ui_display_c_g_ux_flow_response;
+
+/* Framework idiom: fuzz_mock_<area>_<failure-condition>, 0 = happy path --
+ * matching fuzz_mock_nbgl_reject in the SDK's NBGL mock. */
+uint8_t fuzz_mock_ui_reject;
+
+void io_seproxyhal_io_heartbeat(void) {
+ ___src_ui_display_c_g_ux_flow_ended = true;
+ ___src_ui_display_c_g_ux_flow_response = (fuzz_mock_ui_reject == 0);
+}
+
+uint8_t psbt_entropy[PSBT_ENTROPY_SIZE];
+
### fuzzing/mock/mocks.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include "cx_errors.h"
+#include "ox_ec.h"
+#include "os_task.h"
+#include <string.h>
+#include <setjmp.h>
+#include "exceptions.h"
+#include <stdio.h>
+#include <stdint.h>
+
+#include "fuzz_defs.h"
+
+
+#define FUZZ_TAIL_SLOT_SIZE 64
+/* 32, matching the 2048-byte slot region the manifest sizes tail_budget on.
+ * At 16 the mutator could only address tail bytes [0,1024) while all three tapes
+ * read well past that, so half the tail was reachable by no app operator. */
+#define FUZZ_TAIL_N_SLOTS 32
+#define FUZZ_TAIL_FAULT_SIZE 4
+
+#define PSBT_ENTROPY_SIZE 16
+extern uint8_t psbt_entropy[PSBT_ENTROPY_SIZE];
+
+
+/* Fault knobs (last 4 tail bytes): fault[0]&0x07 = kind (BTC_FAULT_*),
+ * fault[1] = target index, fault[2..3] = per-kind parameters. */
+/* Only faults that corrupt a host reply *after* its commitment was computed belong
+ * here. Mutating a scenario field before the Merkle trees are built leaves every
+ * length, leaf count, proof and root self-consistent, which malforms nothing. */
+#define BTC_FAULT_CLEAN 0
+#define BTC_FAULT_CONT_TRUNCATE 6
+#define BTC_FAULT_CONT_FLIP 7
+
+extern uint8_t btc_fault_kind;
+extern uint8_t btc_fault_target;
+extern uint8_t btc_fault_param[2];
### fuzzing/mock/psbt_model.c
@@ -0,0 +1,760 @@
+#include "psbt_model.h"
+
+#include "fuzz_varint.h"
+#include "mocks.h"
+
+#include "base58.h"
+#include "bip32.h"
+#include "psbt.h"
+#include "varint.h"
+#include "wallet.h"
+#include "write.h"
+#include "constants.h"
+#include "crypto.h"
+#include "policy.h" /* compute_wallet_hmac */
+
+#include <string.h>
+#include <stdio.h>
+
+// Tail fault knobs select targeted corruption: pre-tree faults change scenario
+// fields before the Merkle tree is built (so preimages stay consistent);
+// post-wallet faults corrupt sealed wallet state (HMAC).
+static serialized_extended_pubkey_t g_derived_xpub;
+static bool g_xpub_derived = false;
+
+int pm_derive_mock_xpub(void) {
+ if (g_xpub_derived) return 0;
+ uint32_t path[] = {0x80000000UL | 84, 0x80000000UL | (uint32_t)BIP44_COIN_TYPE, 0x80000000UL};
+ cx_err_t err = get_extended_pubkey_at_path(path, 3, BIP32_PUBKEY_VERSION, &g_derived_xpub);
+ if (err != CX_OK) return -1;
+ g_xpub_derived = true;
+ return 0;
+}
+
+static int encode_xpub_internal(const serialized_extended_pubkey_t *xpub, char *out, size_t out_len) {
+ serialized_extended_pubkey_check_t check;
+ memcpy(&check.serialized_extended_pubkey, xpub, sizeof(check.serialized_extended_pubkey));
+ crypto_get_checksum((uint8_t *)&check.serialized_extended_pubkey,
+ sizeof(check.serialized_extended_pubkey),
+ check.checksum);
+ return base58_encode((uint8_t *)&check, sizeof(check), out, out_len);
+}
+
+typedef struct {
+ const char *descriptor;
+ int n_keys;
+ uint32_t purpose;
+ uint8_t script_type; /* 0=P2WPKH, 1=P2TR, 2=P2WSH, 3=P2SH-P2WPKH */
+ int musig_n_keys; /* keys inside musig() placeholder; 0 for non-musig */
+} pm_descriptor_t;
+
+// SIGN_PSBT descriptors: indices < PM_MUSIG_DESC_START are non-MuSig; the rest
+// are MuSig-only (sign_mode 3/4).
+static const pm_descriptor_t PM_DESCRIPTORS[] = {
+ /* 0 */ {"wpkh(@0)", 1, 84, 0, 0},
+ /* 1 */ {"tr(@0)", 1, 86, 1, 0},
+ /* 2 */ {"wsh(multi(2,@0,@1))", 2, 48, 2, 0},
+ /* 3 */ {"sh(wpkh(@0))", 1, 49, 3, 0},
+ /* 4 */ {"wsh(sortedmulti(2,@0,@1))", 2, 48, 2, 0},
+ /* 5 */ {"tr(@0,pk(@1))", 2, 86, 1, 0},
+ /* 6 */ {"wsh(and_v(v:pk(@0),pk(@1)))", 2, 48, 2, 0},
+ /* 7 */ {"wsh(or_b(pk(@0),s:pk(@1)))", 2, 48, 2, 0},
+ /* 8 musig keypath only */ {"tr(musig(@0,@1)/**)", 2, 86, 1, 2},
+ /* 9 musig keypath + script leaf */ {"tr(musig(@0,@1)/**,pk(@2/**))", 3, 86, 1, 2},
+ /* 10 musig 3-of-3 keypath */ {"tr(musig(@0,@1,@2)/**)", 3, 86, 1, 3},
+};
+
+#define PM_MUSIG_DESC_START 8
+
+#define PM_N_DESCRIPTORS (sizeof(PM_DESCRIPTORS) / sizeof(PM_DESCRIPTORS[0]))
+
+static int g_pm_desc_idx = 0;
+
+enum {
+ PM_SIGN_MODE_DEFAULT = 0,
+ PM_SIGN_MODE_REGISTERED = 1,
+ PM_SIGN_MODE_RAWTX = 2,
+ PM_SIGN_MODE_MUSIG_R1 = 3,
+ PM_SIGN_MODE_MUSIG_R2 = 4,
+};
+
+enum {
+ /* 40..51 had no reader in any builder; two of them now carry the declared
+ * input/output counts, which must be independent of how many maps get built. */
+ PM_SLOT0_DECL_INPUTS_OFF = 40,
+ PM_SLOT0_DECL_OUTPUTS_OFF = 41,
+ PM_SLOT0_TX_VERSION_OFF = 52,
+ PM_SLOT0_LOCKTIME_OFF = 56,
+ PM_SLOT0_N_INPUTS_OFF = 60,
+ PM_SLOT0_N_OUTPUTS_OFF = 61,
+ PM_SLOT0_SUBTYPE_OFF = 62,
+ PM_SLOT0_DESCRIPTOR_OFF = 63,
+};
+
+#define PM_DESC_WSH_SORTEDMULTI 4
+
+/* script_type == 4 is OP_RETURN; change / addr_index are ignored. */
+// Build a coherent tail-driven prevout transaction so the recomputed txid
+// matches the PSBT fields.
+// Layout: version(4) | vin_count | vin | vout_count | vout | locktime(4)
+static int add_merkleized_map(mock_dispatcher_t *host,
+ const uint8_t *keys[], const size_t *key_lens,
+ const uint8_t *values[], const size_t *value_lens,
+ int n_entries,
+ uint8_t root_keys[32], uint8_t root_values[32]) {
+ int kt = mock_dispatcher_tree_begin(host);
+ int vt = mock_dispatcher_tree_begin(host);
+ if (kt < 0 || vt < 0) return -1;
+
+ for (int i = 0; i < n_entries; i++) {
+ /* A dropped leaf makes the root commit to fewer leaves than the APDU
+ * declares, so the app's proof request for the missing index is refused
+ * mid-conversation with no signal. Fail the scenario instead, as
+ * message_model and wallet_model already do. */
+ if (mock_dispatcher_tree_add_leaf(host, kt, keys[i], key_lens[i]) < 0 ||
+ mock_dispatcher_tree_add_leaf(host, vt, values[i], value_lens[i]) < 0) {
+ return -1;
+ }
+ }
+ mock_dispatcher_tree_end(host, kt, NULL);
+ mock_dispatcher_tree_end(host, vt, NULL);
+ memcpy(root_keys, host->trees[kt].root, 32);
+ memcpy(root_values, host->trees[vt].root, 32);
+ return 0;
+}
+
+static int add_map_commitment_preimage(mock_dispatcher_t *host,
+ int n_keys,
+ const uint8_t keys_root[32],
+ const uint8_t values_root[32]) {
+ uint8_t commit[1 + 32 + 32];
+ commit[0] = (uint8_t)n_keys;
+ memcpy(commit + 1, keys_root, 32);
+ memcpy(commit + 33, values_root, 32);
+
+ uint8_t preimage[1 + sizeof(commit)];
+ preimage[0] = 0x00;
+ memcpy(preimage + 1, commit, sizeof(commit));
+ mock_dispatcher_add_preimage(host, preimage, 1 + sizeof(commit));
+ return 0;
+}
+
+static int build_global_map(psbt_scenario_t *sc, mock_dispatcher_t *host) {
+ uint8_t key_version[1] = {PSBT_GLOBAL_TX_VERSION};
+ uint8_t val_version[4];
+ U4LE_ENCODE(val_version, 0, sc->tx_version);
+
+ uint8_t key_locktime[1] = {PSBT_GLOBAL_FALLBACK_LOCKTIME};
+ uint8_t val_locktime[4];
+ U4LE_ENCODE(val_locktime, 0, sc->locktime);
+
+ const uint8_t *keys[] = {key_version, key_locktime};
+ const size_t key_lens[] = {1, 1};
+ const uint8_t *vals[] = {val_version, val_locktime};
+ const size_t val_lens[] = {4, 4};
+
+ return add_merkleized_map(host, keys, key_lens, vals, val_lens, 2,
+ sc->global_root_keys, sc->global_root_values);
+}
+
+
+/* Referenced by zero-symbols.txt and fuzz_globals.zon so Absolution keeps
+ * a zero-cost slot for it; no runtime use. */
+
+// MuSig TAP_BIP32 carries the aggregate fingerprint + [change, index] only;
+// cached per descriptor index.
+
+typedef struct {
+ const uint8_t *keys[16];
+ size_t key_lens[16];
+ const uint8_t *vals[16];
+ size_t val_lens[16];
+ int n_entries;
+} pm_merkle_map_t;
+
+static int pm_map_add(pm_merkle_map_t *map,
+ const uint8_t *key,
+ size_t key_len,
+ const uint8_t *val,
+ size_t val_len) {
+ size_t max_entries = sizeof(map->keys) / sizeof(map->keys[0]);
+ int idx = map->n_entries;
+
+ if ((size_t) idx >= max_entries) {
+ return -1;
+ }
+
+ map->keys[idx] = key;
+ map->key_lens[idx] = key_len;
+ map->vals[idx] = val;
+ map->val_lens[idx] = val_len;
+ map->n_entries++;
+ return 0;
+}
+
+static int pm_map_finalize(mock_dispatcher_t *host,
+ pm_merkle_map_t *map,
+ uint8_t keys_root[32],
+ uint8_t values_root[32]) {
+ return add_merkleized_map(host,
+ map->keys,
+ map->key_lens,
+ map->vals,
+ map->val_lens,
+ map->n_entries,
+ keys_root,
+ values_root);
+}
+
+static int build_wallet_policy(psbt_scenario_t *sc, mock_dispatcher_t *host) {
+ const pm_descriptor_t *desc = &PM_DESCRIPTORS[g_pm_desc_idx];
+ const char *descriptor = desc->descriptor;
+ size_t desc_len = strlen(descriptor);
+
+ // Use V2 when V1 cannot represent the descriptor (inline "/**", multi-key
+ // registered signing, or MuSig round 1).
+ int has_inline_wildcard = (strstr(descriptor, "/**") != NULL);
+ int use_v2 = has_inline_wildcard ||
+ (sc->sign_mode == 1 && desc->n_keys > 1) ||
+ (sc->sign_mode == 3);
+
+ int ki_tree = mock_dispatcher_tree_begin(host);
+ if (ki_tree < 0) return -1;
+
+ for (int k = 0; k < desc->n_keys; k++) {
+ // Vary account per @N for distinct xpubs; static survives the longjmp.
+ uint32_t account = (uint32_t) k;
+ static serialized_extended_pubkey_t key_xpub;
+ uint32_t path[] = {
+ 0x80000000UL | desc->purpose,
+ 0x80000000UL | (uint32_t)BIP44_COIN_TYPE,
+ 0x80000000UL | account
+ };
+ cx_err_t err = get_extended_pubkey_at_path(path, 3, BIP32_PUBKEY_VERSION, &key_xpub);
+ if (err != CX_OK) return -1;
+
+ static char xpub_str[MAX_SERIALIZED_PUBKEY_LENGTH + 1];
+ int xpub_len = encode_xpub_internal(&key_xpub, xpub_str, sizeof(xpub_str) - 1);
+ if (xpub_len < 0) return -1;
+ xpub_str[xpub_len] = '\0';
+
+ char key_info[256];
+ int ki_len;
+ if (use_v2) {
+ ki_len = snprintf(key_info, sizeof(key_info),
+ "[00000000/%u'/%d'/%u']%s",
+ (unsigned)desc->purpose, BIP44_COIN_TYPE, (unsigned)account,
+ xpub_str);
+ } else {
+ ki_len = snprintf(key_info, sizeof(key_info),
+ "[00000000/%u'/%d'/%u']%s/**",
+ (unsigned)desc->purpose, BIP44_COIN_TYPE, (unsigned)account,
+ xpub_str);
+ }
+ if (ki_len < 0) return -1;
+
+ mock_dispatcher_tree_add_leaf(host, ki_tree, (const uint8_t *)key_info, (size_t)ki_len);
+ }
+ mock_dispatcher_tree_end(host, ki_tree, NULL);
+
+ uint8_t *p = sc->wallet_policy;
+
+ if (use_v2) {
+ // V2: version | name_len | name | desc_len | sha256(desc) | n_keys | keys_root.
+ static const char wallet_name[] = "mywallet";
+ uint8_t name_len = (uint8_t) strlen(wallet_name);
+
+ *p++ = WALLET_POLICY_VERSION_V2;
+ *p++ = name_len;
+ memcpy(p, wallet_name, name_len);
+ p += name_len;
+ p += fuzz_write_varint(p, desc_len);
+ cx_hash_sha256((const uint8_t *) descriptor, desc_len, p, 32);
+ p += 32;
+ p += fuzz_write_varint(p, (uint64_t) desc->n_keys);
+ memcpy(p, host->trees[ki_tree].root, 32);
+ p += 32;
+ sc->wallet_policy_len = (size_t) (p - sc->wallet_policy);
+
+ mock_dispatcher_add_preimage(host, (const uint8_t *) descriptor, desc_len);
+ } else {
+ // V1: version | name_len | desc_len | descriptor | n_keys | keys_root.
+ // Registered mode (sign_mode 1) gets a name; otherwise name_len = 0.
+ static const char reg_name[] = "mywallet";
+ uint8_t name_len = (sc->sign_mode == 1) ? (uint8_t) strlen(reg_name) : 0;
+
+ *p++ = WALLET_POLICY_VERSION_V1;
+ *p++ = name_len;
+ if (name_len > 0) {
+ memcpy(p, reg_name, name_len);
+ p += name_len;
+ }
+ p += fuzz_write_varint(p, desc_len);
+ memcpy(p, descriptor, desc_len);
+ p += desc_len;
+ p += fuzz_write_varint(p, (uint64_t) desc->n_keys);
+ memcpy(p, host->trees[ki_tree].root, 32);
+ p += 32;
+ sc->wallet_policy_len = (size_t) (p - sc->wallet_policy);
+ }
+
+ cx_hash_sha256(sc->wallet_policy, sc->wallet_policy_len, sc->wallet_id, 32);
+
+ mock_dispatcher_add_preimage(host, sc->wallet_policy, sc->wallet_policy_len);
+
+ if (!compute_wallet_hmac(sc->wallet_id, sc->wallet_hmac)) {
+ memset(sc->wallet_hmac, 0, 32);
+ }
+
+ /* Derive the scriptPubKey this policy owns, so an input can be made internal.
+ *
+ * preprocess_inputs.c:363 aborts the whole command when every input is external,
+ * and is_in_out_internal() calls an input internal only when its scriptPubKey
+ * equals what the policy derives for some (change, address_index). That value is a
+ * hash of a pubkey derived from the wallet's own xpub: the fuzzer cannot reach it
+ * by mutation any more than it can reach a prevout txid.
+ *
+ * Same class of obligation as the key sort and the txid binding -- a real host
+ * signing with this wallet always has matching scriptPubKeys. Everything about
+ * which keys exist, their lengths and their contents still comes from the tape.
+ *
+ * Driven through the unit-test mock's own dispatcher context, which answers the
+ * client commands in C. mock_dispatcher_reset() (called at the top of
+ * pm_build_scenario) has already pointed g_active_mock at this host. */
+ sc->wallet_spk_len = 0;
+ {
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(host);
+ buffer_t policy_buf = buffer_create(sc->wallet_policy, sc->wallet_policy_len);
+ policy_map_wallet_header_t header;
+ uint8_t descriptor_out[MAX_DESCRIPTOR_TEMPLATE_LENGTH];
+ union {
+ uint8_t bytes[MAX_WALLET_POLICY_BYTES];
+ policy_node_t parsed;
+ } policy_map;
+
+ if (0 <= read_and_parse_wallet_policy(dc, &policy_buf, &header, descriptor_out,
+ policy_map.bytes, sizeof(policy_map.bytes))) {
+ uint8_t spk[34];
+ int spk_len = get_wallet_script(
+ dc, &policy_map.parsed,
+ &(wallet_derivation_info_t) {.wallet_version = header.version,
+ .keys_merkle_root = header.keys_info_merkle_root,
+ .n_keys = header.n_keys,
+ .change = 0,
+ .address_index = 0},
+ spk);
+ if (spk_len > 0 && (size_t) spk_len <= sizeof(sc->wallet_spk)) {
+ memcpy(sc->wallet_spk, spk, (size_t) spk_len);
+ sc->wallet_spk_len = (size_t) spk_len;
+ }
+
+ /* And the key expression the policy's first key names at (0, 0).
+ *
+ * process_in_outs.c:110 returns "external" before the scriptPubKey is even
+ * compared unless in_out_info->key_expression_found is set, and that is set
+ * only by a PSBT_IN_BIP32_DERIVATION entry whose 33-byte pubkey the policy
+ * derives, under the master fingerprint and path its key_info declares.
+ * Both bindings are needed; the scriptPubKey alone leaves every input
+ * external.
+ *
+ * build_wallet_policy writes key_info as "[00000000/purpose'/coin'/account']",
+ * so the fingerprint is 00000000 and the path continues .../0/0. */
+ static serialized_extended_pubkey_t leaf;
+ uint32_t leaf_path[5] = {
+ 0x80000000UL | desc->purpose,
+ 0x80000000UL | (uint32_t) BIP44_COIN_TYPE,
+ 0x80000000UL, /* account 0, matching key 0's key_info */
+ 0, /* change */
+ 0, /* address index */
+ };
+ if (CX_OK == get_extended_pubkey_at_path(leaf_path, 5, BIP32_PUBKEY_VERSION,
+ &leaf)) {
+ memcpy(sc->wallet_pubkey, leaf.compressed_pubkey, 33);
+ sc->wallet_pubkey_len = 33;
+
+ uint8_t *d = sc->wallet_deriv;
+ memset(d, 0, 4); /* master fingerprint 00000000 */
+ for (size_t i = 0; i < 5; i++) {
+ U4LE_ENCODE(d + 4 + i * 4, 0, leaf_path[i]);
+ }
+ sc->wallet_deriv_len = 4 + 5 * 4;
+ }
+ }
+ }
+
+ return 0;
+}
+
+static const uint8_t pm_zero_slot[FUZZ_TAIL_SLOT_SIZE] = {0};
+
+static inline const uint8_t *pm_slot(const uint8_t *slot_data, size_t slot_data_len, int idx) {
+ size_t off = (size_t) idx * FUZZ_TAIL_SLOT_SIZE;
+ if (slot_data && off + FUZZ_TAIL_SLOT_SIZE <= slot_data_len)
+ return slot_data + off;
+ return pm_zero_slot;
+}
+
+static uint8_t pm_decode_subtype_slot(const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot0) {
+ uint8_t entropy_subtype = (entropy_len > 2) ? entropy[2] : 0;
+ return (uint8_t) ((slot0[PM_SLOT0_SUBTYPE_OFF] + entropy_subtype) & 0x0F);
+}
+
+static uint8_t pm_pick_sign_mode(uint8_t subtype_slot) {
+ if (subtype_slot < 6) {
+ return PM_SIGN_MODE_DEFAULT;
+ }
+ if (subtype_slot < 10) {
+ return PM_SIGN_MODE_REGISTERED;
+ }
+ if (subtype_slot < 14) {
+ return PM_SIGN_MODE_RAWTX;
+ }
+ if (subtype_slot == 14) {
+ return PM_SIGN_MODE_MUSIG_R1;
+ }
+ return PM_SIGN_MODE_MUSIG_R2;
+}
+
+static void pm_select_descriptor(uint8_t sign_mode, uint8_t desc_seed) {
+ if (sign_mode == PM_SIGN_MODE_MUSIG_R1 ||
+ sign_mode == PM_SIGN_MODE_MUSIG_R2) {
+ g_pm_desc_idx = PM_MUSIG_DESC_START +
+ (desc_seed % (PM_N_DESCRIPTORS - PM_MUSIG_DESC_START));
+ } else {
+ g_pm_desc_idx = desc_seed % PM_MUSIG_DESC_START;
+ }
+
+}
+
+static void pm_decode_global_controls(psbt_scenario_t *sc, const uint8_t *slot0) {
+ /* How many maps get built, bounded because each one costs trees from the
+ * per-scenario MOCK_MAX_TREES budget and tape from a finite supply. */
+ sc->n_inputs = 1 + (slot0[PM_SLOT0_N_INPUTS_OFF] % PM_MAX_INPUTS);
+ sc->n_outputs = 1 + (slot0[PM_SLOT0_N_OUTPUTS_OFF] % PM_MAX_OUTPUTS);
+
+ /* What the APDU *declares*, which is a separate question. Serializing the number
+ * actually built made the app's declared-count handling unreachable by
+ * construction: sign_psbt reads these counts from the APDU and then asks the host
+ * for that many leaves, so a count larger than the tree exercises the
+ * out-of-range leaf request, and a smaller one leaves committed leaves unread.
+ * Bounding the built count is a harness resource limit; bounding the declared
+ * count would mirror a bound the app checks itself. */
+ sc->declared_inputs = ((slot0[PM_SLOT0_N_INPUTS_OFF] & 0xF0u) == 0xF0u)
+ ? (int) slot0[PM_SLOT0_DECL_INPUTS_OFF]
+ : sc->n_inputs;
+ sc->declared_outputs = ((slot0[PM_SLOT0_N_OUTPUTS_OFF] & 0xF0u) == 0xF0u)
+ ? (int) slot0[PM_SLOT0_DECL_OUTPUTS_OFF]
+ : sc->n_outputs;
+ if (sc->n_inputs > PM_MAX_INPUTS) sc->n_inputs = PM_MAX_INPUTS;
+ if (sc->n_outputs > PM_MAX_OUTPUTS) sc->n_outputs = PM_MAX_OUTPUTS;
+
+ sc->tx_version = U4LE(slot0 + PM_SLOT0_TX_VERSION_OFF, 0);
+ sc->locktime = U4LE(slot0 + PM_SLOT0_LOCKTIME_OFF, 0);
+}
+
+/* ─── Content from the tape (pm_tape_t lives in the header) ─────────── */
+
+static uint8_t pm_tape_u8(pm_tape_t *t) {
+ return (t->off < t->len) ? t->p[t->off++] : 0;
+}
+
+/* Sized so a whole scenario's demand fits the tape the harness actually supplies.
+ *
+ * Supply is slot_data_len - FUZZ_TAIL_SLOT_SIZE = 1988 bytes. Demand is
+ * (1 global + n_inputs + n_outputs) maps, each costing 1 + E[n] * (3 + E[klen] +
+ * E[vlen]) bytes. These values put demand at ~1000 bytes, 2x inside the supply.
+ *
+ * Fewer inputs and outputs, but funded ones: one input map carrying real key bytes
+ * reaches preprocess_inputs, txhashes and psbt_parse_rawtx; five that zero-fill
+ * reach none of them. */
+#define PM_TAPE_MAP_MAX 12 /* entries per map the tape may request */
+#define PM_TAPE_KV_MAX 128 /* ceiling for a key or value */
+#define PM_TAPE_VAL_SHORT 40 /* the common case; covers 32B hashes and 33B pubkeys */
+
+/* Read one PSBT map off the tape: an entry count, then per entry a key length, the
+ * key bytes, a value length and the value bytes.
+ *
+ * Nothing is constrained. A key may be any length including zero, a value any
+ * length, keys may repeat, and the count may exceed anything a real PSBT carries.
+ * Key lengths are biased short because a PSBT key type is one byte -- that is a
+ * shape prior, not a value constraint, and it is what lets libFuzzer's coverage
+ * feedback find the key types that matter instead of guessing them uniformly.
+ */
+static int pm_map_from_tape(pm_tape_t *t, mock_dispatcher_t *host,
+ uint8_t kr[32], uint8_t vr[32], int *n_entries,
+ const psbt_scenario_t *sc) {
+ static uint8_t kb[PM_TAPE_MAP_MAX][PM_TAPE_KV_MAX];
+ static uint8_t vb[PM_TAPE_MAP_MAX][PM_TAPE_KV_MAX];
+ pm_merkle_map_t map;
+ memset(&map, 0, sizeof(map));
+
+ size_t n = 1 + (size_t) (pm_tape_u8(t) % PM_TAPE_MAP_MAX);
+ for (size_t i = 0; i < n; i++) {
+ size_t kl = 1 + (size_t) (pm_tape_u8(t) % 4u); /* short keys */
+ /* 0x0F, not 0: see the exhaustion rule above *n_entries below. A tape that
+ * has run out yields zeros, so testing == 0 here made every unfunded entry
+ * take the rare long-key branch and land on kl = 0. */
+ if ((pm_tape_u8(t) & 0x0Fu) == 0x0Fu) {
+ kl = (size_t) (pm_tape_u8(t) % PM_TAPE_KV_MAX); /* sometimes long, or 0 */
+ }
+ /* Values short by default with a long tail. Most PSBT values are a few bytes
+ * -- an index, an amount, a 33-byte pubkey -- but PSBT_IN_NON_WITNESS_UTXO
+ * carries a whole serialized transaction, so the tail has to reach ~100 bytes
+ * for psbt_parse_rawtx to have anything to parse. Drawing uniformly over the
+ * ceiling would cost 3x the tape for the same reach. */
+ size_t vl = (size_t) (pm_tape_u8(t) % PM_TAPE_VAL_SHORT);
+ if ((pm_tape_u8(t) & 0x07u) == 0x07u) {
+ vl = (size_t) (pm_tape_u8(t) % PM_TAPE_KV_MAX);
+ }
+ for (size_t j = 0; j < kl; j++) kb[i][j] = pm_tape_u8(t);
+ for (size_t j = 0; j < vl; j++) vb[i][j] = pm_tape_u8(t);
+ if (pm_map_add(&map, kb[i], kl, vb[i], vl) < 0) break;
+ }
+
+ /* Sort by key, strictly, and drop equal keys.
+ *
+ * This is a commitment obligation, not content authoring: the app runs
+ * check_merkle_tree_sorted() on every merkleized map it opens
+ * (get_merkleized_map.c:41) and rejects any pair where
+ * compare_byte_arrays(prev, cur) >= 0 -- so duplicates fail too. A map that is
+ * not strictly ascending is rejected at leaf 2, before a single value is
+ * fetched, which makes the whole tape unreadable rather than malformed.
+ * Ordering a fuzz-chosen multiset decides no value; the keys, their lengths,
+ * their contents and their number all still come from the tape.
+ *
+ * Insertion sort: n <= 16 here. */
+ for (int i = 1; i < map.n_entries; i++) {
+ for (int j = i; j > 0; j--) {
+ size_t la = map.key_lens[j - 1], lb = map.key_lens[j];
+ size_t m = la < lb ? la : lb;
+ int c = m ? memcmp(map.keys[j - 1], map.keys[j], m) : 0;
+ if (c < 0 || (c == 0 && la < lb)) break;
+ const uint8_t *tk = map.keys[j - 1]; size_t tkl = map.key_lens[j - 1];
+ const uint8_t *tv = map.vals[j - 1]; size_t tvl = map.val_lens[j - 1];
+ map.keys[j - 1] = map.keys[j]; map.key_lens[j - 1] = map.key_lens[j];
+ map.vals[j - 1] = map.vals[j]; map.val_lens[j - 1] = map.val_lens[j];
+ map.keys[j] = tk; map.key_lens[j] = tkl;
+ map.vals[j] = tv; map.val_lens[j] = tvl;
+ }
+ }
+ int w = 0;
+ for (int i = 0; i < map.n_entries; i++) {
+ if (w > 0 && map.key_lens[w - 1] == map.key_lens[i] &&
+ (map.key_lens[i] == 0 ||
+ memcmp(map.keys[w - 1], map.keys[i], map.key_lens[i]) == 0)) {
+ continue; /* equal to its predecessor; the app rejects those */
+ }
+ map.keys[w] = map.keys[i]; map.key_lens[w] = map.key_lens[i];
+ map.vals[w] = map.vals[i]; map.val_lens[w] = map.val_lens[i];
+ w++;
+ }
+ map.n_entries = w;
+
+ /* Bind PSBT_IN_PREVIOUS_TXID to the transaction PSBT_IN_NON_WITNESS_UTXO carries,
+ * most of the time.
+ *
+ * amount_from_psbt.c:73 rejects an input whose PSBT_IN_PREVIOUS_TXID does not
+ * equal the double-SHA256 of its PSBT_IN_NON_WITNESS_UTXO. No mutation can
+ * produce that equality: it would mean guessing a 32-byte hash of bytes the
+ * fuzzer also chose. So everything past that line -- the rest of
+ * amount_from_psbt, and txhashes.c through sign_psbt.c:111 -- is unreachable at
+ * any campaign length without the host discharging the binding. That is the same
+ * class of obligation as the key sort above: a cryptographic commitment the host
+ * must satisfy for the conversation to continue, not a value the harness chose.
+ *
+ * Deliberately not always. One time in sixteen the txid is left exactly as the
+ * tape wrote it, so the mismatch rejection stays reachable as well. */
+ if ((pm_tape_u8(t) & 0x0Fu) != 0x0Fu) {
+ const uint8_t *rawtx = NULL;
+ size_t rawtx_len = 0;
+ uint8_t *txid = NULL;
+ for (int i = 0; i < map.n_entries; i++) {
+ if (map.key_lens[i] != 1) continue;
+ if (map.keys[i][0] == PSBT_IN_NON_WITNESS_UTXO) {
+ rawtx = map.vals[i];
+ rawtx_len = map.val_lens[i];
+ } else if (map.keys[i][0] == PSBT_IN_PREVIOUS_TXID && map.val_lens[i] == 32) {
+ txid = (uint8_t *) map.vals[i]; /* points into vb[], writable */
+ }
+ }
+ if (rawtx != NULL && txid != NULL && rawtx_len > 0) {
+ uint8_t h[32];
+ cx_hash_sha256(rawtx, rawtx_len, h, sizeof(h));
+ cx_hash_sha256(h, sizeof(h), txid, 32);
+ }
+
+ /* Name the wallet's own key expression, so key_expression_found gets set. */
+ if (sc != NULL && sc->wallet_pubkey_len > 0 && sc->wallet_deriv_len > 0) {
+ for (int i = 0; i < map.n_entries; i++) {
+ if (map.key_lens[i] != 1 + sc->wallet_pubkey_len) continue;
+ if (map.keys[i][0] != PSBT_IN_BIP32_DERIVATION) continue;
+ uint8_t *k = (uint8_t *) map.keys[i]; /* points into kb[], writable */
+ uint8_t *v = (uint8_t *) map.vals[i];
+ memcpy(k + 1, sc->wallet_pubkey, sc->wallet_pubkey_len);
+ memcpy(v, sc->wallet_deriv, sc->wallet_deriv_len);
+ map.val_lens[i] = sc->wallet_deriv_len;
+ break;
+ }
+ }
+
+ /* And make the witness utxo claim the wallet's own scriptPubKey, so
+ * is_in_out_internal() can call the input internal. A witness utxo is
+ * amount(8) | varint script_len | script; the amount and the tape's choice of
+ * total length are left alone, only the script is replaced. */
+ if (sc != NULL && sc->wallet_spk_len > 0) {
+ for (int i = 0; i < map.n_entries; i++) {
+ if (map.key_lens[i] != 1 || map.keys[i][0] != PSBT_IN_WITNESS_UTXO) continue;
+ if (map.val_lens[i] < 8 + 1 + sc->wallet_spk_len) continue;
+ uint8_t *v = (uint8_t *) map.vals[i]; /* points into vb[], writable */
+ v[8] = (uint8_t) sc->wallet_spk_len;
+ memcpy(v + 9, sc->wallet_spk, sc->wallet_spk_len);
+ map.val_lens[i] = 8 + 1 + sc->wallet_spk_len;
+ break;
+ }
+ }
+ }
+
+ /* Declared count from its own tape byte, so it can still disagree with the
+ * leaves actually served -- the one disagreement a merkleized map allows.
+ *
+ * THE EXHAUSTION RULE, which every tape predicate in this tree must obey: reads
+ * past the end of the tape yield 0, so a predicate written as `== 0` fires on
+ * *every* unfunded read -- which would make an unfunded map declare itself empty.
+ *
+ * Testing 0x0F keeps the same 1-in-16 rate for the interesting disagreement
+ * while making the degenerate case the benign one. */
+ *n_entries = ((pm_tape_u8(t) & 0x0Fu) == 0x0Fu) ? (int) pm_tape_u8(t) : map.n_entries;
+ return pm_map_finalize(host, &map, kr, vr);
+}
+
+static int pm_build_inputs_tree(psbt_scenario_t *sc, mock_dispatcher_t *host) {
+ int inputs_tree = mock_dispatcher_tree_begin(host);
+ if (inputs_tree < 0) return -1;
+
+ for (int i = 0; i < sc->n_inputs; i++) {
+ uint8_t kr[32], vr[32];
+ uint8_t commit[65];
+ int n_entries;
+
+ if (pm_map_from_tape(&sc->tape, host, kr, vr, &n_entries, sc) < 0) return -1;
+
+ commit[0] = (uint8_t) n_entries;
+ memcpy(commit + 1, kr, 32);
+ memcpy(commit + 33, vr, 32);
+ if (mock_dispatcher_tree_add_leaf(host, inputs_tree, commit, sizeof(commit)) < 0)
+ return -1;
+ add_map_commitment_preimage(host, (int) commit[0], kr, vr);
+ }
+
+ mock_dispatcher_tree_end(host, inputs_tree, NULL);
+ memcpy(sc->inputs_root, host->trees[inputs_tree].root, 32);
+ return 0;
+}
+
+static int pm_build_outputs_tree(psbt_scenario_t *sc, mock_dispatcher_t *host) {
+ int outputs_tree = mock_dispatcher_tree_begin(host);
+ if (outputs_tree < 0) return -1;
+
+ for (int i = 0; i < sc->n_outputs; i++) {
+ uint8_t kr[32], vr[32];
+ uint8_t commit[65];
+ int n_entries;
+
+ if (pm_map_from_tape(&sc->tape, host, kr, vr, &n_entries, sc) < 0) return -1;
+
+ commit[0] = (uint8_t) n_entries;
+ memcpy(commit + 1, kr, 32);
+ memcpy(commit + 33, vr, 32);
+ if (mock_dispatcher_tree_add_leaf(host, outputs_tree, commit, sizeof(commit)) < 0)
+ return -1;
+ add_map_commitment_preimage(host, (int) commit[0], kr, vr);
+ }
+
+ mock_dispatcher_tree_end(host, outputs_tree, NULL);
+ memcpy(sc->outputs_root, host->trees[outputs_tree].root, 32);
+ return 0;
+}
+
+int pm_build_scenario(psbt_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len) {
+ /* Wire the mock's dispatcher_context_t the first time through.
+ *
+ * The fuzz path answers client commands with mock_dispatcher_handle_ccmd(), which
+ * needs none of the dc callbacks, but build_wallet_policy() calls the app's own
+ * get_wallet_script() through this dc, which goes through dc->add_to_response.
+ * Testing the pointer rather than a static flag keeps it out of the invariant: a
+ * sampled `already initialised` on the first iteration is a null-pointer call.
+ *
+ * Init before reset, never after: init zeroes the whole struct, which would
+ * discard the trees and preimages a scenario has already registered. */
+ if (host->dc.add_to_response == NULL) {
+ mock_dispatcher_init(host);
+ }
+ memset(sc, 0, sizeof(*sc));
+ mock_dispatcher_reset(host);
+
+ /* The whole tail is one stream. Slot 0 stays reserved for the scenario controls
+ * the harness reads directly; map content starts after it. */
+ sc->tape.p = slot_data ? slot_data + FUZZ_TAIL_SLOT_SIZE : NULL;
+ sc->tape.len = (slot_data_len > FUZZ_TAIL_SLOT_SIZE) ? slot_data_len - FUZZ_TAIL_SLOT_SIZE : 0;
+ sc->tape.off = 0;
+
+ uint8_t e0 = (entropy_len > 0) ? entropy[0] : 0;
+ const uint8_t *s0 = pm_slot(slot_data, slot_data_len, 0);
+
+ // SIGN_PSBT subtype from slot0[62]: 0..5 default, 6..9 registered,
+ // 10..13 rawtx, 14 MuSig R1 (sign_mode 3), 15 MuSig R2 (sign_mode 4).
+ uint8_t subtype_slot = pm_decode_subtype_slot(entropy, entropy_len, s0);
+ uint8_t desc_seed = s0[PM_SLOT0_DESCRIPTOR_OFF] ^ e0;
+
+ sc->sign_mode = pm_pick_sign_mode(subtype_slot);
+ pm_select_descriptor(sc->sign_mode, desc_seed);
+ pm_decode_global_controls(sc, s0);
+
+
+ // Inputs and outputs read dense 64-byte tail slots at the PM_*_SLOT_*_OFF
+ // offsets defined above.
+
+ // Apply pre-tree faults before building wallet/maps so every streamed
+ // preimage reflects them.
+
+ if (build_wallet_policy(sc, host) < 0) return -1;
+ if (build_global_map(sc, host) < 0) return -1;
+ if (pm_build_inputs_tree(sc, host) < 0) return -1;
+ if (pm_build_outputs_tree(sc, host) < 0) return -1;
+
+
+
+ sc->apdu_len = pm_build_apdu(sc, sc->apdu, sizeof(sc->apdu));
+ return 0;
+}
+
+size_t pm_build_apdu(const psbt_scenario_t *sc, uint8_t *buf, size_t max) {
+ uint8_t *p = buf;
+ uint8_t *end = buf + max;
+
+ p += fuzz_write_varint(p, 2);
+ if (p + 64 > end) return 0;
+ memcpy(p, sc->global_root_keys, 32); p += 32;
+ memcpy(p, sc->global_root_values, 32); p += 32;
+
+ p += fuzz_write_varint(p, (uint64_t) sc->declared_inputs);
+ if (p + 32 > end) return 0;
+ memcpy(p, sc->inputs_root, 32); p += 32;
+
+ p += fuzz_write_varint(p, (uint64_t) sc->declared_outputs);
+ if (p + 32 > end) return 0;
+ memcpy(p, sc->outputs_root, 32); p += 32;
+
+ if (p + 64 > end) return 0;
+ memcpy(p, sc->wallet_id, 32); p += 32;
+ memcpy(p, sc->wallet_hmac, 32); p += 32;
+
+ return (size_t)(p - buf);
+}
### fuzzing/mock/psbt_model.h
@@ -0,0 +1,73 @@
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "mock_dispatcher.h"
+
+#define PM_MAX_INPUTS 3
+#define PM_MAX_OUTPUTS 3
+
+/* Cursor over the harness input. The scenario carries it so every map drawn during
+ * one build continues where the last stopped -- the tail is one stream, not a set of
+ * fixed-offset slots. */
+typedef struct {
+ const uint8_t *p;
+ size_t len;
+ size_t off;
+} pm_tape_t;
+
+typedef struct {
+ pm_tape_t tape;
+ uint32_t tx_version;
+ uint32_t locktime;
+ int n_inputs;
+ int n_outputs;
+ uint8_t sign_mode; /* 0=default, 1=registered, 2=rawtx, 3=musig-r1, 4=musig-r2 */
+
+ uint8_t wallet_policy[512];
+ size_t wallet_policy_len;
+
+ uint8_t wallet_id[32];
+ uint8_t wallet_hmac[32];
+
+ /* Counts the APDU declares, which the tape may set independently of how many
+ * input/output maps were actually committed to the trees. */
+ int declared_inputs;
+ int declared_outputs;
+
+ /* The scriptPubKey this wallet policy derives for (change 0, index 0), or
+ * length 0 if it could not be derived. See pm_bind_wallet_spk(). */
+ uint8_t wallet_spk[34];
+ size_t wallet_spk_len;
+
+ /* The compressed pubkey the policy's first key expression derives at
+ * (change 0, index 0), and the PSBT_IN_BIP32_DERIVATION value that names it:
+ * master fingerprint followed by the full derivation path. */
+ uint8_t wallet_pubkey[33];
+ size_t wallet_pubkey_len;
+ uint8_t wallet_deriv[4 + 5 * 4];
+ size_t wallet_deriv_len;
+
+ uint8_t global_root_keys[32];
+ uint8_t global_root_values[32];
+ uint8_t inputs_root[32];
+ uint8_t outputs_root[32];
+
+ uint8_t apdu[512];
+ size_t apdu_len;
+} psbt_scenario_t;
+
+extern int pm_force_sign_mode;
+
+int pm_build_scenario(psbt_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len);
+
+size_t pm_build_apdu(const psbt_scenario_t *sc, uint8_t *buf, size_t max);
+
+int pm_derive_mock_xpub(void);
### fuzzing/mock/wallet_model.c
@@ -0,0 +1,466 @@
+/* Wallet-policy scenarios for the fuzz harness.
+ *
+ * This file has exactly two jobs, and keeping them apart is the whole design:
+ *
+ * content -- the descriptor text, key count, name, version and key material.
+ * Every byte of it comes from the fuzz input, through wm_tape_t.
+ * Nothing here decides a value.
+ *
+ * commitment -- serialize the policy, hash it, build the key-info Merkle tree,
+ * register the preimages the app will ask for. This is the only part
+ * that must be correct, because a commitment the host cannot answer
+ * ends the APDU before any app logic runs.
+ *
+ * There is no fault injector: corrupting a scalar before the Merkle trees are built
+ * leaves every declared length, leaf count, proof and root self-consistent.
+ * libFuzzer's own mutators serve that role, because the tape *is* the wire content.
+ */
+
+#include "wallet_model.h"
+#include "fuzz_varint.h"
+#include "mocks.h"
+
+#include "base58.h"
+#include "bip32.h"
+#include "policy.h" /* compute_wallet_hmac */
+#include "wallet.h"
+#include "write.h"
+#include "constants.h"
+#include "crypto.h"
+
+#include <string.h>
+#include <stdio.h>
+
+/* ─── Layer 1: the tape ────────────────────────────────────────────────────
+ *
+ * A cursor over the harness input. Reads past the end return 0 rather than
+ * failing, so a short input degrades to a simple descriptor instead of no
+ * descriptor at all. This is the only source of values in the file.
+ */
+typedef struct {
+ const uint8_t *p;
+ size_t len;
+ size_t off;
+} wm_tape_t;
+
+static uint8_t tape_u8(wm_tape_t *t) {
+ return (t->off < t->len) ? t->p[t->off++] : 0;
+}
+
+static uint16_t tape_u16(wm_tape_t *t) {
+ uint16_t hi = tape_u8(t);
+ return (uint16_t) ((hi << 8) | tape_u8(t));
+}
+
+static uint32_t tape_u32(wm_tape_t *t) {
+ uint32_t v = 0;
+ for (int i = 0; i < 4; i++) {
+ v = (v << 8) | tape_u8(t);
+ }
+ return v;
+}
+
+/* ─── Layer 2: content from the tape ───────────────────────────────────────── */
+
+/* Bounded output buffer. On overflow len goes past cap and stays there, so callers
+ * test wm_ok() once at the end instead of checking every append. */
+typedef struct {
+ char *buf;
+ size_t cap;
+ size_t len;
+ int max_key; /* highest @N emitted, so n_keys is derived rather than declared */
+} wm_out_t;
+
+static int wm_ok(const wm_out_t *o) { return o->len <= o->cap; }
+
+static void wm_put(wm_out_t *o, const char *s, size_t n) {
+ if (o->len + n > o->cap) { o->len = o->cap + 1; return; }
+ memcpy(o->buf + o->len, s, n);
+ o->len += n;
+}
+
+static void wm_putc(wm_out_t *o, char c) { wm_put(o, &c, 1); }
+static void wm_puts(wm_out_t *o, const char *s) { wm_put(o, s, strlen(s)); }
+
+static void wm_put_dec(wm_out_t *o, uint32_t v) {
+ char tmp[12];
+ int n = snprintf(tmp, sizeof(tmp), "%u", (unsigned) v);
+ if (n > 0) wm_put(o, tmp, (size_t) n);
+}
+
+/* ─── The grammar, as data ──────────────────────────────────────────────────
+ *
+ * One expander walks a production string and substitutes from the tape. Making the
+ * productions data rather than a switch per rule is what keeps this small: adding a
+ * fragment is one table row, not a case block.
+ *
+ * K a key expression S a nested script (recurses)
+ * M a multi-family token D decimal from one byte
+ * T decimal from two bytes U decimal from four bytes
+ * H 32 hex-encoded bytes * repeat the preceding production, comma-separated
+ *
+ * Every count, index, depth and threshold above comes off the tape, which is the
+ * whole point: the shapes that matter are the ones no fixed table contained.
+ */
+static const char *const WM_FRAGS[] = {
+ "pk(K)", "pkh(K)", "M(D,K*)", "thresh(T,S*)",
+ "and_v(v:S,S)", "or_d(S,S)", "older(U)", "sha256(H)",
+};
+static const char *const WM_MULTI[] = {"multi", "multi_a", "sortedmulti", "sortedmulti_a"};
+static const char *const WM_CTX[] = {
+ "wsh(S)", "sh(wsh(S))", "wpkh(K)", "pkh(K)", "tr(K)", "tr(K,S)",
+};
+
+/* @N, optionally with a BIP-389 suffix carrying two full 31-bit fields. The
+ * catalogue only ever emitted <0;1>, <1;2> and <2;3>; those fields are what a
+ * stride-desynchronised consumer reads as a type tag and a relative pointer. */
+static void wm_key(wm_tape_t *t, wm_out_t *o) {
+ size_t n = ((tape_u8(t) & 3u) == 3u) ? 2 + (size_t) (tape_u8(t) % 7u) : 0;
+ if (n) wm_puts(o, "musig(");
+ for (size_t i = 0; i <= n && wm_ok(o); i++) {
+ if (i) wm_putc(o, ',');
+ uint8_t k = (uint8_t) (tape_u8(t) % WM_MAX_KEYS);
+ wm_putc(o, '@');
+ wm_put_dec(o, k);
+ if (k > o->max_key) o->max_key = k;
+ if (!n) break;
+ }
+ if (n) wm_putc(o, ')');
+ switch (tape_u8(t) % 3u) {
+ case 0: wm_puts(o, "/**"); break;
+ case 1:
+ wm_puts(o, "/<");
+ wm_put_dec(o, tape_u32(t) & 0x7FFFFFFFu);
+ wm_putc(o, ';');
+ wm_put_dec(o, tape_u32(t) & 0x7FFFFFFFu);
+ wm_puts(o, ">/*");
+ break;
+ default: break;
+ }
+}
+
+static void wm_expand(wm_tape_t *t, wm_out_t *o, const char *tpl, int budget);
+
+/* A script is an optional wrapper run then a fragment. The app's wrapper loop is
+ * flat and uncapped -- MAX_PARSE_SCRIPT_RECURSION_DEPTH does not count wrappers --
+ * so depth is a value the fuzzer must own. The catalogue's maximum was 2. */
+static void wm_script(wm_tape_t *t, wm_out_t *o, int budget) {
+ if (budget <= 0 || !wm_ok(o)) {
+ wm_puts(o, "pk(@0/**)"); /* always-legal bottom, so a short tape still balances */
+ return;
+ }
+ size_t n_wrap = (size_t) (tape_u8(t) % 64u);
+ if (n_wrap) {
+ char w = "acdjlnstuv"[tape_u8(t) % 10u];
+ /* Not `n_wrap--` in the condition: on the last iteration that evaluates 0,
+ * exits correctly, and then wraps n_wrap to SIZE_MAX -- an unsigned overflow
+ * UBSan reports on every descriptor with a wrapper run. */
+ while (n_wrap > 0 && wm_ok(o)) {
+ wm_putc(o, w);
+ n_wrap--;
+ }
+ wm_putc(o, ':');
+ }
+ wm_expand(t, o, WM_FRAGS[tape_u8(t) % 8u], budget);
+}
+
+static void wm_one(wm_tape_t *t, wm_out_t *o, char c, int budget) {
+ switch (c) {
+ case 'K': wm_key(t, o); break;
+ case 'S': wm_script(t, o, budget - 1); break;
+ case 'M': wm_puts(o, WM_MULTI[tape_u8(t) % 4u]); break;
+ case 'D': wm_put_dec(o, tape_u8(t)); break;
+ case 'T': wm_put_dec(o, tape_u16(t)); break;
+ case 'U': wm_put_dec(o, tape_u32(t)); break;
+ case 'H':
+ for (int i = 0; i < 32 && wm_ok(o); i++) {
+ char hx[3];
+ snprintf(hx, sizeof(hx), "%02x", tape_u8(t));
+ wm_put(o, hx, 2);
+ }
+ break;
+ default: wm_putc(o, c); break;
+ }
+}
+
+static void wm_expand(wm_tape_t *t, wm_out_t *o, const char *tpl, int budget) {
+ for (const char *c = tpl; *c && wm_ok(o); c++) {
+ if (c[1] == '*') {
+ size_t n = 1 + (size_t) (tape_u8(t) % 20u);
+ for (size_t i = 0; i < n && wm_ok(o); i++) {
+ if (i) wm_putc(o, ',');
+ wm_one(t, o, *c, budget);
+ }
+ c++; /* consume the '*' */
+ } else {
+ wm_one(t, o, *c, budget);
+ }
+ }
+}
+
+static size_t wm_emit_descriptor(wm_tape_t *t, char *buf, size_t cap, int *n_keys_out) {
+ wm_out_t o = {.buf = buf, .cap = cap - 1, .len = 0, .max_key = 0};
+ wm_expand(t, &o, WM_CTX[tape_u8(t) % 6u], 4);
+ if (!wm_ok(&o)) return 0;
+ buf[o.len] = '\0';
+ int n = o.max_key + 1;
+ *n_keys_out = (n > WM_MAX_KEYS) ? WM_MAX_KEYS : n;
+ return o.len;
+}
+
+/* ─── The floor: key info the app can parse ────────────────────────────────
+ *
+ * parse_policy_map_key_info checks length 111/112, base58 decoding and the
+ * checksum -- it does not check curve membership -- but a leaf that fails those
+ * ends the conversation, so the shape stays real. The tape still chooses how many
+ * keys there are and may replace any one of them with arbitrary bytes.
+ */
+static int build_key_info(int key_idx, uint32_t purpose, int version,
+ char *out, size_t out_len) {
+ // Vary account per @N for distinct xpubs; static so it survives the
+ // crypto-mock longjmp.
+ static serialized_extended_pubkey_t key_xpub;
+ uint32_t account = (uint32_t) key_idx;
+ uint32_t path[] = {
+ 0x80000000UL | purpose,
+ 0x80000000UL | (uint32_t) BIP44_COIN_TYPE,
+ 0x80000000UL | account
+ };
+ cx_err_t err = get_extended_pubkey_at_path(path, 3, BIP32_PUBKEY_VERSION, &key_xpub);
+ if (err != CX_OK) return -1;
+
+ static serialized_extended_pubkey_check_t check;
+ memcpy(&check.serialized_extended_pubkey, &key_xpub, sizeof(key_xpub));
+ crypto_get_checksum((uint8_t *) &check.serialized_extended_pubkey,
+ sizeof(check.serialized_extended_pubkey),
+ check.checksum);
+
+ static char xpub_str[120];
+ int xpub_len = base58_encode((uint8_t *) &check, sizeof(check), xpub_str, sizeof(xpub_str) - 1);
+ if (xpub_len < 0) return -1;
+ xpub_str[xpub_len] = '\0';
+
+ const char *suffix = (version == WALLET_POLICY_VERSION_V1) ? "/**" : "";
+
+ return snprintf(out, out_len,
+ "[00000000/%u'/%d'/%u']%s%s",
+ (unsigned) purpose, BIP44_COIN_TYPE, (unsigned) account,
+ xpub_str, suffix);
+}
+
+/* GET_WALLET_ADDRESS fields live in slot 0 at fixed offsets so that a mutation to
+ * one does not disturb another. Everything else now comes off the tape. */
+static const uint8_t wm_zero_slot[FUZZ_TAIL_SLOT_SIZE] = {0};
+
+enum {
+ WM_ADDR_DISPLAY_OFF = 32,
+ WM_ADDR_IS_CHANGE_OFF = 33,
+ WM_ADDR_INDEX_OFF = 34,
+ WM_ADDR_USE_REGISTERED_OFF = 38,
+ WM_ADDR_FLIP_HMAC_OFF = 39,
+};
+
+static inline const uint8_t *wm_slot(const uint8_t *slot_data, size_t slot_data_len, int idx) {
+ size_t off = (size_t) idx * FUZZ_TAIL_SLOT_SIZE;
+ if (slot_data && off + FUZZ_TAIL_SLOT_SIZE <= slot_data_len)
+ return slot_data + off;
+ return wm_zero_slot;
+}
+
+/* ─── Layer 3: the commitment keeper ──────────────────────────────────────── */
+
+int wm_build_scenario(wallet_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len) {
+ mock_dispatcher_reset(host);
+
+ /* The tape starts after slot 0, which GET_WALLET_ADDRESS reserves for its own
+ * fields; everything from slot 1 on is descriptor content. */
+ wm_tape_t tape = {
+ .p = slot_data ? slot_data + FUZZ_TAIL_SLOT_SIZE : NULL,
+ .len = (slot_data_len > FUZZ_TAIL_SLOT_SIZE) ? slot_data_len - FUZZ_TAIL_SLOT_SIZE : 0,
+ .off = 0,
+ };
+
+ uint8_t ctl = (entropy_len > 3) ? entropy[3] : 0;
+
+ int n_keys = 1;
+ sc->descriptor_len = wm_emit_descriptor(&tape, sc->descriptor,
+ sizeof(sc->descriptor), &n_keys);
+ if (sc->descriptor_len == 0) {
+ return -1;
+ }
+ sc->n_keys = n_keys;
+
+ /* Version from the tape. V1 cannot express a descriptor longer than
+ * MAX_DESCRIPTOR_TEMPLATE_LENGTH_V1 or an inline wildcard, so a long one is forced
+ * to V2 -- otherwise the app rejects it before the parser does anything. */
+ sc->version = (ctl & 1u) ? WALLET_POLICY_VERSION_V2 : WALLET_POLICY_VERSION_V1;
+ if (sc->version == WALLET_POLICY_VERSION_V1 &&
+ (sc->descriptor_len > MAX_DESCRIPTOR_TEMPLATE_LENGTH_V1 ||
+ strstr(sc->descriptor, "/**") != NULL)) {
+ sc->version = WALLET_POLICY_VERSION_V2;
+ }
+
+ /* Name: length straight from the tape, including 0. Zero matters -- the
+ * zero-HMAC branch in get_wallet_address requires name_len == 0, and the old
+ * builder clamped it to at least 1, so that branch always died. */
+ size_t name_len = (size_t) (tape_u8(&tape) % (sizeof(sc->name) - 1));
+ sc->name_len = (uint8_t) name_len;
+ for (size_t i = 0; i < name_len; i++) {
+ sc->name[i] = (char) tape_u8(&tape);
+ }
+
+ /* A full 32-bit derivation index per key, not a pick from a table: the purpose
+ * lands in key_info and in the derivation path, so the path parser needs boundary
+ * and multi-digit indices too. One of the six standard BIP numbers three times in
+ * four, so the standard-policy paths stay reachable. */
+ for (int i = 0; i < sc->n_keys; i++) {
+ static const uint32_t PURPOSES[] = {84, 44, 49, 86, 48, 45};
+ sc->purposes[i] = ((tape_u8(&tape) & 3u) == 3u)
+ ? tape_u32(&tape)
+ : PURPOSES[tape_u8(&tape) % 6u];
+ }
+
+ /* Key-info leaves. One key in eight is replaced by arbitrary tape bytes, so the
+ * key-info parser sees more than one malformed shape. */
+ int ki_tree = mock_dispatcher_tree_begin(host);
+ if (ki_tree < 0) return -1;
+
+ for (int i = 0; i < sc->n_keys; i++) {
+ char key_info[256];
+ int ki_len;
+
+ if ((tape_u8(&tape) & 0x07u) == 0x07u) {
+ size_t n = (size_t) (tape_u8(&tape) % 120u);
+ for (size_t j = 0; j < n; j++) {
+ key_info[j] = (char) tape_u8(&tape);
+ }
+ ki_len = (int) n;
+ } else {
+ ki_len = build_key_info(i, sc->purposes[i], sc->version,
+ key_info, sizeof(key_info));
+ }
+ if (ki_len < 0) return -1;
+ if (mock_dispatcher_tree_add_leaf(host, ki_tree,
+ (const uint8_t *) key_info,
+ (size_t) ki_len) < 0) {
+ return -1;
+ }
+ }
+ mock_dispatcher_tree_end(host, ki_tree, sc->keys_info_root);
+
+ /* Bound every write into wallet_policy[] before making it. */
+ uint8_t *const wp_end = sc->wallet_policy + sizeof(sc->wallet_policy);
+ if (2 + sc->name_len + 9 + 32 + 9 + 32 + sc->descriptor_len > sizeof(sc->wallet_policy)) {
+ return -1;
+ }
+
+ uint8_t *p = sc->wallet_policy;
+ *p++ = sc->version;
+ *p++ = sc->name_len;
+ memcpy(p, sc->name, sc->name_len);
+ p += sc->name_len;
+
+ /* The declared length is emitted from the tape, so it can disagree with the
+ * descriptor actually served. The app checks exactly that
+ * (policy.c: "Descriptor template length mismatch"), a branch the old builder
+ * could never reach because one variable drove both. */
+ uint64_t declared_len = sc->descriptor_len;
+ if ((tape_u8(&tape) & 0x0Fu) == 0x0Fu) {
+ declared_len = tape_u16(&tape);
+ }
+
+ if (sc->version == WALLET_POLICY_VERSION_V1) {
+ p += fuzz_write_varint(p, declared_len);
+ memcpy(p, sc->descriptor, sc->descriptor_len);
+ p += sc->descriptor_len;
+ } else {
+ p += fuzz_write_varint(p, declared_len);
+ uint8_t desc_sha[32];
+ cx_hash_sha256((const uint8_t *) sc->descriptor, sc->descriptor_len, desc_sha, 32);
+ memcpy(p, desc_sha, 32);
+ p += 32;
+
+ mock_dispatcher_add_preimage(host, (const uint8_t *) sc->descriptor,
+ sc->descriptor_len);
+ }
+
+ /* Same for the key count: declared independently of the leaves served. */
+ uint64_t declared_keys = (uint64_t) sc->n_keys;
+ if ((tape_u8(&tape) & 0x0Fu) == 0x0Fu) {
+ declared_keys = tape_u8(&tape);
+ }
+ p += fuzz_write_varint(p, declared_keys);
+ memcpy(p, sc->keys_info_root, 32);
+ p += 32;
+ if (p > wp_end) {
+ return -1;
+ }
+ sc->wallet_policy_len = (size_t) (p - sc->wallet_policy);
+
+ cx_hash_sha256(sc->wallet_policy, sc->wallet_policy_len, sc->wallet_id, 32);
+ mock_dispatcher_add_preimage(host, sc->wallet_policy, sc->wallet_policy_len);
+
+ if (!compute_wallet_hmac(sc->wallet_id, sc->wallet_hmac)) {
+ memset(sc->wallet_hmac, 0, 32);
+ }
+
+ uint8_t *ap = sc->apdu;
+ uint8_t *ap_end = sc->apdu + sizeof(sc->apdu);
+
+ ap += fuzz_write_varint(ap, sc->wallet_policy_len);
+ if (ap + sc->wallet_policy_len > ap_end) return -1;
+ memcpy(ap, sc->wallet_policy, sc->wallet_policy_len);
+ ap += sc->wallet_policy_len;
+
+ sc->apdu_len = (size_t) (ap - sc->apdu);
+ return 0;
+}
+
+int wm_build_get_address_apdu(wallet_scenario_t *sc,
+ const uint8_t *slot_data,
+ size_t slot_data_len) {
+ uint8_t *ap = sc->apdu;
+ uint8_t *ap_end = sc->apdu + sizeof(sc->apdu);
+ const uint8_t *s0 = wm_slot(slot_data, slot_data_len, 0);
+
+ if (ap + 1 + 32 + 32 + 1 + 4 > ap_end) return -1;
+
+ /* Passed through unmasked. The old builder masked both to &1, which made
+ * get_wallet_address's "is_change != 0 && is_change != 1" rejection dead code. */
+ uint8_t display = s0[WM_ADDR_DISPLAY_OFF];
+ uint8_t is_change = s0[WM_ADDR_IS_CHANGE_OFF];
+ uint32_t address_index = U4LE(s0 + WM_ADDR_INDEX_OFF, 0);
+ int use_registered = (s0[WM_ADDR_USE_REGISTERED_OFF] & 1) != 0;
+
+ *ap++ = display;
+
+ memcpy(ap, sc->wallet_id, 32);
+ ap += 32;
+
+ if (use_registered) {
+ memcpy(ap, sc->wallet_hmac, 32);
+ } else {
+ memset(ap, 0, 32);
+ }
+
+ if (s0[WM_ADDR_FLIP_HMAC_OFF] & 1) {
+ for (int i = 0; i < 32; i++)
+ ap[i] ^= 0xFF;
+ }
+ ap += 32;
+
+ *ap++ = is_change;
+ ap[0] = (uint8_t) ((address_index >> 24) & 0xFF);
+ ap[1] = (uint8_t) ((address_index >> 16) & 0xFF);
+ ap[2] = (uint8_t) ((address_index >> 8) & 0xFF);
+ ap[3] = (uint8_t) (address_index & 0xFF);
+ ap += 4;
+
+ sc->apdu_len = (size_t) (ap - sc->apdu);
+ return 0;
+}
### fuzzing/mock/wallet_model.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "mock_dispatcher.h"
+
+/* Match the app, do not mirror a smaller bound: MAX_N_KEYS_IN_WALLET_POLICY is 15
+ * and a serialized header allows 252, so a builder capped at 6 could never test the
+ * limit. The descriptor cap is deliberately above the app's
+ * MAX_DESCRIPTOR_TEMPLATE_LENGTH_V2 (512) so the over-length rejection is reachable. */
+#define WM_MAX_KEYS 16
+#define WM_MAX_DESCRIPTOR_LEN 600
+
+typedef struct {
+ uint8_t version;
+ char name[72]; /* app allows MAX_WALLET_NAME_LENGTH = 64 */
+ uint8_t name_len;
+
+ char descriptor[WM_MAX_DESCRIPTOR_LEN];
+ size_t descriptor_len;
+
+ int n_keys;
+ uint32_t purposes[WM_MAX_KEYS];
+
+ /* V1 inlines the descriptor, so this must hold the longest one plus the header
+ * (version, name, two varints, two 32-byte roots). */
+ uint8_t wallet_policy[WM_MAX_DESCRIPTOR_LEN + 256];
+ size_t wallet_policy_len;
+
+ uint8_t wallet_id[32];
+ uint8_t wallet_hmac[32];
+
+ uint8_t keys_info_root[32];
+
+ uint8_t apdu[WM_MAX_DESCRIPTOR_LEN + 512];
+ size_t apdu_len;
+} wallet_scenario_t;
+
+int wm_build_scenario(wallet_scenario_t *sc,
+ mock_dispatcher_t *host,
+ const uint8_t *entropy,
+ size_t entropy_len,
+ const uint8_t *slot_data,
+ size_t slot_data_len);
+
+int wm_build_get_address_apdu(wallet_scenario_t *sc,
+ const uint8_t *slot_data,
+ size_t slot_data_len);
### fuzzing/sanitizers/ubsan-ignorelist.txt
@@ -0,0 +1,23 @@
+# Bitcoin-specific UBSan carve-outs, composed onto the SDK's list via
+# -DLEDGER_FUZZ_APP_UBSAN_IGNORELIST (see ledger-secure-sdk/fuzzing/CMakeLists.txt).
+#
+# Scoped to the files owning the generic-callback idiom, rather than a blanket
+# -fno-sanitize=function, which would remove the indirect-call-type oracle app-wide.
+
+[function]
+# parsing_step_t and merkle_tree_elements_callback_t are generic function-pointer
+# typedefs that the dispatcher casts typed callbacks through deliberately. The
+# idiom trips -fsanitize=function without being a defect. Scoped to the files that
+# own the idiom rather than the whole app.
+src:*/src/handler/lib/stream_merkleized_map_value.c
+src:*/src/handler/lib/parser.c
+src:*/common/parser_ext.c
+src:*/src/handler/sign_psbt/preprocess_inputs.c
+src:*/src/handler/sign_psbt/preprocess_outputs.c
+src:*/src/handler/sign_psbt/sign_input.c
+
+# send_response() carries an empty parameter list, so its type is not void(void)
+# and reaching it through dispatcher_context_t's `void (*send_response)(void)` is
+# a call through an incompatible type. Scoped to the function so the dispatcher
+# keeps the oracle everywhere else.
+fun:send_response
### fuzzing/scripts/generate-seed-corpus.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""Seed corpus for the bitcoin fuzz target.
+
+A seed here is nothing but an input the fuzzer could have produced on its own. It
+authors no behaviour: it lands mutation in a region of the input space that random
+bytes reach with probability near zero, and libFuzzer explores outward from there.
+
+Why any seed is needed at all. The builders read their content off a cursor over the
+harness input (the "tape"). To reach psbt_parse_rawtx.c and txhashes.c the app must
+find, in a single input map, three specific one-byte PSBT keys -- NON_WITNESS_UTXO,
+PREVIOUS_TXID and OUTPUT_INDEX -- with WITNESS_UTXO absent and OUTPUT_INDEX carrying
+exactly four bytes. Each one-byte key is uniform over 256 values, so that conjunction
+arrives about once in 400 000 inputs and has to coincide with a map that is otherwise
+well formed. Measured: 0.00% of both files after a 300-second campaign that executed
+439 049 inputs. A seed spells the conjunction once; mutation keeps whatever the
+coverage signal rewards.
+
+MuSig is deliberately not seeded. Subtypes 14 and 15 reach the musig sign modes, but a
+musig policy is taproot, so key_expression_found comes from PSBT_IN_TAP_BIP32_DERIVATION
+carrying the x-only *aggregate* musig key -- which psbt_model does not reproduce, so the
+input stays external and preprocess_inputs.c:363 aborts exactly as before. Measured: two
+such seeds moved musig_signing.c not at all (0/317 either way) and cost 1.35pp overall by
+displacing fuzzing budget. Reaching it needs a third host binding for the aggregate key,
+not a seed.
+
+Only SIGN_PSBT is seeded, and that is a measured choice rather than an omission. From
+a cold start with no seeds at all, register_wallet.c already reaches 84.7%,
+get_wallet_address.c 61% and sign_message.c 40% -- random bytes find those lanes
+unaided, so seeding them would be the fuzzer's work done by hand. Every file still at
+0% is downstream of a valid PSBT input map.
+
+Everything a seed writes is a tape byte, so every byte stays mutable and still means
+what the builders say it means. The Absolution prefix is copied verbatim from the
+generated fuzzer.seed via resolve_seed_prefix(): this file never authors a prefix byte
+and knows nothing of the prefix layout, which is Absolution's alone.
+
+Input layout (fuzz_defs.h):
+ [ Absolution prefix ][ 4 ctrl ][ 16 app header ][ tail ]
+ ctrl = lane selector, command index, P1, P2
+ tail = slot 0 (64 B of scenario controls) followed by the tape
+"""
+import os
+import re
+import sys
+
+sys.path.insert(
+ 0,
+ os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "..", "..", "..",
+ "ledger-secure-sdk", "fuzzing", "scripts",
+ ),
+)
+from fuzz_seed_utils import resolve_prefix_size, resolve_seed_prefix # noqa: E402
+
+FUZZER_NAME = os.environ.get("FUZZER", "fuzz_app")
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_MOCK = os.path.join(_HERE, "..", "mock")
+_SDK_INC = os.path.join(_HERE, "..", "..", "..", "ledger-secure-sdk", "fuzzing", "include")
+
+
+def _c_const(name, *files):
+ """Read a constant out of the C sources rather than duplicating its value.
+
+ This file has to agree with psbt_model.c on the tape encoding and with mocks.h and
+ fuzz_defs.h on the input layout. Duplicating those numbers here is what destroyed
+ the previous generator: it accumulated 227 references to a slot layout the builders
+ had stopped using, and every seed it wrote silently decoded as noise for months
+ because nothing checks that a seed still means what it was written to mean.
+
+ Matches both `#define NAME value` and an enum's `NAME = value,`. Raises rather than
+ guessing, so a renamed constant fails the run instead of producing dead seeds.
+ """
+ pats = (
+ re.compile(r"^\s*#define\s+" + re.escape(name) + r"\s+(\d+)", re.M),
+ re.compile(r"^\s*" + re.escape(name) + r"\s*=\s*(\d+)\s*,", re.M),
+ )
+ for f in files:
+ try:
+ text = open(f, encoding="utf-8").read()
+ except OSError:
+ continue
+ for pat in pats:
+ m = pat.search(text)
+ if m:
+ return int(m.group(1))
+ raise SystemExit(f"error: could not find {name} in {', '.join(files)}")
+
+
+_PSBT = os.path.join(_MOCK, "psbt_model.c")
+_MOCKS_H = os.path.join(_MOCK, "mocks.h")
+_DEFS_H = os.path.join(_SDK_INC, "fuzz_defs.h")
+
+# ── harness layout ────────────────────────────────────────────────────────────
+CTRL_LEN = _c_const("FUZZ_CTRL_LEN", _DEFS_H)
+APP_HEADER_LEN = _c_const("PSBT_ENTROPY_SIZE", _MOCKS_H)
+SLOT_SIZE = _c_const("FUZZ_TAIL_SLOT_SIZE", _MOCKS_H)
+# data[0] must be strictly greater than the threshold to select the structured lane.
+STRUCTURED_LANE_MIN = _c_const("FUZZ_STRUCTURED_LANE_THRESHOLD", _DEFS_H) + 1
+
+# slot 0 offsets read directly by pm_build_scenario
+S0_TX_VERSION = _c_const("PM_SLOT0_TX_VERSION_OFF", _PSBT)
+S0_LOCKTIME = _c_const("PM_SLOT0_LOCKTIME_OFF", _PSBT)
+S0_N_INPUTS = _c_const("PM_SLOT0_N_INPUTS_OFF", _PSBT)
+S0_N_OUTPUTS = _c_const("PM_SLOT0_N_OUTPUTS_OFF", _PSBT)
+S0_SUBTYPE = _c_const("PM_SLOT0_SUBTYPE_OFF", _PSBT)
+S0_DESCRIPTOR = _c_const("PM_SLOT0_DESCRIPTOR_OFF", _PSBT)
+
+# fuzz_commands[] is a positional table, so this one is counted rather than defined.
+N_SIGN_PSBT_SLOTS = len(re.findall(
+ r"\.ins\s*=\s*SIGN_PSBT",
+ open(os.path.join(_HERE, "..", "harness", "fuzz_dispatcher.c"), encoding="utf-8").read()))
+if N_SIGN_PSBT_SLOTS == 0:
+ raise SystemExit("error: no SIGN_PSBT entries found in fuzz_commands[]")
+
+# ── tape encoding (pm_map_from_tape) ──────────────────────────────────────────
+TAPE_MAP_MAX = _c_const("PM_TAPE_MAP_MAX", _PSBT)
+TAPE_KV_MAX = _c_const("PM_TAPE_KV_MAX", _PSBT)
+TAPE_VAL_SHORT = _c_const("PM_TAPE_VAL_SHORT", _PSBT)
+
+# PSBT key types (src/common/psbt.h)
+IN_NON_WITNESS_UTXO = 0x00
+IN_WITNESS_UTXO = 0x01
+IN_SIGHASH_TYPE = 0x03
+IN_REDEEM_SCRIPT = 0x04
+IN_WITNESS_SCRIPT = 0x05
+IN_BIP32_DERIVATION = 0x06
+IN_PREVIOUS_TXID = 0x0E
+IN_OUTPUT_INDEX = 0x0F
+IN_SEQUENCE = 0x10
+IN_TAP_BIP32_DERIVATION = 0x16
+OUT_BIP32_DERIVATION = 0x02
+OUT_AMOUNT = 0x03
+OUT_SCRIPT = 0x04
+
+
+def tape_entry(key, val):
+ """One map entry: key-length control, value-length control, then the bytes."""
+ out = bytearray()
+
+ if len(key) <= 4:
+ out.append((len(key) - 1) % 4) # kl = 1 + (b % 4)
+ out.append(0x00) # long-key flag clear (set is b & 0x0F == 0x0F)
+ else:
+ out.append(0x00)
+ out.append(0x0F)
+ out.append(len(key) % TAPE_KV_MAX)
+
+ if len(val) < TAPE_VAL_SHORT:
+ out.append(len(val)) # vl = b % TAPE_VAL_SHORT
+ out.append(0x00) # long-value flag clear (set is b & 0x07 == 0x07)
+ else:
+ out.append(0x00)
+ out.append(0x07)
+ out.append(len(val) % TAPE_KV_MAX)
+
+ return bytes(out) + key + val
+
+
+def tape_map(entries, bind_txid=True, declared=None):
+ """A whole map: entry count, the entries, then the two trailing control bytes."""
+ out = bytearray()
+ out.append((len(entries) - 1) % TAPE_MAP_MAX) # n = 1 + (b % TAPE_MAP_MAX)
+ for key, val in entries:
+ out += tape_entry(key, val)
+ out.append(0x00 if bind_txid else 0x0F) # bind unless b & 0x0F == 0x0F
+ if declared is None:
+ out.append(0x00) # declared = actual entry count
+ else:
+ out.append(0x0F)
+ out.append(declared & 0xFF)
+ return bytes(out)
+
+
+# ── bitcoin bytes the app parses ──────────────────────────────────────────────
+def p2wpkh_spk(fill=0x11):
+ return bytes([0x00, 0x14]) + bytes([fill]) * 20
+
+
+def raw_legacy_tx(n_out=1, value=1000, fill=0x11):
+ """A minimal legacy (non-segwit) transaction: the shape psbt_parse_rawtx walks."""
+ tx = bytearray()
+ tx += (1).to_bytes(4, "little") # version
+ tx.append(1) # input count
+ tx += bytes(32) # prevout txid
+ tx += (0).to_bytes(4, "little") # prevout index
+ tx.append(0) # scriptSig length
+ tx += b"\xff" * 4 # sequence
+ tx.append(n_out) # output count
+ for i in range(n_out):
+ tx += (value + i).to_bytes(8, "little")
+ spk = p2wpkh_spk(fill)
+ tx.append(len(spk))
+ tx += spk
+ tx += (0).to_bytes(4, "little") # locktime
+ return bytes(tx)
+
+
+def witness_utxo(value=1000, fill=0x11):
+ spk = p2wpkh_spk(fill)
+ return value.to_bytes(8, "little") + bytes([len(spk)]) + spk
+
+
+def bip32_derivation():
+ """Master fingerprint then the full derivation path, 4 + 5*4 = 24 bytes.
+
+ build_wallet_policy writes key_info as "[00000000/purpose\'/coin\'/account\']", so
+ the fingerprint is zero and the path continues .../0/0. psbt_model rewrites both
+ this value and the 33-byte pubkey in the key to whatever the policy actually
+ derives -- extract_bip32_derivation compares against a hash of the wallet's own
+ xpub, which no mutation can reach. The shape has to be right here so there is an
+ entry of the correct length for it to rewrite.
+ """
+ hardened = [0x80000000 | v for v in (84, 1, 0)]
+ return bytes(4) + b"".join(
+ v.to_bytes(4, "little") for v in hardened + [0, 0]
+ )
+
+
+# ── input assembly ────────────────────────────────────────────────────────────
+def make_input(prefix, cmd_slot, tape, subtype=0, n_inputs=1, n_outputs=1,
+ descriptor=0, p2=0, tx_version=2, locktime=0):
+ slot0 = bytearray(SLOT_SIZE)
+ slot0[S0_TX_VERSION:S0_TX_VERSION + 4] = tx_version.to_bytes(4, "little")
+ slot0[S0_LOCKTIME:S0_LOCKTIME + 4] = locktime.to_bytes(4, "little")
+ # pm_build_scenario computes 1 + (b % PM_MAX_INPUTS), so subtract one here.
+ slot0[S0_N_INPUTS] = (n_inputs - 1) & 0xFF
+ slot0[S0_N_OUTPUTS] = (n_outputs - 1) & 0xFF
+ slot0[S0_SUBTYPE] = subtype
+ slot0[S0_DESCRIPTOR] = descriptor
+
+ ctrl = bytes([STRUCTURED_LANE_MIN, cmd_slot, 0x00, p2])
+ return prefix + ctrl + bytes(APP_HEADER_LEN) + bytes(slot0) + tape
+
+
+def output_map():
+ return tape_map([
+ (bytes([OUT_AMOUNT]), (5000).to_bytes(8, "little")),
+ (bytes([OUT_SCRIPT]), p2wpkh_spk(0x22)),
+ (bytes([OUT_BIP32_DERIVATION]) + bytes([0x02]) + bytes([0x33]) * 32,
+ bip32_derivation()),
+ ])
+
+
+def psbt_cases():
+ """(name, input-map entries, make_input kwargs, tape_map kwargs)."""
+ pk33 = bytes([0x02]) + bytes([0x33]) * 32
+ xonly32 = bytes([0x44]) * 32
+ idx0 = (0).to_bytes(4, "little")
+ txid_slot = bytes(32)
+ cases = []
+
+ # The legacy path: non-witness utxo present, witness utxo absent. This is the only
+ # route into psbt_parse_rawtx.c, via amount_from_psbt.c:60.
+ for tag, tx in (("1out", raw_legacy_tx(1)),
+ ("2out", raw_legacy_tx(2)),
+ ("bigval", raw_legacy_tx(1, value=0xFFFFFFFFFF))):
+ cases.append((f"nonwitness-{tag}", [
+ (bytes([IN_NON_WITNESS_UTXO]), tx),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ ], {}, {}))
+
+ # Same shape with the txid binding left as written, so the mismatch rejection at
+ # amount_from_psbt.c:73 stays reachable.
+ cases.append(("nonwitness-badtxid", [
+ (bytes([IN_NON_WITNESS_UTXO]), raw_legacy_tx(1)),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ ], {}, {"bind_txid": False}))
+
+ # An output index past the end of the parsed transaction.
+ cases.append(("nonwitness-oob-index", [
+ (bytes([IN_NON_WITNESS_UTXO]), raw_legacy_tx(1)),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), (7).to_bytes(4, "little")),
+ ], {}, {}))
+
+ # The segwit path.
+ cases.append(("witness", [
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ (bytes([IN_BIP32_DERIVATION]) + pk33, bip32_derivation()),
+ ], {}, {}))
+
+ # Both utxo kinds present: the gate at preprocess_inputs.c:158 passes either way
+ # and the two downstream branches disagree about which to trust.
+ cases.append(("both-utxo", [
+ (bytes([IN_NON_WITNESS_UTXO]), raw_legacy_tx(1)),
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ ], {}, {}))
+
+ # extract_bip32_derivation.c only runs for a key of exactly 1+33 or 1+32 bytes.
+ cases.append(("bip32-p2tr", [
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ (bytes([IN_TAP_BIP32_DERIVATION]) + xonly32,
+ bytes([0x00]) + bip32_derivation()),
+ ], {"subtype": 1, "descriptor": 1}, {}))
+
+ # Scripts, and the fields that gate the sighash and locktime arithmetic.
+ cases.append(("scripts", [
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_SIGHASH_TYPE]), (1).to_bytes(4, "little")),
+ (bytes([IN_REDEEM_SCRIPT]), p2wpkh_spk(0x33)),
+ (bytes([IN_WITNESS_SCRIPT]), bytes([0x51])),
+ (bytes([IN_PREVIOUS_TXID]), txid_slot),
+ (bytes([IN_OUTPUT_INDEX]), idx0),
+ (bytes([IN_SEQUENCE]), b"\xfe\xff\xff\xff"),
+ ], {}, {}))
+
+ return cases
+
+
+def build_seeds(prefix):
+ seeds = {}
+
+ # The tape is consumed as n_inputs input maps followed by n_outputs output maps
+ # (pm_build_inputs_tree then pm_build_outputs_tree), so a scenario's tape is just
+ # those maps concatenated.
+ for name, entries, kw, mapkw in psbt_cases():
+ # Spread over the SIGN_PSBT command slots and the sign-mode subtypes, so a
+ # seed is not pinned to one conversation shape.
+ for slot, subtype in ((0, 0), (1, 6), (2, 10)):
+ kwargs = dict(kw)
+ kwargs.setdefault("subtype", subtype)
+ seeds[f"psbt-{name}-s{slot}"] = make_input(
+ prefix, slot % N_SIGN_PSBT_SLOTS,
+ tape_map(entries, **mapkw) + output_map(), **kwargs
+ )
+
+ common = [
+ (bytes([IN_WITNESS_UTXO]), witness_utxo()),
+ (bytes([IN_PREVIOUS_TXID]), bytes(32)),
+ (bytes([IN_OUTPUT_INDEX]), (0).to_bytes(4, "little")),
+ ]
+
+ # Two inputs, so preprocess_inputs iterates and per-input state must hold across
+ # the loop; both must pass the utxo gate for preprocess_outputs to run at all.
+ seeds["psbt-two-inputs"] = make_input(
+ prefix, 0, tape_map(common) * 2 + output_map(), n_inputs=2
+ )
+ seeds["psbt-two-in-two-out"] = make_input(
+ prefix, 0, tape_map(common) * 2 + output_map() * 2,
+ n_inputs=2, n_outputs=2,
+ )
+
+ # A declared entry count that disagrees with the leaves actually served.
+ seeds["psbt-count-mismatch"] = make_input(
+ prefix, 0, tape_map(common, declared=9) + output_map()
+ )
+
+ return seeds
+
+
+def main():
+ out_dir = sys.argv[1] if len(sys.argv) > 1 else "."
+ os.makedirs(out_dir, exist_ok=True)
+
+ prefix_size = resolve_prefix_size(FUZZER_NAME)
+ prefix = resolve_seed_prefix(prefix_size, FUZZER_NAME)
+
+ seeds = build_seeds(prefix)
+ for name, data in sorted(seeds.items()):
+ with open(os.path.join(out_dir, name), "wb") as f:
+ f.write(data)
+
+ payloads = [len(d) - prefix_size for d in seeds.values()]
+ print(f" seeds: wrote {len(seeds)} file(s) to {out_dir} "
+ f"(prefix {prefix_size} B, payload {min(payloads)}..{max(payloads)} B)")
+
+
+if __name__ == "__main__":
+ main()
### src/common/merkle.c
@@ -55,14 +55,19 @@ int merkle_get_ith_direction(size_t size, size_t index, size_t i) {
if (size <= 1 || index >= size) {
return -1;
}
-
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Bound fuzz-only sizes to avoid catastrophic iteration counts.
+ if (size > ((size_t) 1U << MAX_MERKLE_TREE_DEPTH)) {
+ return -1;
+ }
+#endif
uint8_t n_directions = 0;
while (size > 1) {
uint8_t depth = ceil_lg(size);
// bitmask of the direction from the current node, where 0 = left, 1 = right;
- // also the number of leaves of the left subtree
- uint32_t mask = 1 << (depth - 1);
+ // also the number of leaves of the left subtree. Unsigned: depth can reach 32.
+ uint32_t mask = 1U << (depth - 1);
uint8_t is_right_child = (index & mask) != 0 ? 1 : 0;
### src/common/merkle.h
@@ -62,7 +62,7 @@ void merkle_combine_hashes(const uint8_t left[static 32],
static inline uint8_t ceil_lg(uint32_t n) {
uint8_t r = 0;
uint32_t t = 1;
- while (t < n) {
+ while (t < n && r < 32) {
t = 2 * t;
++r;
}
### src/common/parser_ext.c
@@ -115,7 +115,9 @@ bool parser_consolidate_buffers(buffer_t *buffers[2], size_t max_size) {
}
memmove(buffers[0]->ptr, buffers[0]->ptr + buffers[0]->offset, length0);
- memmove(buffers[0]->ptr + length0, buffers[1]->ptr + buffers[1]->offset, length1);
+ if (length1 > 0) {
+ memmove(buffers[0]->ptr + length0, buffers[1]->ptr + buffers[1]->offset, length1);
+ }
buffers[0]->offset = 0;
buffers[0]->size = length0 + length1;
return true;
### src/common/segwit_addr.c
@@ -44,7 +44,9 @@ static uint32_t bech32_final_constant(bech32_encoding enc) {
return 0; // suppress compiler warning on missing return value
}
-static const char* charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+// `const` array (not a string-literal pointer) so the alphabet lands in
+// .rodata, keeping it out of the Absolution prefix.
+static const char charset[] = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
static const int8_t charset_rev[128] = {
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
### src/handler/lib/check_merkle_tree_sorted.c
@@ -24,10 +24,12 @@ int call_check_merkle_tree_sorted_with_callback(dispatcher_context_t *dispatcher
for (size_t cur_el_idx = 0; cur_el_idx < size; cur_el_idx++) {
uint8_t cur_el[MAX_CHECK_MERKLE_TREE_SORTED_PREIMAGE_SIZE];
+ // tree size and leaf index are bounded well below 2^32 in practice;
+ // cast to the callee's uint32_t parameters explicitly.
int cur_el_len = call_get_merkle_leaf_element(dispatcher_context,
root,
- size,
- cur_el_idx,
+ (uint32_t) size,
+ (uint32_t) cur_el_idx,
cur_el,
sizeof(cur_el));
### src/handler/lib/policy.c
@@ -1964,7 +1964,7 @@ static int check_older_node_cb(const policy_node_t *node, void *callback_state)
(void) callback_state;
if (node->type == TOKEN_OLDER) {
const policy_node_with_uint32_t *older = (const policy_node_with_uint32_t *) node;
- uint32_t n = older->n & ~SEQUENCE_LOCKTIME_TYPE_FLAG;
+ uint32_t n = older->n & ~(uint32_t) SEQUENCE_LOCKTIME_TYPE_FLAG;
if (n < 1 || n > 65535) {
return -1;
}
@@ -2023,8 +2023,13 @@ int is_policy_sane(dispatcher_context_t *dispatcher_context,
if (memcmp(pubkey_i.compressed_pubkey,
pubkey_j.compressed_pubkey,
sizeof(pubkey_i.compressed_pubkey)) == 0) {
+#ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
// duplicated pubkey
return WITH_ERROR(-1, "Repeated pubkey in wallet policy");
+#else
+ // Fuzz mode: the crypto mock returns a constant pubkey for every
+ // derivation, so allow the collision to keep multi-key policies reachable.
+#endif
}
}
}
### src/handler/sign_psbt.h
@@ -34,7 +34,7 @@ typedef struct {
// matched with the current key expression in the signing flow
bool is_change;
- int address_index;
+ uint32_t address_index;
// For an output, its scriptPubKey
// for an input, the prevout's scriptPubKey (either from the non-witness-utxo, or from the
### src/handler/sign_psbt/musig_signing.c
@@ -368,12 +368,14 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
memcpy(musig_my_psbt_id + 33 + 33, keyexpr_info->tapleaf_hash, 32);
}
musig_pubnonce_t my_pubnonce;
- if (sizeof(musig_pubnonce_t) != call_get_merkleized_map_value(dc,
- &input->in_out.map,
- musig_my_psbt_id_key,
- 1 + psbt_id_len,
- my_pubnonce.raw,
- sizeof(musig_pubnonce_t))) {
+ // call_get_merkleized_map_value returns int (negative on error); cast the
+ // unsigned sizeof so the comparison doesn't trip UBSan's sign-change check.
+ if ((int) sizeof(musig_pubnonce_t) != call_get_merkleized_map_value(dc,
+ &input->in_out.map,
+ musig_my_psbt_id_key,
+ 1 + psbt_id_len,
+ my_pubnonce.raw,
+ sizeof(musig_pubnonce_t))) {
PRINTF("Missing or erroneous pubnonce in PSBT\n");
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
### src/handler/sign_psbt/preprocess_inputs.c
@@ -186,6 +186,12 @@ bool __attribute__((noinline)) preprocess_inputs(
return false;
}
+ // sanity check before accumulating, to avoid overflowing the total
+ if (input.prevout_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("Input amount exceeds Bitcoin total supply!\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
st->inputs_total_amount += input.prevout_amount;
}
@@ -220,6 +226,12 @@ bool __attribute__((noinline)) preprocess_inputs(
}
} else {
// we extract the scriptPubKey and prevout amount from the witness utxo
+ // sanity check before accumulating, to avoid overflowing the total
+ if (wit_utxo_prevout_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("Input amount exceeds Bitcoin total supply!\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
st->inputs_total_amount += wit_utxo_prevout_amount;
input.prevout_amount = wit_utxo_prevout_amount;
@@ -228,13 +240,6 @@ bool __attribute__((noinline)) preprocess_inputs(
}
}
- if (input.prevout_amount > BITCOIN_TOTAL_SUPPLY) {
- // sanity check to avoid overflows in amounts
- PRINTF("Input amount exceed Bitcoin total supply!\n");
- SEND_SW(dc, SW_INCORRECT_DATA);
- return false;
- }
-
// check if the input is internal; if not, continue
int is_internal = is_in_out_internal(dc, st, sign_psbt_cache, &input.in_out, true);
### src/handler/sign_psbt/swap_checks.c
@@ -41,9 +41,14 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
// Swap feature: check that wallet policy is a default one
if (!st->account.is_default) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: force the default-wallet branch so swap validation runs.
+ st->account.is_default = true;
+#else
PRINTF("Must be a default wallet policy for swap feature\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_NONDEFAULT_POLICY);
finalize_exchange_sign_transaction(false);
+#endif
}
// No external inputs allowed
@@ -54,11 +59,17 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
}
if (st->warnings.missing_nonwitnessutxo || st->warnings.non_default_sighash) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: clear warnings so the swap-validation tail runs.
+ st->warnings.missing_nonwitnessutxo = false;
+ st->warnings.non_default_sighash = false;
+#else
// Do not allow transactions with missing non-witness utxos or non-default sighash flags
PRINTF(
"Missing non-witness utxo or non-default sighash flags are not allowed during swaps\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_MISSING_NONWITNESSUTXO);
finalize_exchange_sign_transaction(false);
+#endif
}
uint64_t fee = st->inputs_total_amount - st->outputs.total_amount;
@@ -82,9 +93,23 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
swap_dest_idx = 1;
if (st->n_external_outputs != 2) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: the exact "2 external outputs" count depends on
+ // mocked change-classification and is rarely hit by mutation. Relax to
+ // >=1 so the OP_RETURN parser below still runs; swap_dest_idx=0 keeps the
+ // later output_scripts[] access in-bounds. Downstream address/hash/fee
+ // checks fail naturally, so findings past here need production-reachability
+ // triage.
+ if (st->n_external_outputs < 1) {
+ SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_WRONG_N_OF_OUTPUTS);
+ finalize_exchange_sign_transaction(false);
+ }
+ swap_dest_idx = 0;
+#else
PRINTF("Cross-chain swap transaction must have exactly 2 external outputs\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_WRONG_N_OF_OUTPUTS);
finalize_exchange_sign_transaction(false);
+#endif
}
uint8_t *opreturn_script = st->outputs.output_scripts[0];
### unit-tests/libs/mock_dispatcher.c
@@ -127,16 +127,24 @@ static void generate_merkle_proof(const uint8_t hashes[][32],
* =========================================================================== */
static void mock_add_to_response(const void *rdata, size_t rdata_len) {
- assert(g_active_mock != NULL);
+ /* No assert(): NDEBUG is defined in the fuzzing build, so an assert here is a
+ * no-op and an over-long request would corrupt memory silently. Drop instead. */
+ if (g_active_mock == NULL) {
+ return;
+ }
mock_dispatcher_t *m = g_active_mock;
- assert(m->request_len + rdata_len <= sizeof(m->request_buf));
+ if (m->request_len + rdata_len > sizeof(m->request_buf)) {
+ return;
+ }
memcpy(m->request_buf + m->request_len, rdata, rdata_len);
m->request_len += rdata_len;
}
static void mock_finalize_response(uint16_t sw) {
- assert(g_active_mock != NULL);
+ if (g_active_mock == NULL) {
+ return;
+ }
g_active_mock->last_sw = sw;
}
@@ -151,6 +159,15 @@ static void mock_set_ui_dirty(void) {
/* ---- Client command handlers ---- */
static int handle_get_preimage(mock_dispatcher_t *m) {
+ /* The element queue belongs to the command immediately before this one. Only
+ * GET_MORE_ELEMENTS may inherit it; every other command starts a fresh reply, so
+ * clear it here rather than relying on the next overflow to overwrite it. Without
+ * this, a GET_MORE_ELEMENTS arriving after a command that fitted in one response
+ * is served leftovers from an earlier round. */
+ m->queue.count = 0;
+ m->queue.head = 0;
+ m->queue.element_size = 0;
+
/* Request format: <CCMD_GET_PREIMAGE:1> <hash_type:1> <hash:32> */
if (m->request_len < 1 + 1 + 32) {
return -1;
@@ -232,10 +249,14 @@ static int handle_get_more_elements(mock_dispatcher_t *m) {
static int handle_yield(mock_dispatcher_t *m) {
/* Store everything after the command byte as a yielded value */
- assert(m->n_yielded < MOCK_MAX_YIELDED);
+ if (m->n_yielded >= MOCK_MAX_YIELDED) {
+ return -1;
+ }
size_t data_len = m->request_len > 0 ? m->request_len - 1 : 0;
- assert(data_len <= MOCK_MAX_YIELDED_LEN);
+ if (data_len > MOCK_MAX_YIELDED_LEN) {
+ return -1;
+ }
m->yielded[m->n_yielded].len = data_len;
if (data_len > 0) {
@@ -249,6 +270,15 @@ static int handle_yield(mock_dispatcher_t *m) {
}
static int handle_get_merkle_leaf_proof(mock_dispatcher_t *m) {
+ /* The element queue belongs to the command immediately before this one. Only
+ * GET_MORE_ELEMENTS may inherit it; every other command starts a fresh reply, so
+ * clear it here rather than relying on the next overflow to overwrite it. Without
+ * this, a GET_MORE_ELEMENTS arriving after a command that fitted in one response
+ * is served leftovers from an earlier round. */
+ m->queue.count = 0;
+ m->queue.head = 0;
+ m->queue.element_size = 0;
+
/* Request: <cmd:1> <merkle_root:32> <tree_size:varint> <leaf_index:varint> */
buffer_t req = buffer_create(m->request_buf + 1, m->request_len - 1);
@@ -314,6 +344,15 @@ static int handle_get_merkle_leaf_proof(mock_dispatcher_t *m) {
}
static int handle_get_merkle_leaf_index(mock_dispatcher_t *m) {
+ /* The element queue belongs to the command immediately before this one. Only
+ * GET_MORE_ELEMENTS may inherit it; every other command starts a fresh reply, so
+ * clear it here rather than relying on the next overflow to overwrite it. Without
+ * this, a GET_MORE_ELEMENTS arriving after a command that fitted in one response
+ * is served leftovers from an earlier round. */
+ m->queue.count = 0;
+ m->queue.head = 0;
+ m->queue.element_size = 0;
+
/* Request: <cmd:1> <merkle_root:32> <leaf_hash:32> */
if (m->request_len < 1 + 32 + 32) return -1;
@@ -355,11 +394,9 @@ static int handle_get_merkle_leaf_index(mock_dispatcher_t *m) {
/* ---- Main interruption handler ---- */
-static int mock_process_interruption(dispatcher_context_t *dc) {
- mock_dispatcher_t *m = g_active_mock;
- assert(m != NULL);
- assert(dc == &m->dc);
-
+/* Run the accumulated request through its handler and apply the tamper hook.
+ * Shared by mock_process_interruption() and mock_dispatcher_handle_ccmd(). */
+static int run_client_command(mock_dispatcher_t *m) {
if (m->request_len == 0) {
return -1;
}
@@ -409,6 +446,19 @@ static int mock_process_interruption(dispatcher_context_t *dc) {
}
}
+ return 0;
+}
+
+static int mock_process_interruption(dispatcher_context_t *dc) {
+ mock_dispatcher_t *m = g_active_mock;
+ if (m == NULL || dc != &m->dc) {
+ return -1;
+ }
+
+ if (run_client_command(m) < 0) {
+ return -1;
+ }
+
/* Set read_buffer to point at the response */
dc->read_buffer = buffer_create(m->response_buf, m->response_len);
return 0;
@@ -454,22 +504,33 @@ int mock_dispatcher_teardown(void **state) {
return 0;
}
-void mock_dispatcher_add_preimage(mock_dispatcher_t *mock, const uint8_t *data, size_t len) {
- assert(mock->n_preimages < MOCK_MAX_PREIMAGES);
- assert(len <= MOCK_BUF_SIZE);
+int mock_dispatcher_add_preimage(mock_dispatcher_t *mock, const uint8_t *data, size_t len) {
+ if (mock->n_preimages >= MOCK_MAX_PREIMAGES || len > MOCK_BUF_SIZE) {
+ return -1;
+ }
size_t idx = mock->n_preimages++;
mock_sha256(data, len, mock->preimages[idx].hash);
memcpy(mock->preimages[idx].data, data, len);
mock->preimages[idx].len = len;
+ return 0;
}
-void mock_dispatcher_add_list(mock_dispatcher_t *mock,
- const uint8_t *const *elements,
- const size_t *element_lens,
- size_t n) {
- assert(mock->n_trees < MOCK_MAX_TREES);
- assert(n <= MOCK_MAX_TREE_ELEMS);
+int mock_dispatcher_add_list(mock_dispatcher_t *mock,
+ const uint8_t *const *elements,
+ const size_t *element_lens,
+ size_t n) {
+ /* Validated up front: a mid-loop bail would leave a tree whose root does not
+ * match the leaves it serves, which the app would report as its own error. */
+ if (mock->n_trees >= MOCK_MAX_TREES || n > MOCK_MAX_TREE_ELEMS ||
+ mock->n_preimages + n > MOCK_MAX_PREIMAGES) {
+ return -1;
+ }
+ for (size_t i = 0; i < n; i++) {
+ if (element_lens[i] > 256) {
+ return -1;
+ }
+ }
mock_merkle_tree_t *tree = &mock->trees[mock->n_trees++];
memset(tree, 0, sizeof(mock_merkle_tree_t));
@@ -481,8 +542,6 @@ void mock_dispatcher_add_list(mock_dispatcher_t *mock,
* 3. Register the preimage (0x00 || element) so GET_PREIMAGE can retrieve it
*/
for (size_t i = 0; i < n; i++) {
- assert(element_lens[i] <= 256);
-
memcpy(tree->raw_elements[i], elements[i], element_lens[i]);
tree->raw_element_lens[i] = element_lens[i];
@@ -498,18 +557,21 @@ void mock_dispatcher_add_list(mock_dispatcher_t *mock,
/* Compute Merkle root */
build_merkle_root((const uint8_t(*)[32]) tree->element_hashes, 0, n, tree->root);
+ return 0;
}
-void mock_dispatcher_add_map(mock_dispatcher_t *mock,
+int mock_dispatcher_add_map(mock_dispatcher_t *mock,
const uint8_t *const *keys,
const size_t *key_lens,
const uint8_t *const *values,
const size_t *value_lens,
- size_t n,
- merkleized_map_commitment_t *out_commitment) {
+ size_t n,
+ merkleized_map_commitment_t *out_commitment) {
/* Sort items by key (simple insertion sort, matching Python's sorted()) */
size_t sorted_indices[MOCK_MAX_TREE_ELEMS];
- assert(n <= MOCK_MAX_TREE_ELEMS);
+ if (n > MOCK_MAX_TREE_ELEMS) {
+ return -1;
+ }
for (size_t i = 0; i < n; i++) {
sorted_indices[i] = i;
}
@@ -544,10 +606,14 @@ void mock_dispatcher_add_map(mock_dispatcher_t *mock,
/* Register both keys and values as Merkle trees (mirrors add_known_mapping) */
size_t keys_tree_idx = mock->n_trees;
- mock_dispatcher_add_list(mock, sorted_keys, sorted_key_lens, n);
+ if (mock_dispatcher_add_list(mock, sorted_keys, sorted_key_lens, n) < 0) {
+ return -1;
+ }
size_t values_tree_idx = mock->n_trees;
- mock_dispatcher_add_list(mock, sorted_values, sorted_value_lens, n);
+ if (mock_dispatcher_add_list(mock, sorted_values, sorted_value_lens, n) < 0) {
+ return -1;
+ }
/* Fill in the commitment */
out_commitment->size = (uint64_t) n;
@@ -556,6 +622,7 @@ void mock_dispatcher_add_map(mock_dispatcher_t *mock,
/* The mock builds the keys tree already sorted, so the commitment satisfies the invariant that
* the by-key value readers assert on (matching what call_get_merkleized_map guarantees). */
out_commitment->_keys_are_sorted = true;
+ return 0;
}
/* ---- Helper: register a psbt_map_t with the mock ---- */
@@ -643,3 +710,108 @@ int mock_dispatcher_add_psbt(mock_dispatcher_t *mock,
return 0;
}
+
+void mock_dispatcher_reset(mock_dispatcher_t *mock) {
+ mock->request_len = 0;
+ mock->response_len = 0;
+ mock->last_sw = 0;
+ mock->n_preimages = 0;
+ mock->n_trees = 0;
+ mock->n_yielded = 0;
+ mock->queue.count = 0;
+ mock->queue.head = 0;
+ mock->queue.element_size = 0;
+ mock->tamper_call_count = 0;
+
+ /* Callbacks in mock->dc are untouched, but the file-scope pointer they use to
+ * find their owner must name this instance. */
+ g_active_mock = mock;
+}
+
+int mock_dispatcher_handle_ccmd(mock_dispatcher_t *mock,
+ const uint8_t *request,
+ size_t request_len,
+ uint8_t *response,
+ size_t response_cap,
+ size_t *response_len) {
+ if (request_len == 0 || request_len > sizeof(mock->request_buf)) {
+ return -1;
+ }
+
+ memcpy(mock->request_buf, request, request_len);
+ mock->request_len = request_len;
+
+ if (run_client_command(mock) < 0) {
+ return -1;
+ }
+ if (mock->response_len > response_cap) {
+ return -1;
+ }
+
+ memcpy(response, mock->response_buf, mock->response_len);
+ *response_len = mock->response_len;
+ return 0;
+}
+
+int mock_dispatcher_tree_begin(mock_dispatcher_t *mock) {
+ if (mock->n_trees >= MOCK_MAX_TREES) {
+ return -1;
+ }
+ int idx = (int) mock->n_trees++;
+ mock_merkle_tree_t *tree = &mock->trees[idx];
+ tree->n_elements = 0;
+ memset(tree->root, 0, sizeof(tree->root));
+ return idx;
+}
+
+int mock_dispatcher_tree_add_leaf(mock_dispatcher_t *mock, int tree,
+ const uint8_t *data, size_t len) {
+ if (tree < 0 || (size_t) tree >= mock->n_trees) {
+ return -1;
+ }
+ mock_merkle_tree_t *t = &mock->trees[tree];
+ if (t->n_elements >= MOCK_MAX_TREE_ELEMS || len > sizeof(t->raw_elements[0])) {
+ return -1;
+ }
+ if (mock->n_preimages >= MOCK_MAX_PREIMAGES || 1 + len > MOCK_BUF_SIZE) {
+ return -1;
+ }
+
+ size_t i = t->n_elements++;
+ memcpy(t->raw_elements[i], data, len);
+ t->raw_element_lens[i] = len;
+ merkle_compute_element_hash(data, len, t->element_hashes[i]);
+
+ /* GET_PREIMAGE serves leaves as (0x00 || element), as add_list() does. */
+ uint8_t prefixed[1 + sizeof(t->raw_elements[0])];
+ prefixed[0] = 0x00;
+ memcpy(prefixed + 1, data, len);
+ mock_dispatcher_add_preimage(mock, prefixed, 1 + len);
+ return 0;
+}
+
+int mock_dispatcher_tree_add_leaf_hash(mock_dispatcher_t *mock, int tree,
+ const uint8_t hash[32]) {
+ if (tree < 0 || (size_t) tree >= mock->n_trees) {
+ return -1;
+ }
+ mock_merkle_tree_t *t = &mock->trees[tree];
+ if (t->n_elements >= MOCK_MAX_TREE_ELEMS) {
+ return -1;
+ }
+ size_t i = t->n_elements++;
+ memcpy(t->element_hashes[i], hash, 32);
+ t->raw_element_lens[i] = 0;
+ return 0;
+}
+
+void mock_dispatcher_tree_end(mock_dispatcher_t *mock, int tree, uint8_t out_root[32]) {
+ if (tree < 0 || (size_t) tree >= mock->n_trees) {
+ return;
+ }
+ mock_merkle_tree_t *t = &mock->trees[tree];
+ build_merkle_root((const uint8_t(*)[32]) t->element_hashes, 0, t->n_elements, t->root);
+ if (out_root != NULL) {
+ memcpy(out_root, t->root, 32);
+ }
+}
### unit-tests/libs/mock_dispatcher.h
@@ -38,7 +38,12 @@
/* ---- Configuration ---- */
#define MOCK_MAX_PREIMAGES 1024
-#define MOCK_MAX_TREES 16
+/* A PSBT scenario spends 1 (key-info) + 2 (global) + 1 (inputs list) + 2 per input
+ * map + 1 (outputs list) + 2 per output map, i.e. 5 + 2*(n_in + n_out). At 16 that
+ * caps n_in + n_out at 5 against declared maxima of 8 and 8, so 54 of 64 uniform
+ * (n_in, n_out) pairs made the builder refuse and the iteration dispatch nothing --
+ * 84.4% of SIGN_PSBT inputs. 40 lifts the cap to 17. */
+#define MOCK_MAX_TREES 40
#define MOCK_MAX_TREE_ELEMS 1024
#define MOCK_MAX_YIELDED 1024
#define MOCK_MAX_QUEUE_ELEMS 2048
@@ -140,6 +145,54 @@ typedef struct {
*/
void mock_dispatcher_init(mock_dispatcher_t *mock);
+/**
+ * Clear per-scenario state without re-zeroing the whole struct.
+ *
+ * mock_dispatcher_init() memsets several megabytes (the preimage store and the
+ * Merkle trees dominate), which is fine once per test but not once per fuzzing
+ * iteration. Only the counters gate what is readable, so resetting them is
+ * enough; the dc callbacks and any tamper hook are left in place.
+ */
+void mock_dispatcher_reset(mock_dispatcher_t *mock);
+
+/**
+ * Answer one client command without going through dispatcher_context_t.
+ *
+ * Same handlers, same tamper hook as mock_process_interruption(); this entry
+ * point exists for callers that already own the transport (a fuzzing harness
+ * intercepting os_io_rx_evt(), for instance) and just need request bytes turned
+ * into response bytes.
+ *
+ * @return 0 on success, -1 on a malformed request, an unknown command, an
+ * oversized response, or a tamper hook simulating a comms failure.
+ */
+int mock_dispatcher_handle_ccmd(mock_dispatcher_t *mock,
+ const uint8_t *request,
+ size_t request_len,
+ uint8_t *response,
+ size_t response_cap,
+ size_t *response_len);
+
+/**
+ * @name Incremental Merkle tree construction
+ *
+ * mock_dispatcher_add_list() needs every element up front. These build a tree
+ * leaf by leaf, which is what a caller assembling elements in a loop wants.
+ * Each leaf is registered as a preimage exactly as add_list() does.
+ * @{
+ */
+/** @return tree index, or -1 if no tree slot is free. */
+int mock_dispatcher_tree_begin(mock_dispatcher_t *mock);
+/** @return 0, or -1 if the tree is full or the element too large. */
+int mock_dispatcher_tree_add_leaf(mock_dispatcher_t *mock, int tree,
+ const uint8_t *data, size_t len);
+/** Add a leaf by its hash, for elements whose preimage is registered elsewhere. */
+int mock_dispatcher_tree_add_leaf_hash(mock_dispatcher_t *mock, int tree,
+ const uint8_t hash[32]);
+/** Compute the root; @p out_root may be NULL. */
+void mock_dispatcher_tree_end(mock_dispatcher_t *mock, int tree, uint8_t out_root[32]);
+/** @} */
+
/**
* cmocka setup fixture: allocates a mock_dispatcher_t on the heap, initializes
* it, and stores the pointer in *state.
@@ -156,7 +209,7 @@ int mock_dispatcher_teardown(void **state);
* Register a known preimage. Computes sha256(data) and stores the mapping.
* The mock will respond to CCMD_GET_PREIMAGE requests matching this hash.
*/
-void mock_dispatcher_add_preimage(mock_dispatcher_t *mock, const uint8_t *data, size_t len);
+int mock_dispatcher_add_preimage(mock_dispatcher_t *mock, const uint8_t *data, size_t len);
/**
* Set a tamper hook to simulate malicious client behavior.
@@ -178,11 +231,13 @@ static inline void mock_dispatcher_set_tamper_hook(mock_dispatcher_t *mock,
* @param elements Array of pointers to element data.
* @param element_lens Array of element lengths.
* @param n Number of elements.
+ * @return 0, or -1 if a capacity or per-element limit would be exceeded. Nothing is
+ * registered on failure, so a tree's root always matches the leaves it serves.
*/
-void mock_dispatcher_add_list(mock_dispatcher_t *mock,
- const uint8_t *const *elements,
- const size_t *element_lens,
- size_t n);
+int mock_dispatcher_add_list(mock_dispatcher_t *mock,
+ const uint8_t *const *elements,
+ const size_t *element_lens,
+ size_t n);
/**
* Register a key-value mapping (like a PSBT map) and its Merkle trees.
@@ -198,7 +253,7 @@ void mock_dispatcher_add_list(mock_dispatcher_t *mock,
* @param n Number of key-value pairs.
* @param out_commitment Filled with the merkleized map commitment (size, keys_root, values_root).
*/
-void mock_dispatcher_add_map(mock_dispatcher_t *mock,
+int mock_dispatcher_add_map(mock_dispatcher_t *mock,
const uint8_t *const *keys,
const size_t *key_lens,
const uint8_t *const *values,Why this scored 15/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.