Merge pull request #536 from LedgerHQ/bip322
What changed, and why it matters
This commit adds a new feature to the Ledger Bitcoin app: support for BIP-322 generic signed messages and proof-of-funds. It lets users sign messages and prove ownership of coins through a PSBT, with on-screen review. The change is a feature addition, not a documented security fix, but it touches sensitive signing code and introduces new validation rules that could affect app safety if implemented incorrectly.
Treat this as a high-priority feature review. Audit validate_bip322_request() for bypasses of the to_spend binding, ensure the message stream cannot be altered between the hashing and display passes, verify that proof-of-funds amount aggregation cannot include external inputs, and run the new unit/integration tests on all target devices. Consider a security review before release because it changes how the app interprets PSBTs and produces signatures.
Security signals we found
New signing path added to SIGN_PSBT handler
On-device recomputation of to_spend txid binds signature to displayed message
Rejects non-default sighashes, external inputs, timelocks, and Exchange/swap context
Exempts BIP-322 virtual to_spend input from missing non-witness-UTXO warning
Adds new error codes for BIP-322 structural/safety failures
Large diff (+3,049/-151) across signing, UI, tests, and generated vectors
Evidence from the diff
The merge commit implements BIP-322 v2.0.0 message signing inside the existing SIGN_PSBT handler. It adds detection of the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) global field, hashes the message with the BIP-322 tagged hash, validates the to_sign transaction structure, recomputes the to_spend txid as a security anchor, and displays the request as a message signature. Proof-of-funds requests with additional wallet-owned inputs are supported. Timelocks, external inputs, non-default sighashes, and use from the Exchange app are rejected. Unit tests, integration tests, and snapshot tests are included.
Changed components
src/handler/sign_psbt.csrc/handler/sign_psbt/bip322_validation.csrc/handler/sign_psbt/init_global_state.csrc/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/transaction_display.csrc/common/bip322.csrc/ui/display.csrc/ui/display_nbgl.csrc/error_codes.hsrc/common/psbt.hInspect captured patch +3049 / −151
### .github/workflows/codegen-check.yml
@@ -1,9 +1,11 @@
-name: Cleartext codegen check
+name: Codegen check
-# Ensures the generated BIP388 cleartext files committed to the repo are in sync
-# with the TOML specs they are derived from. If someone edits
-# specs/bip388/*.toml without re-running specs/bip388/gen.py, this
-# job fails and points at the drifted files.
+# Ensures the generated files committed to the repo are in sync with the specs
+# they are derived from:
+# - the BIP388 cleartext files, generated from specs/bip388/*.toml
+# - the BIP-322 unit-test vectors, generated from specs/bip322/basic-test-vectors.json
+# If someone edits a spec without re-running the corresponding gen.py, this job
+# fails and points at the drifted files.
on:
workflow_dispatch:
@@ -30,3 +32,6 @@ jobs:
- name: Check codegen is in sync with the TOML specs
run: python3 specs/bip388/gen.py --check
+
+ - name: Check the BIP-322 unit-test vectors are in sync with the pinned JSON
+ run: python3 specs/bip322/gen.py --check
### CHANGELOG.md
@@ -9,6 +9,10 @@ Dates are in `dd-mm-yyyy` format.
## [2.X.X] - XX-XX-XXXX
+### Added
+
+- Support for [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) (v2.0.0) generic signed messages: a PSBT carrying the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (0x09) is now verified to have the exact BIP-322 *to_sign* structure and reviewed on-screen as a message signature (account, address and message), instead of being shown as a transaction with an `OP_RETURN` output and no fees. Proof-of-funds requests (additional inputs spending real coins of the account) are supported, with the total proven amount shown in the review. Works with any supported wallet policy, including multisig and miniscript.
+
### Fixed
- The transaction lock time is now determined as BIP-370 prescribes, from each input's `PSBT_IN_REQUIRED_TIME_LOCKTIME` / `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME` together with `PSBT_GLOBAL_FALLBACK_LOCKTIME`, instead of always using the fallback verbatim. PSBTs not using the individual preferred locktime fields are unaffected, as they will keep depending on `PSBT_GLOBAL_FALLBACK_LOCKTIME` alone.
### doc/bitcoin.md
@@ -256,6 +256,28 @@ In both cases, the `tapleaf_hash` is only present for a Script path spend.
Other values of the `<tag>` are undefined and reserved for future use.
+#### BIP-322 message signing
+
+If the PSBT contains the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (`0x09`, defined in [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki), version 2.0.0), the PSBT is treated as a request to sign the BIP-322 *to_sign* virtual transaction for the message contained in the field, and is reviewed on-screen as a **message signature** (account, address whose ownership is being proven, and the message), never as a transaction. If the message is too long (more than 640 bytes) or not printable ASCII (which includes any UTF-8 text with non-ASCII characters), its plain sha256 hash is shown instead, matching the behavior of the legacy `SIGN_MESSAGE` command (note that this display hash is *not* the BIP-322 tagged hash that the signature commits to).
+
+Before showing the message, the app enforces the exact structure mandated by BIP-322, and refuses to sign otherwise:
+
+- exactly one output, with zero value and a script equal to a single `OP_RETURN` byte;
+- transaction version 0 or 2; locktime 0; the first input with an explicit sequence of 0 (BIP-322 gives its sequence, together with the locktime, the meaning of a timelock, and timelocked signatures are not supported yet);
+- all signatures use `SIGHASH_ALL` (or `SIGHASH_DEFAULT` for taproot inputs);
+- the first input's prevout must reference output 0 of the *to_spend* transaction that the app independently recomputes from the message (as per BIP-322, using the tagged hash with tag `BIP0322-signed-message`) and the input's own scriptPubKey; the *to_spend* output has zero value.
+
+The last rule is the security anchor of the flow: it guarantees that the message shown on-screen is exactly the message committed to by the produced signature, and that the signed transaction is provably unspendable (the *to_spend* transaction's input references the null outpoint).
+
+**Proof of funds**: any input beyond the first makes the request a BIP-322 *proof of funds*. The first input must still be the *message_challenge* input spending the recomputed *to_spend* transaction (as clarified in BIP-322 v2.0.0, it is not optional); a request made only of real UTXOs is rejected. The additional inputs spend real UTXOs; each one must belong to the wallet policy (external inputs are rejected) and have a sequence that is either 0 or has the [BIP-68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) relative-timelock disable flag set (which includes the final sequence `0xFFFFFFFF`, explicit or implied by an omitted `PSBT_IN_SEQUENCE`). Any other sequence is rejected: with version 2 it would be a relative timelock on *to_sign* (not supported yet); with version 0 it is meaningless. The total amount of the proven coins is shown in the review as an additional "Proving funds" line. The usual UTXO authentication rules apply to these inputs, including the warning for segwitv0 inputs missing the non-witness UTXO. The first input is exempt from that warning, even in a proof of funds: the *to_spend* transaction it spends is entirely recomputed by the app, so its non-witness UTXO is not needed.
+
+A structurally invalid PSBT carrying the field fails with `SW_INCORRECT_DATA`; valid BIP-322 requests using features that are not yet supported (timelocks, i.e. a non-zero locktime, a non-zero sequence on the first input, or a relative timelock on a proof-of-funds input) fail with `SW_NOT_SUPPORTED`. See `error_codes.h` for the specific error codes.
+
+The signatures are yielded exactly as for a regular transaction; the client is responsible for finalizing the transaction and encoding the signature in one of the formats defined by BIP-322 (`smp`/`ful`/`pof` prefixes). This command is not allowed when the app is started from the Exchange app (swap).
+
+##### Deviation from BIP-322: no shared non-witness UTXOs
+
+As an optimization for proofs of funds, BIP-322 allows omitting the non-witness UTXO of an input that spends an output of the same transaction as an earlier input. This optimization is **not supported**: each input is authenticated on its own, exactly as in a regular `SIGN_PSBT`, so a legacy input without its non-witness UTXO is rejected, and a segwitv0 input without it triggers the missing non-witness UTXO warning. Clients should therefore include the non-witness UTXO in every input, even when it repeats the one of an earlier input.
#### Client commands
### specs/bip322/README.md
@@ -0,0 +1,43 @@
+# BIP-322 test vectors
+
+This folder holds a **pinned, verbatim copy** of the test vectors published with
+[BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki), and the
+generator that turns them into a C array for the unit tests.
+
+## Files
+
+| File | Role |
+|---------------------------|------|
+| `basic-test-vectors.json` | Byte-for-byte copy of `bip-0322/basic-test-vectors.json` from the bips repository. Never edit it by hand: replace it with a newer upstream copy and update the pin below. |
+| `gen.py` | Code generator. Reads the `tx_hashes` table of the JSON, independently recomputes every hash (as a guard against misreading the file), and (over)writes the generated C file listed below, or verifies that it is up to date. |
+
+## Pin
+
+| | |
+|---|---|
+| Upstream commit | [`d77863fb9e9be7829ad8bb51694b9ba80a786766`](https://github.com/bitcoin/bips/blob/d77863fb9e9be7829ad8bb51694b9ba80a786766/bip-0322/basic-test-vectors.json) (2026-05-06, "BIP-0322: update test vectors") |
+| BIP-322 version | 2.0.0 |
+| sha256 | `a9184da5687b9cb7a1863807f4a8f4d743ec07d4f683f707cb85ce72f5b33f47` |
+
+## Generated files
+
+`gen.py` overwrites this file; it is committed to the repo but must never be edited
+by hand (it carries a `// Generated by specs/bip322/gen.py. DO NOT EDIT.` banner):
+
+- `unit-tests/bip322_vectors.inc.c` — the `tx_hashes` vectors as a static C array
+ (`BIP322_TX_HASHES_VECTORS`), consumed by `unit-tests/test_bip322.c`.
+
+The `simple` and `error` tables of the JSON (complete signatures for given private
+keys, and malformed signatures) are about signature verification, which the app does
+not do; they are not consumed.
+
+## Updating the vectors
+
+```
+curl -sL https://raw.githubusercontent.com/bitcoin/bips/<commit>/bip-0322/basic-test-vectors.json \
+ -o specs/bip322/basic-test-vectors.json
+python3 specs/bip322/gen.py
+```
+
+then update the pin table above and commit everything. CI runs
+`python3 specs/bip322/gen.py --check` and fails if the generated file drifted.
### specs/bip322/basic-test-vectors.json
@@ -0,0 +1,137 @@
+{
+ "tx_hashes": [
+ {
+ "message": "",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "message_hash": "c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1",
+ "to_spend_tx_hash": "c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7",
+ "to_sign_tx_hash": "1e9654e951a5ba44c8604c4de6c67fd78a27e81dcadcfe1edf638ba3aaebaed6"
+ },
+ {
+ "message": "Hello World",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "message_hash": "f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a",
+ "to_spend_tx_hash": "b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b",
+ "to_sign_tx_hash": "88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf"
+ },
+ {
+ "message": "UTF-8 support: öäüéàè 测试文本 \uD83D\uDE04",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "message_hash": "43936b237ea38c7794eb5d755e0d220b6db92ebfc5c8f482759d22b1286376d7",
+ "to_spend_tx_hash": "c8f4f525fe8afb1bc09b44175bd2096f079c98425e8a1be676b712add1fb62f0",
+ "to_sign_tx_hash": "8f488e06b89eafd019ec528109eafaf7f1d1811fd617aa1eeb9658f1c1be6586"
+ }
+ ],
+ "simple": [
+ {
+ "message": "",
+ "private_keys": [
+ "L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k"
+ ],
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "type": "p2wpkh",
+ "witness_script": "",
+ "bip322_signatures": [
+ "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=",
+ "smpAkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy"
+ ]
+ },
+ {
+ "message": "Hello World",
+ "private_keys": [
+ "L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k"
+ ],
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "type": "p2wpkh",
+ "witness_script": "",
+ "bip322_signatures": [
+ "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=",
+ "smpAkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy"
+ ]
+ },
+ {
+ "message": "This will be a p2wsh 3-of-3 multisig BIP 322 signed message",
+ "private_keys": [
+ "L4DksdGZ4KQJfcLHD5Dv25fu8Rxyv7hHi2RjZR4TYzr8c6h9VNrp",
+ "KzSRqnCVwjzY8id2X5oHEJWXkSHwKUYaAXusjwgkES8BuQPJnPNu",
+ "L1zt9Rw7HrU7jaguMbVzhiX8ffuVkmMis5wLHddXYuHWYf8u8uRj"
+ ],
+ "address": "bc1qp0ahvfh83088w49k405szqgg4f3pptr7p2g06tdxfjcd40z4lh4q95lsz9",
+ "type": "p2wsh-multisig-3of3",
+ "witness_script": "5321027568b11f122ff8a7bc1c57e5c7642055bc618967b2f7bfe8e11fe99903c94dd321020a8bdf79cfa421d9655e9282800f115ff1d9db1e721ceb4248a3fcfec7faa67c21030c529e0ea40a00975d202624e39915daf7bdd2b71f31aa08596838781ce5f33a53ae",
+ "bip322_signatures": [
+ "smpBQBHMEQCIFX9aaqPJWq2Ff2kpen5bFDTid+ehgUOpHV0LfjncXy4AiA3GNicF7aKPzdpa9PCpmaYQs3pHd+qbvvhXdxOCKCAMAFIMEUCIQD/ELXg6CNYyUQijCg96JtgvgjZb9dsl1Ctof4QAeyTcQIgVM/1AAblFl/DCt6A1gJg+T/i2qU5SQD09+chFJzolRwBSDBFAiEAlqRfSFyWNVQhvaCnmeV5tyneiCWMTcFbuujoD/pFa3wCIGnZjfQb8NolSYq9asV+ZeBSkCGHJcqnaV4JYS5MYPEGAWlTIQJ1aLEfEi/4p7wcV+XHZCBVvGGJZ7L3v+jhH+mZA8lN0yECCovfec+kIdllXpKCgA8RX/HZ2x5yHOtCSKP8/sf6pnwhAwxSng6kCgCXXSAmJOOZFdr3vdK3HzGqCFloOHgc5fM6U64="
+ ]
+ },
+ {
+ "message": "No prefix fallback",
+ "private_keys": [
+ "KyrSGCFPhqZMjCe5fNTYddiLMp4tMj4gLKuJ26TsB2rvr1VJGPbt"
+ ],
+ "address": "bc1pss0zhytly75awhm6x2hhvd5lnzv3vssgrf9axfheq8ldyzn88ges79fler",
+ "type": "p2tr",
+ "witness_script": "",
+ "bip322_signatures": [
+ "AUCJYOwOjxYAvatTAGYaVlNXBVyFuc4MwNQkOuK2tl8xhfKDONd0NjfYyNSYcRqeCp8hsAnCEPHAVEkO9h6vbQ/R"
+ ]
+ }
+ ],
+ "error": [
+ {
+ "description": "invalid base64 encoding",
+ "message": "",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "signature": "not-valid-base64!!!",
+ "error_substr": "base64"
+ },
+ {
+ "description": "empty signature",
+ "message": "",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "signature": "",
+ "error_substr": "signature too short"
+ },
+ {
+ "description": "wrong message for valid simple p2wpkh signature (empty message was signed)",
+ "message": "Wrong message that was not signed",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "signature": "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=",
+ "error_substr": "invalid signature"
+ },
+ {
+ "description": "wrong address for valid simple p2wpkh signature (signed for different address)",
+ "message": "",
+ "address": "bc1qp0ahvfh83088w49k405szqgg4f3pptr7p2g06tdxfjcd40z4lh4q95lsz9",
+ "signature": "smpAkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=",
+ "error_substr": "invalid signature"
+ },
+ {
+ "description": "empty witness stack (single zero byte)",
+ "message": "",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "signature": "smpAA==",
+ "error_substr": "invalid signature"
+ },
+ {
+ "description": "wrong message for valid simple p2wsh 3-of-3 multisig signature",
+ "message": "This is not the message that was signed",
+ "address": "bc1qp0ahvfh83088w49k405szqgg4f3pptr7p2g06tdxfjcd40z4lh4q95lsz9",
+ "signature": "smpBQBHMEQCIFX9aaqPJWq2Ff2kpen5bFDTid+ehgUOpHV0LfjncXy4AiA3GNicF7aKPzdpa9PCpmaYQs3pHd+qbvvhXdxOCKCAMAFIMEUCIQD/ELXg6CNYyUQijCg96JtgvgjZb9dsl1Ctof4QAeyTcQIgVM/1AAblFl/DCt6A1gJg+T/i2qU5SQD09+chFJzolRwBSDBFAiEAlqRfSFyWNVQhvaCnmeV5tyneiCWMTcFbuujoD/pFa3wCIGnZjfQb8NolSYq9asV+ZeBSkCGHJcqnaV4JYS5MYPEGAWlTIQJ1aLEfEi/4p7wcV+XHZCBVvGGJZ7L3v+jhH+mZA8lN0yECCovfec+kIdllXpKCgA8RX/HZ2x5yHOtCSKP8/sf6pnwhAwxSng6kCgCXXSAmJOOZFdr3vdK3HzGqCFloOHgc5fM6U64=",
+ "error_substr": "invalid signature"
+ },
+ {
+ "description": "invalid signature prefix",
+ "message": "",
+ "address": "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ "signature": "fooAA==",
+ "error_substr": "error decoding signature as base64"
+ },
+ {
+ "description": "incorrect prefix type",
+ "message": "incorrect prefix",
+ "address": "bc1pyrgrm6cu6n54jrvkdjd9rvyd3xfyu84s2623awu2srn6mxhscwpsm5644w",
+ "signature": "fulAUDZwFXUp+adN+/UZj5dVrGAbB3zKs1Vcalz5fCF9srxS63eSWNGvH1NYbrBkPt1BJDUyWUz9zgUxfc63/QheT6M",
+ "error_substr": "error parsing signature as full variant"
+ }
+ ]
+}
\ No newline at end of file
### specs/bip322/gen.py
@@ -0,0 +1,324 @@
+#!/usr/bin/env python3
+"""Generate the C unit-test vector array from the pinned BIP-322 test vectors.
+
+Input:
+ specs/bip322/basic-test-vectors.json -- verbatim copy of bip-0322/basic-test-vectors.json
+
+Output (overwritten):
+ unit-tests/bip322_vectors.inc.c -- static C array of the tx_hashes vectors
+
+Only the "tx_hashes" table is consumed: for each (message, address) pair it gives the
+BIP0322-signed-message tagged hash of the message and the txids of the to_spend and
+to_sign virtual transactions. Every hash is recomputed here and compared with the JSON
+before emitting anything, so that a byte-order or encoding misreading of the file cannot
+silently turn into a wrong expected value.
+
+Run this script manually after replacing the JSON and commit the result. Pass --check
+(e.g. in CI) to verify the committed output is up to date instead of regenerating it.
+"""
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import json
+import struct
+import sys
+from hashlib import sha256
+from pathlib import Path
+
+SPECS_DIR = Path(__file__).resolve().parent
+REPO_ROOT = SPECS_DIR.parents[1]
+VECTORS_FILE = SPECS_DIR / "basic-test-vectors.json"
+VECTORS_OUT = REPO_ROOT / "unit-tests" / "bip322_vectors.inc.c"
+
+BIP0322_TAG = b"BIP0322-signed-message"
+
+
+# ---------------------------------------------------------------------------
+# bech32 / bech32m (BIP-173 / BIP-350) address decoding
+# ---------------------------------------------------------------------------
+
+_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+_BECH32_CONST = 1
+_BECH32M_CONST = 0x2BC830A3
+
+
+def _bech32_polymod(values: list[int]) -> int:
+ generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
+ chk = 1
+ for value in values:
+ top = chk >> 25
+ chk = (chk & 0x1FFFFFF) << 5 ^ value
+ for i in range(5):
+ chk ^= generator[i] if ((top >> i) & 1) else 0
+ return chk
+
+
+def _bech32_hrp_expand(hrp: str) -> list[int]:
+ return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
+
+
+def _convertbits(data: list[int], frombits: int, tobits: int) -> list[int]:
+ acc = 0
+ bits = 0
+ ret = []
+ maxv = (1 << tobits) - 1
+ for value in data:
+ acc = (acc << frombits) | value
+ bits += frombits
+ while bits >= tobits:
+ bits -= tobits
+ ret.append((acc >> bits) & maxv)
+ if bits >= frombits or ((acc << (tobits - bits)) & maxv):
+ raise ValueError("invalid padding in bech32 data")
+ return ret
+
+
+def address_to_script(address: str) -> bytes:
+ """Decodes a segwit (bech32/bech32m) address into its scriptPubKey."""
+ if address.lower() != address and address.upper() != address:
+ raise ValueError(f"mixed-case address: {address}")
+ address = address.lower()
+ hrp, sep, data_part = address.rpartition("1")
+ if not sep or hrp not in ("bc", "tb", "bcrt"):
+ raise ValueError(f"unsupported address (only segwit addresses are handled): {address}")
+ data = [_CHARSET.find(c) for c in data_part]
+ if -1 in data:
+ raise ValueError(f"invalid bech32 character in {address}")
+ const = _bech32_polymod(_bech32_hrp_expand(hrp) + data)
+ witness_version = data[0]
+ program = bytes(_convertbits(data[1:-6], 5, 8))
+ expected_const = _BECH32_CONST if witness_version == 0 else _BECH32M_CONST
+ if const != expected_const:
+ raise ValueError(f"bad checksum for {address}")
+ if not 2 <= len(program) <= 40:
+ raise ValueError(f"bad witness program length for {address}")
+ op = 0 if witness_version == 0 else 0x50 + witness_version
+ return bytes([op, len(program)]) + program
+
+
+# ---------------------------------------------------------------------------
+# BIP-322 virtual transactions (independent reimplementation, used to
+# cross-check the JSON before emitting it)
+# ---------------------------------------------------------------------------
+
+def tagged_hash(tag: bytes, message: bytes) -> bytes:
+ tag_hash = sha256(tag).digest()
+ return sha256(tag_hash + tag_hash + message).digest()
+
+
+def sha256d(data: bytes) -> bytes:
+ return sha256(sha256(data).digest()).digest()
+
+
+def to_spend_tx(message_hash: bytes, challenge_script: bytes) -> bytes:
+ return b"".join([
+ struct.pack("<i", 0), # nVersion
+ b"\x01", # 1 input
+ b"\x00" * 32, struct.pack("<I", 0xFFFFFFFF), # null outpoint
+ b"\x22\x00\x20" + message_hash, # scriptSig: OP_0 PUSH32(message_hash)
+ struct.pack("<I", 0), # nSequence
+ b"\x01", # 1 output
+ struct.pack("<q", 0), # value
+ bytes([len(challenge_script)]) + challenge_script,
+ struct.pack("<I", 0), # nLockTime
+ ])
+
+
+def to_sign_tx(to_spend_txid: bytes) -> bytes:
+ return b"".join([
+ struct.pack("<i", 0), # nVersion
+ b"\x01", # 1 input
+ to_spend_txid, struct.pack("<I", 0), # spends to_spend:0
+ b"\x00", # empty scriptSig
+ struct.pack("<I", 0), # nSequence
+ b"\x01", # 1 output
+ struct.pack("<q", 0), # value
+ b"\x01\x6a", # OP_RETURN
+ struct.pack("<I", 0), # nLockTime
+ ])
+
+
+# ---------------------------------------------------------------------------
+# Loading and cross-checking
+# ---------------------------------------------------------------------------
+
+def load_tx_hashes_vectors() -> list[dict]:
+ """Returns the tx_hashes vectors, with all byte fields decoded into bytes objects
+ (txids in the internal byte order used in transaction serializations and in
+ PSBT_IN_PREVIOUS_TXID), after checking them against an independent computation."""
+ doc = json.loads(VECTORS_FILE.read_text(encoding="utf-8"))
+ vectors = []
+ for i, entry in enumerate(doc["tx_hashes"]):
+ message = entry["message"].encode("utf-8")
+ script = address_to_script(entry["address"])
+ message_hash = bytes.fromhex(entry["message_hash"])
+ # txids are displayed in reverse byte order
+ to_spend_txid = bytes.fromhex(entry["to_spend_tx_hash"])[::-1]
+ to_sign_txid = bytes.fromhex(entry["to_sign_tx_hash"])[::-1]
+
+ computed_message_hash = tagged_hash(BIP0322_TAG, message)
+ if computed_message_hash != message_hash:
+ raise SystemExit(f"tx_hashes[{i}]: message_hash does not match the tagged hash")
+ computed_to_spend_txid = sha256d(to_spend_tx(message_hash, script))
+ if computed_to_spend_txid != to_spend_txid:
+ raise SystemExit(f"tx_hashes[{i}]: to_spend_tx_hash does not match")
+ if sha256d(to_sign_tx(to_spend_txid)) != to_sign_txid:
+ raise SystemExit(f"tx_hashes[{i}]: to_sign_tx_hash does not match")
+
+ vectors.append({
+ "message": message,
+ "address": entry["address"],
+ "script": script,
+ "message_hash": message_hash,
+ "to_spend_txid": to_spend_txid,
+ "to_sign_txid": to_sign_txid,
+ })
+ return vectors
+
+
+# ---------------------------------------------------------------------------
+# C emission
+# ---------------------------------------------------------------------------
+
+def c_bytes(data: bytes, indent: str = " ") -> str:
+ if not data:
+ return indent + "0" # placeholder; the length field is what matters
+ lines = []
+ for off in range(0, len(data), 16):
+ chunk = data[off:off + 16]
+ lines.append(indent + ", ".join(f"0x{b:02x}" for b in chunk) + ",")
+ return "\n".join(lines)
+
+
+def c_string(data: bytes) -> str:
+ """A C string literal for the (UTF-8) message, escaping everything outside printable
+ ASCII as octal escapes (which cannot swallow the following characters, unlike \\x)."""
+ out = []
+ for b in data:
+ if b == 0x22:
+ out.append('\\"')
+ elif b == 0x5C:
+ out.append("\\\\")
+ elif 0x20 <= b <= 0x7E:
+ out.append(chr(b))
+ else:
+ out.append(f"\\{b:03o}")
+ return '"' + "".join(out) + '"'
+
+
+def emit_vectors(vectors: list[dict]) -> str:
+ out = [
+ "// Generated by specs/bip322/gen.py. DO NOT EDIT.",
+ "// Source: specs/bip322/basic-test-vectors.json (the \"tx_hashes\" table), a pinned",
+ "// copy of bip-0322/basic-test-vectors.json from the bips repository.",
+ "// clang-format off",
+ "",
+ "typedef struct {",
+ " const char *message_str; // the message, as a C string literal (for test output)",
+ " const uint8_t *message; // the UTF-8 encoded message",
+ " size_t message_len;",
+ " const char *address;",
+ " const uint8_t *challenge_script; // scriptPubKey of address",
+ " size_t challenge_script_len;",
+ " uint8_t message_hash[32]; // BIP0322-signed-message tagged hash of message",
+ " uint8_t to_spend_txid[32]; // txid of to_spend, in internal byte order",
+ " uint8_t to_sign_txid[32]; // txid of to_sign, in internal byte order",
+ "} bip322_tx_hashes_vector_t;",
+ "",
+ ]
+ for i, v in enumerate(vectors):
+ out.append(f"static const uint8_t vec_{i:03d}_message[] = {{")
+ out.append(c_bytes(v["message"]))
+ out.append("};")
+ out.append(f"static const uint8_t vec_{i:03d}_script[] = {{")
+ out.append(c_bytes(v["script"]))
+ out.append("};")
+ out.append("")
+
+ out.append("static const bip322_tx_hashes_vector_t BIP322_TX_HASHES_VECTORS[] = {")
+ for i, v in enumerate(vectors):
+ out.append(" {")
+ out.append(f" .message_str = {c_string(v['message'])},")
+ out.append(f" .message = vec_{i:03d}_message,")
+ out.append(f" .message_len = {len(v['message'])},")
+ out.append(f" .address = \"{v['address']}\",")
+ out.append(f" .challenge_script = vec_{i:03d}_script,")
+ out.append(f" .challenge_script_len = {len(v['script'])},")
+ for field in ("message_hash", "to_spend_txid", "to_sign_txid"):
+ out.append(f" .{field} = {{")
+ out.append(c_bytes(v[field], indent=" "))
+ out.append(" },")
+ out.append(" },")
+ out.append("};")
+ out.append("")
+ out.append("#define BIP322_TX_HASHES_VECTORS_COUNT "
+ "(sizeof(BIP322_TX_HASHES_VECTORS) / sizeof(BIP322_TX_HASHES_VECTORS[0]))")
+ return "\n".join(out)
+
+
+# ---------------------------------------------------------------------------
+# Driver
+# ---------------------------------------------------------------------------
+
+def generate_outputs() -> list[tuple[Path, str]]:
+ return [(VECTORS_OUT, emit_vectors(load_tx_hashes_vectors()) + "\n")]
+
+
+def write_outputs(outputs: list[tuple[Path, str]]) -> int:
+ for path, content in outputs:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ print(f"wrote {path.relative_to(REPO_ROOT)}")
+ return 0
+
+
+def check_outputs(outputs: list[tuple[Path, str]]) -> int:
+ stale: list[Path] = []
+ for path, content in outputs:
+ rel = path.relative_to(REPO_ROOT)
+ current = path.read_text() if path.exists() else None
+ if current == content:
+ print(f"ok {rel}")
+ continue
+ stale.append(rel)
+ if current is None:
+ print(f"MISSING {rel} (file does not exist)")
+ continue
+ print(f"DRIFT {rel}")
+ sys.stdout.writelines(difflib.unified_diff(
+ current.splitlines(keepends=True),
+ content.splitlines(keepends=True),
+ fromfile=f"{rel} (committed)",
+ tofile=f"{rel} (generated)",
+ ))
+ if stale:
+ print()
+ print(f"error: {len(stale)} generated file(s) are out of date; regenerate with:")
+ print(" python3 specs/bip322/gen.py")
+ return 1
+ print("All generated files are up to date.")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="gen.py",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ description=__doc__,
+ )
+ parser.add_argument(
+ "-c", "--check", action="store_true",
+ help="don't write anything; verify the committed file matches what this script "
+ "would generate and exit non-zero on any drift.",
+ )
+ args = parser.parse_args(argv)
+ outputs = generate_outputs()
+ if args.check:
+ return check_outputs(outputs)
+ return write_outputs(outputs)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
### src/common/bip322.c
@@ -0,0 +1,89 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#include <string.h>
+
+#include "bip322.h"
+
+/* SDK headers */
+#include "write.h"
+
+/* Local headers */
+#include "crypto.h"
+
+// The BIP-340 tag used for the message hash, as defined by BIP-322.
+static const uint8_t BIP0322_MSG_TAG[] = {'B', 'I', 'P', '0', '3', '2', '2', '-', 's', 'i', 'g',
+ 'n', 'e', 'd', '-', 'm', 'e', 's', 's', 'a', 'g', 'e'};
+
+void bip322_message_hash_init(cx_sha256_t *hash_context) {
+ crypto_tr_tagged_hash_init(hash_context, BIP0322_MSG_TAG, sizeof(BIP0322_MSG_TAG));
+}
+
+int bip322_serialize_to_spend(const uint8_t message_hash[static 32],
+ const uint8_t *challenge_script,
+ size_t challenge_script_len,
+ uint8_t out[static BIP322_TO_SPEND_MAX_LEN]) {
+ if (challenge_script_len > MAX_PREVOUT_SCRIPTPUBKEY_LEN) {
+ return -1;
+ }
+
+ size_t offset = 0;
+ write_u32_le(out, offset, 0); // nVersion = 0
+ offset += 4;
+ out[offset++] = 0x01; // 1 input
+ memset(out + offset, 0, 32); // prevout hash = null
+ offset += 32;
+ write_u32_le(out, offset, 0xFFFFFFFF); // prevout index
+ offset += 4;
+ out[offset++] = 34; // scriptSig length
+ out[offset++] = 0x00; // OP_0
+ out[offset++] = 0x20; // push of 32 bytes
+ memcpy(out + offset, message_hash, 32);
+ offset += 32;
+ write_u32_le(out, offset, 0); // nSequence = 0
+ offset += 4;
+ out[offset++] = 0x01; // 1 output
+ memset(out + offset, 0, 8); // value = 0
+ offset += 8;
+ // the compact size of the script length is a single byte for lengths below 0xFD
+ _Static_assert(MAX_PREVOUT_SCRIPTPUBKEY_LEN < 0xFD,
+ "the challenge script length must be serializable as a single byte");
+ out[offset++] = (uint8_t) challenge_script_len;
+ memcpy(out + offset, challenge_script, challenge_script_len);
+ offset += challenge_script_len;
+ write_u32_le(out, offset, 0); // nLockTime = 0
+ offset += 4;
+
+ return (int) offset;
+}
+
+int bip322_compute_to_spend_txid(const uint8_t message_hash[static 32],
+ const uint8_t *challenge_script,
+ size_t challenge_script_len,
+ uint8_t out_txid[static 32]) {
+ uint8_t to_spend[BIP322_TO_SPEND_MAX_LEN];
+
+ int len =
+ bip322_serialize_to_spend(message_hash, challenge_script, challenge_script_len, to_spend);
+ if (len < 0) {
+ return -1;
+ }
+
+ cx_hash_sha256(to_spend, len, out_txid, 32);
+ cx_hash_sha256(out_txid, 32, out_txid, 32);
+ return 0;
+}
### src/common/bip322.h
@@ -0,0 +1,71 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "cx.h"
+
+#include "constants.h"
+
+// BIP-322 generic signed messages: the parts of the specification that do not depend on how a
+// signing request reaches the app.
+//
+// A BIP-322 signature for a message and a message_challenge (the scriptPubKey whose control is
+// proven) is a signature of the "to_sign" virtual transaction, whose first input spends output 0
+// of the "to_spend" virtual transaction. to_spend commits to the message hash and to the
+// message_challenge; its own input references the null outpoint, so neither transaction can
+// ever be broadcast.
+
+/**
+ * Initializes hash_context for the BIP-322 message hash: the BIP-340 tagged hash of the message
+ * with the tag "BIP0322-signed-message". The message is then added with crypto_hash_update(),
+ * and the hash obtained with crypto_hash_digest().
+ */
+void bip322_message_hash_init(cx_sha256_t *hash_context);
+
+// Maximum length of the serialization of a BIP-322 to_spend transaction:
+// 4 (version) + 1 (input count) + 32 (prevout hash) + 4 (prevout index) + 1 (scriptSig length)
+// + 34 (scriptSig) + 4 (sequence) + 1 (output count) + 8 (value) + 1 (script length)
+// + MAX_PREVOUT_SCRIPTPUBKEY_LEN (challenge script) + 4 (locktime)
+#define BIP322_TO_SPEND_MAX_LEN (94 + MAX_PREVOUT_SCRIPTPUBKEY_LEN)
+
+/**
+ * Serializes the BIP-322 to_spend transaction for the given message hash and challenge
+ * scriptPubKey into out (which must be at least BIP322_TO_SPEND_MAX_LEN bytes long).
+ *
+ * Returns the length of the serialization, or -1 if challenge_script_len is larger than
+ * MAX_PREVOUT_SCRIPTPUBKEY_LEN.
+ */
+int bip322_serialize_to_spend(const uint8_t message_hash[static 32],
+ const uint8_t *challenge_script,
+ size_t challenge_script_len,
+ uint8_t out[static BIP322_TO_SPEND_MAX_LEN]);
+
+/**
+ * Computes the txid (in the byte order used inside transaction serializations, matching
+ * PSBT_IN_PREVIOUS_TXID) of the BIP-322 to_spend transaction for the given message hash and
+ * challenge scriptPubKey.
+ *
+ * Returns 0 on success, -1 on failure.
+ */
+int bip322_compute_to_spend_txid(const uint8_t message_hash[static 32],
+ const uint8_t *challenge_script,
+ size_t challenge_script_len,
+ uint8_t out_txid[static 32]);
### src/common/psbt.h
@@ -10,6 +10,7 @@ enum PsbtGlobalType {
PSBT_GLOBAL_INPUT_COUNT = 0x04,
PSBT_GLOBAL_OUTPUT_COUNT = 0x05,
PSBT_GLOBAL_TX_MODIFIABLE = 0x06,
+ PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09,
PSBT_GLOBAL_VERSION = 0xFB,
PSBT_GLOBAL_PROPRIETARY = 0xFC
};
### src/error_codes.h
@@ -79,6 +79,30 @@
// - a PSBT_IN_REQUIRED_TIME_LOCKTIME must be at least 500000000.
#define EC_SIGN_PSBT_REQUIRED_LOCKTIME_OUT_OF_RANGE 0x000f
+// The PSBT has the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field, but the transaction does not have
+// the structure mandated by BIP-322 for a to_sign transaction.
+// Note: the app cannot verify whether the additional inputs of a proof of funds spend real coins.
+// Therefore, it is not possible to prevent the app from producing invalid BIP-322 signatures.
+#define EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE 0x0010
+
+// The first input of the BIP-322 to_sign transaction does not spend the to_spend transaction.
+// For a proof-of-funds, this includes a missing message_challenge input (BIP-322 v2.0.0).
+#define EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH 0x0011
+
+// BIP-322 requires all signatures to use SIGHASH_ALL (or SIGHASH_DEFAULT for taproot inputs).
+#define EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH 0x0012
+
+// The PSBT uses unsupported features (non-zero locktime, non-zero sequence of the first input,
+// or a relative timelock on a proof-of-funds input).
+#define EC_SIGN_PSBT_BIP322_UNSUPPORTED 0x0013
+
+// BIP-322 message signing is not allowed when called from the Exchange app.
+#define EC_SIGN_PSBT_BIP322_NOT_ALLOWED_IN_SWAP 0x0014
+
+// All the inputs of a BIP-322 proof-of-funds must belong to the wallet policy: the total
+// proven amount shown to the user must be trustworthy, and external inputs cannot be signed.
+#define EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS 0x0015
+
/**
* Swap
*/
### src/handler/lib/check_merkle_tree_sorted.h
@@ -54,16 +54,37 @@ static inline int call_check_merkle_tree_sorted(dispatcher_context_t *dispatcher
* This is the counterpart of call_get_merkleized_map for maps that are not fetched from an outer
* Merkle tree of maps (e.g. the PSBT global map, whose commitment comes straight from the APDU).
*
+ * If a callback to a non-NULL function is given, it is called once for each key of the map, in
+ * lexicographical order. As every key is authenticated against the committed keys root, this is
+ * the way to learn soundly which keys are present (in a by-key lookup, the client might lie
+ * about keys being absent).
+ *
* Returns 0 on success, or a negative number on failure.
*/
-static inline int call_check_merkleized_map_sorted(dispatcher_context_t *dispatcher_context,
- merkleized_map_commitment_t *map) {
+static inline int call_check_merkleized_map_sorted_with_callback(
+ dispatcher_context_t *dispatcher_context,
+ merkleized_map_commitment_t *map,
+ void *callback_state,
+ merkle_tree_elements_callback_t callback) {
// The map is not yet validated; explicitly mark it as such
map->_keys_are_sorted = false;
- int ret = call_check_merkle_tree_sorted(dispatcher_context, map->keys_root, (size_t) map->size);
+ int ret = call_check_merkle_tree_sorted_with_callback(dispatcher_context,
+ callback_state,
+ map->keys_root,
+ (size_t) map->size,
+ callback,
+ map);
if (ret >= 0) {
map->_keys_are_sorted = true;
}
return ret;
}
+
+/**
+ * Convenience function to call call_check_merkleized_map_sorted_with_callback with no callback.
+ */
+static inline int call_check_merkleized_map_sorted(dispatcher_context_t *dispatcher_context,
+ merkleized_map_commitment_t *map) {
+ return call_check_merkleized_map_sorted_with_callback(dispatcher_context, map, NULL, NULL);
+}
### src/handler/sign_message.c
@@ -32,8 +32,6 @@
#include "menu.h"
#include "sw.h"
-extern const char GA_LOADING_MESSAGE[];
-
static unsigned char const BSM_SIGN_MAGIC[] = {'\x18', 'B', 'i', 't', 'c', 'o', 'i', 'n', ' ',
'S', 'i', 'g', 'n', 'e', 'd', ' ', 'M', 'e',
's', 's', 'a', 'g', 'e', ':', '\n'};
### src/handler/sign_psbt.c
@@ -19,6 +19,7 @@
#include <string.h>
#include "sign_psbt.h"
+#include "sign_psbt/bip322_validation.h"
#include "sign_psbt/init_global_state.h"
#include "sign_psbt/preprocess_inputs.h"
#include "sign_psbt/preprocess_outputs.h"
@@ -34,6 +35,7 @@
#include "constants.h"
#include "display.h"
#include "dispatcher.h"
+#include "error_codes.h"
#include "handle_swap_sign_transaction.h"
#include "musig_sessions.h"
#include "sign_psbt_cache.h"
@@ -55,6 +57,18 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
// read APDU inputs, initialize global state and read global PSBT map
if (!init_global_state(dc, &st)) return;
+#ifdef HAVE_SWAP
+ if (G_called_from_swap && st.bip322.is_message_signing) {
+ PRINTF("BIP-322 message signing is not allowed during swap\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_NOT_ALLOWED_IN_SWAP);
+ return;
+ }
+#endif /* HAVE_SWAP */
+
+ if (st.bip322.is_message_signing) {
+ ui_set_processing_screen_text(GA_LOADING_MESSAGE);
+ }
+
sign_psbt_cache_t cache;
init_sign_psbt_cache(&cache);
@@ -83,6 +97,14 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
*/
if (!preprocess_outputs(dc, &st, &cache, internal_outputs)) return;
+ /** BIP-322 STRUCTURAL VALIDATION
+ *
+ * If the PSBT declares itself as a BIP-322 message signing request, enforce the exact
+ * to_sign structure; the PSBT is never reviewed as a transaction once the field is present,
+ * but it shares the same signing flow.
+ */
+ if (st.bip322.is_message_signing && !validate_bip322_request(dc, &st)) return;
+
// check if we're only executing the MuSig2 Round 1
bool only_signing_for_musig = true;
for (size_t i = 0; i < st.account.n_internal_key_expressions; i++) {
@@ -130,11 +152,20 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
} else
#endif /* HAVE_SWAP */
{
- /** TRANSACTION CONFIRMATION
- *
- * Display each non-change output, and transaction fees, and acquire user confirmation,
- */
- if (!display_transaction(dc, &st, internal_outputs)) return;
+ if (st.bip322.is_message_signing) {
+ /** BIP-322 MESSAGE CONFIRMATION
+ *
+ * Review as a message signature (account, address, message).
+ */
+ if (!display_bip322_message(dc, &st)) return;
+ } else {
+ /** TRANSACTION CONFIRMATION
+ *
+ * Display each non-change output, and transaction fees, and acquire user
+ * confirmation,
+ */
+ if (!display_transaction(dc, &st, internal_outputs)) return;
+ }
}
// Signing always takes some time, so we rather not wait before showing the spinner
@@ -151,7 +182,11 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
if (!G_called_from_swap)
#endif /* HAVE_SWAP */
{
- ui_post_processing_confirm_transaction(dc, sign_result);
+ if (st.bip322.is_message_signing) {
+ ui_post_processing_confirm_message(dc, sign_result);
+ } else {
+ ui_post_processing_confirm_transaction(dc, sign_result);
+ }
}
if (!sign_result) {
### src/handler/sign_psbt.h
@@ -107,6 +107,21 @@ typedef struct {
uint8_t tapleaf_hash[32];
} keyexpr_info_t;
+// State for BIP-322 message signing, used when the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field
+// is present in the PSBT. The message itself is not stored in the signing state, and rather
+// streamed from the client when needed.
+typedef struct {
+ bool is_message_signing; // true iff PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE is present
+ bool message_printable; // short enough and printable ASCII => shown in full
+ uint64_t message_length;
+ uint8_t message_hash[32]; // BIP0322-signed-message tagged hash of the message
+ uint8_t message_sha256[32]; // plain sha256(message), shown when not printable
+ // the scriptPubKey whose ownership is being proven (BIP-322's message_challenge);
+ // copied at validation time as it is needed again to show the address at display time
+ uint8_t challenge_script[MAX_PREVOUT_SCRIPTPUBKEY_LEN];
+ size_t challenge_script_len;
+} bip322_state_t;
+
// Cache for partial hashes during signing (avoid quadratic hashing for segwit transactions)
typedef struct tx_hashes_s {
uint8_t sha_prevouts[32];
@@ -212,4 +227,7 @@ typedef struct {
tx_ux_warning_t warnings;
+ // BIP-322 message signing state; bip322.is_message_signing is false for normal transactions.
+ bip322_state_t bip322;
+
} sign_psbt_state_t;
### src/handler/sign_psbt/bip322_validation.c
@@ -0,0 +1,202 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#include <stdint.h>
+#include <string.h>
+
+#include "bip322_validation.h"
+
+/* Local headers */
+#include "amount_from_psbt.h"
+#include "bip322.h"
+#include "constants.h"
+#include "error_codes.h"
+#include "get_merkleized_map.h"
+#include "psbt_fields.h"
+#include "script.h"
+#include "sw.h"
+
+// BIP-68: a sequence with this flag set has no relative timelock semantics.
+#define BIP68_SEQUENCE_LOCKTIME_DISABLE_FLAG (1u << 31)
+
+bool __attribute__((noinline)) validate_bip322_request(dispatcher_context_t *dc,
+ sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ // The to_sign transaction must have exactly one output: a zero-value bare OP_RETURN.
+ // preprocess_outputs() cached it as the first (and only) external output; the read below
+ // relies on the first external output always being cached.
+ _Static_assert(N_CACHED_EXTERNAL_OUTPUTS >= 1, "the first external output must be cached");
+ if (st->n_outputs != 1 || st->n_external_outputs != 1 || st->outputs.n_change != 0 ||
+ st->outputs.total_amount != 0 || st->outputs.output_script_lengths[0] != 1 ||
+ st->outputs.output_scripts[0][0] != OP_RETURN) {
+ PRINTF("BIP-322: output is not a single zero-value bare OP_RETURN\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // BIP-322 upgradeable rules: the transaction version must be 0 or 2.
+ // The total input amount (zero for a plain message signing; the sum of the proven coins
+ // for a proof-of-funds) is shown to the user, so it must pass the same sanity bound used
+ // elsewhere.
+ if ((st->tx_version != 0 && st->tx_version != 2) ||
+ st->inputs_total_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("BIP-322: invalid transaction version or input amount\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // BIP-322 required rules: all signatures use SIGHASH_ALL (or SIGHASH_DEFAULT for taproot).
+ if (st->warnings.non_default_sighash) {
+ PRINTF("BIP-322: only SIGHASH_ALL or SIGHASH_DEFAULT are allowed\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH);
+ return false;
+ }
+
+ // Timelocked BIP-322 signatures are not yet supported.
+ if (st->locktime != 0) {
+ PRINTF("BIP-322: timelocks are not supported\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+
+ // Any input beyond the first makes this a proof-of-funds. Every input must then belong to
+ // the wallet policy: the total proven amount shown to the user must be trustworthy, and
+ // external inputs could not be signed anyway.
+ if (st->warnings.external_inputs) {
+ PRINTF("BIP-322: all inputs must belong to the wallet policy\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS);
+ return false;
+ }
+
+ for (unsigned int cur_input_index = 0; cur_input_index < st->n_inputs; cur_input_index++) {
+ merkleized_map_commitment_t input_map;
+ if (0 > call_get_merkleized_map(dc,
+ st->inputs_root,
+ st->n_inputs,
+ cur_input_index,
+ &input_map)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ // Sequence rules. BIP-322 gives a timelock meaning only to the sequence of the first
+ // input (the "age" of the signature, together with nLockTime); the proof-of-funds
+ // inputs are ordinary spends of real coins.
+ // A missing PSBT_IN_SEQUENCE means the final sequence number (0xFFFFFFFF) per BIP-370.
+ uint32_t sequence;
+ psbt_field_status_t sequence_status = psbt_get_input_sequence(dc, &input_map, &sequence);
+ if (sequence_status == PSBT_FIELD_ERROR) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ if (cur_input_index == 0) {
+ // The first input must have an explicit sequence of 0: any other value makes this
+ // a timelocked variant, which is not supported yet.
+ if (sequence_status != PSBT_FIELD_PRESENT || sequence != 0) {
+ PRINTF("BIP-322: timelocked variants are not supported (first input's sequence)\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+ } else {
+ // A proof-of-funds input may have sequence 0 (the value BIP-322 expects) or any
+ // sequence with the BIP-68 relative-timelock disable flag set, which includes the
+ // final sequence number (explicit, or implied by a missing PSBT_IN_SEQUENCE). With
+ // version 2, any other value would impose a relative timelock on to_sign, which is
+ // again a timelocked variant; with version 0, it is meaningless, so rejected too.
+ if (sequence_status == PSBT_FIELD_ABSENT) {
+ sequence = 0xFFFFFFFF;
+ }
+ if (sequence != 0 && (sequence & BIP68_SEQUENCE_LOCKTIME_DISABLE_FLAG) == 0) {
+ PRINTF("BIP-322: relative timelocks on proof-of-funds inputs are not supported\n");
+ SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_UNSUPPORTED);
+ return false;
+ }
+ }
+
+ if (cur_input_index != 0) {
+ // Additional (proof-of-funds) inputs spend real UTXOs of the wallet policy; their
+ // amounts and scripts were already verified and aggregated by preprocess_inputs().
+ continue;
+ }
+
+ // The remaining checks apply to the first input, which must adhere to the strict BIP-322
+ // structure.
+
+ // The first input must spend output 0 of to_spend. This holds for a proof-of-funds
+ // too: per BIP-322 v2.0.0, the message_challenge is not optional, so a request made
+ // only of real UTXOs (no virtual input) fails below, on the txid binding.
+ uint32_t prevout_index;
+ if (PSBT_FIELD_PRESENT != psbt_get_input_prevout_index(dc, &input_map, &prevout_index) ||
+ prevout_index != 0) {
+ PRINTF("BIP-322: the input does not spend the first output of to_spend\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ uint8_t prevout_txid[32];
+ if (PSBT_FIELD_PRESENT != psbt_get_input_prevout_txid(dc, &input_map, prevout_txid)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ uint64_t amount;
+ uint8_t challenge_script[MAX_PREVOUT_SCRIPTPUBKEY_LEN];
+ size_t challenge_script_len;
+ if (0 > get_amount_scriptpubkey_from_psbt(dc,
+ &input_map,
+ &amount,
+ challenge_script,
+ &challenge_script_len)) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ // The virtual to_spend output has zero value; only the additional (real) inputs may
+ // contribute to the proven amount for proofs of funds.
+ if (amount != 0) {
+ PRINTF("BIP-322: the to_spend output must have zero value\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ // The security anchor: the input's prevout txid must equal the txid of the to_spend
+ // transaction recomputed from the message hash and the input's own scriptPubKey. This
+ // binds any produced signatures to the to_spend transaction, which contains both the
+ // message (binding it to the signature), and non-existing coins (proving that signatures
+ // are for an unspendable transaction).
+ uint8_t expected_txid[32];
+ if (0 > bip322_compute_to_spend_txid(st->bip322.message_hash,
+ challenge_script,
+ challenge_script_len,
+ expected_txid)) {
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE);
+ return false;
+ }
+
+ if (memcmp(expected_txid, prevout_txid, sizeof(expected_txid)) != 0) {
+ PRINTF("BIP-322: the input does not spend to_spend for this message\n");
+ SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH);
+ return false;
+ }
+
+ memcpy(st->bip322.challenge_script, challenge_script, challenge_script_len);
+ st->bip322.challenge_script_len = challenge_script_len;
+ }
+
+ return true;
+}
### src/handler/sign_psbt/bip322_validation.h
@@ -0,0 +1,60 @@
+/*****************************************************************************
+ * Ledger App Bitcoin.
+ * (c) 2026 Ledger SAS.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *****************************************************************************/
+
+#pragma once
+
+#include <stdbool.h>
+
+#include "dispatcher.h"
+#include "sign_psbt.h"
+
+// Support for BIP-322 generic signed messages (message signing via SIGN_PSBT).
+// This implements BIP-322 v2.0.0 (2026-06-04).
+//
+// A PSBT with the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field requests signing the BIP-322
+// "to_sign" virtual transaction for the contained message. It can never be broadcast (its first
+// input spends "to_spend", whose input references the null outpoint, and all produced signatures
+// commit to all inputs), so it is reviewed as a message signature, not as a transaction.
+//
+// The security anchor is validate_bip322_request(): the to_spend txid is recomputed on-device
+// from the message (tagged hash) and the input's scriptPubKey, and must match the input's
+// prevout. This guarantees that the displayed message is exactly the one committed to by the
+// signature, and that the signed transaction is provably unspendable.
+//
+// Primitives (message hash, to_spend) are in common/bip322.h. Other steps live in the SIGN_PSBT
+// steps they belong to: init_global_state() detects the request and hashes the message, and
+// display_bip322_message() reviews it.
+
+/**
+ * Validates that the PSBT follows the structure mandated by BIP-322 for a to_sign transaction.
+ * Must be called after preprocess_inputs() and preprocess_outputs(), and only if
+ * st->bip322.is_message_signing is true.
+ *
+ * The first input must always spend the recomputed to_spend transaction: BIP-322 v2.0.0
+ * clarifies that the message_challenge is not optional in a proof of funds, so a request whose
+ * first input spends a real UTXO is rejected. Additional inputs make the request a
+ * proof-of-funds: they must all belong to the wallet policy, and their total amount is later
+ * shown to the user.
+ *
+ * On success, st->bip322.challenge_script contains the scriptPubKey being proven. The to_spend
+ * input is exempt from the missing_nonwitnessutxo warning in preprocess_inputs() (this function
+ * rejects the request unless it spends the recomputed to_spend), so that warning can only concern
+ * the additional inputs of a proof-of-funds.
+ *
+ * Returns true on success; returns false and sends an error status word on failure.
+ */
+bool validate_bip322_request(dispatcher_context_t *dc, sign_psbt_state_t *st);
### src/handler/sign_psbt/init_global_state.c
@@ -20,6 +20,7 @@
#include <string.h>
#include "init_global_state.h"
+#include "bip322.h"
/* SDK headers */
#include "crypto_helpers.h"
@@ -33,6 +34,7 @@
#include "compare_wallet_script_at_path.h"
#include "constants.h"
#include "crypto.h"
+#include "display.h"
#include "dispatcher.h"
#include "error_codes.h"
#include "get_merkle_leaf_element.h"
@@ -43,6 +45,7 @@
#include "psbt.h"
#include "psbt_fields.h"
#include "sign_psbt_cache.h"
+#include "stream_merkleized_map_value.h"
#include "sw.h"
#include "wallet.h"
@@ -113,6 +116,23 @@ static bool __attribute__((noinline)) parse_sign_psbt_apdu(dispatcher_context_t
return true;
}
+static void global_map_keys_callback(dispatcher_context_t *dc,
+ void *callback_state,
+ const merkleized_map_commitment_t *map_commitment,
+ int index,
+ buffer_t *data) {
+ UNUSED(dc);
+ UNUSED(map_commitment);
+ UNUSED(index);
+
+ sign_psbt_state_t *st = (sign_psbt_state_t *) callback_state;
+ // PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE has no keydata: the key is exactly the key type
+ if (data->size - data->offset == 1 &&
+ data->ptr[data->offset] == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE) {
+ st->bip322.is_message_signing = true;
+ }
+}
+
/**
* Verifies the integrity of the PSBT global map (already committed to by
* st->global_map) and extracts the transaction-wide fields from it
@@ -125,7 +145,13 @@ static bool __attribute__((noinline)) process_global_map(dispatcher_context_t *d
sign_psbt_state_t *st) {
// Check integrity of the global map (this also marks it as validated, so that its values may
// be read by key below).
- if (call_check_merkleized_map_sorted(dc, &st->global_map) < 0) {
+ // This also walks over all keys in the global map, keeping track of any fields
+ // we care about tracking (currently, only the presence of a BIP-322 signature request)
+ st->bip322.is_message_signing = false;
+ if (call_check_merkleized_map_sorted_with_callback(dc,
+ &st->global_map,
+ st,
+ global_map_keys_callback) < 0) {
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
}
@@ -236,6 +262,70 @@ static bool __attribute__((noinline)) load_wallet_account(dispatcher_context_t *
return true;
}
+// State for the message-hashing streaming pass.
+typedef struct {
+ cx_sha256_t tagged_hash_context; // BIP0322-signed-message tagged hash
+ cx_sha256_t sha256_context; // plain sha256, for display of long/unprintable messages
+ bool printable;
+} msg_hash_state_t;
+
+static void message_hash_callback(buffer_t *data, void *cb_state) {
+ msg_hash_state_t *state = (msg_hash_state_t *) cb_state;
+ const uint8_t *bytes = data->ptr + data->offset;
+ size_t len = data->size - data->offset;
+
+ crypto_hash_update(&state->tagged_hash_context.header, bytes, len);
+ crypto_hash_update(&state->sha256_context.header, bytes, len);
+
+ for (size_t i = 0; i < len; i++) {
+ // Line Feed (LF) character is handled by NBGL - let's allow it
+ if ((bytes[i] < 0x20 || bytes[i] > 0x7E) && bytes[i] != '\n') {
+ state->printable = false;
+ }
+ }
+}
+
+/**
+ * Streams the message in the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field from the client,
+ * computing its BIP-322 tagged hash, its plain sha256 (used for display when the message is too
+ * long or not printable), and whether it is printable; the results are stored in st->bip322.
+ *
+ * Returns true on success; returns false and sends an error status word on failure.
+ */
+static bool load_bip322_message(dispatcher_context_t *dc, sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ uint8_t key[] = {PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE};
+
+ // Stream the message, computing its hashes and printability.
+ msg_hash_state_t hash_state;
+ bip322_message_hash_init(&hash_state.tagged_hash_context);
+ cx_sha256_init(&hash_state.sha256_context);
+ hash_state.printable = true;
+
+ int message_length = call_stream_merkleized_map_value(dc,
+ &st->global_map,
+ key,
+ sizeof(key),
+ NULL,
+ message_hash_callback,
+ &hash_state);
+ if (message_length < 0) {
+ PRINTF("Failed to stream the BIP-322 message\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
+ st->bip322.message_length = (uint64_t) message_length;
+ st->bip322.message_printable =
+ hash_state.printable && message_length <= MAX_DISPLAYBLE_MESSAGE_LENGTH;
+
+ crypto_hash_digest(&hash_state.tagged_hash_context.header, st->bip322.message_hash, 32);
+ crypto_hash_digest(&hash_state.sha256_context.header, st->bip322.message_sha256, 32);
+
+ return true;
+}
+
bool __attribute__((noinline)) init_global_state(dispatcher_context_t *dc, sign_psbt_state_t *st) {
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
@@ -246,6 +336,9 @@ bool __attribute__((noinline)) init_global_state(dispatcher_context_t *dc, sign_
if (!process_global_map(dc, st)) return false;
+ // for a BIP-322 message signing request, load the message hashes
+ if (st->bip322.is_message_signing && !load_bip322_message(dc, st)) return false;
+
if (!load_wallet_account(dc, st, wallet_id, wallet_hmac)) return false;
st->master_key_fingerprint = crypto_get_master_key_fingerprint();
### src/handler/sign_psbt/preprocess_inputs.c
@@ -341,8 +341,12 @@ bool __attribute__((noinline)) preprocess_inputs(
}
// For segwitv0 inputs, the non-witness utxo _should_ be present; we show a warning
- // to the user otherwise, but we continue nonetheless on approval
- if (segwit_version == 0 && !input.has_nonWitnessUtxo) {
+ // to the user otherwise, but we continue nonetheless on approval.
+ // The first input of a BIP-322 message signing request is exempt: it spends the virtual
+ // to_spend transaction, which validate_bip322_request() recomputes on-device and requires
+ // to match the input's prevout (aborting otherwise), so its utxo is fully authenticated.
+ bool is_bip322_to_spend = st->bip322.is_message_signing && cur_input_index == 0;
+ if (segwit_version == 0 && !input.has_nonWitnessUtxo && !is_bip322_to_spend) {
PRINTF("Non-witness utxo missing for segwitv0 input. Will show a warning.\n");
st->warnings.missing_nonwitnessutxo = true;
}
### src/handler/sign_psbt/transaction_display.c
@@ -16,6 +16,7 @@
*****************************************************************************/
#include <stdint.h>
+#include <stdio.h>
#include <string.h>
#include "transaction_display.h"
@@ -34,6 +35,7 @@
#include "psbt_fields.h"
#include "script.h"
#include "sighash.h"
+#include "stream_merkleized_map_value.h"
#include "sw.h"
#include "wallet.h"
@@ -436,3 +438,108 @@ bool __attribute__((noinline)) display_transaction(
return true;
}
+
+// State for the message-copying streaming pass (into the buffer shown in the review).
+typedef struct {
+ char *out;
+ size_t max; // capacity of out, excluding the terminating NUL
+ size_t offset;
+ bool overflow;
+} msg_copy_state_t;
+
+static void message_copy_callback(buffer_t *data, void *cb_state) {
+ msg_copy_state_t *state = (msg_copy_state_t *) cb_state;
+ size_t len = data->size - data->offset;
+
+ if (state->overflow || state->offset + len > state->max) {
+ state->overflow = true;
+ return;
+ }
+ memcpy(state->out + state->offset, data->ptr + data->offset, len);
+ state->offset += len;
+}
+
+bool __attribute__((noinline)) display_bip322_message(dispatcher_context_t *dc,
+ sign_psbt_state_t *st) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ // Show any input verification warnings, exactly as the transaction review does. The
+ // external-inputs and non-default-sighash warnings are unreachable here, as
+ // validate_bip322_request() rejects both; the missing-non-witness-utxo warning can only concern
+ // the additional inputs of a proof-of-funds, as preprocess_inputs() exempts the to_spend input.
+ // This also keeps any warning added in the future from being silently skipped in this flow.
+ if (!display_warnings(dc, st)) {
+ return false;
+ }
+
+ char address[MAX_ADDRESS_LENGTH_STR + 1];
+ if (0 > get_script_address(st->bip322.challenge_script,
+ st->bip322.challenge_script_len,
+ address,
+ sizeof(address))) {
+ // wallet policies always produce scripts with an address; this should never happen
+ SEND_SW(dc, SW_BAD_STATE);
+ return false;
+ }
+
+ char account_label_buf[MAX_WALLET_NAME_LENGTH + 1];
+ const char *account_label = st->account.wallet_header.name;
+ if (st->account.is_default) {
+ account_label = NULL;
+ if (format_default_account_label(st->account.bip44_purpose,
+ st->account.bip44_account,
+ account_label_buf,
+ sizeof(account_label_buf))) {
+ account_label = account_label_buf;
+ }
+ }
+
+ char message[MAX_DISPLAYBLE_MESSAGE_LENGTH + 1];
+ bool is_hash = !st->bip322.message_printable;
+
+ if (!is_hash) {
+ // Stream the message again, this time into a buffer. The fetch is authenticated
+ // by the same Merkle commitment as the hashing pass in load_bip322_message(), so the client
+ // cannot provide different contents.
+ msg_copy_state_t copy_state = {.out = message,
+ .max = MAX_DISPLAYBLE_MESSAGE_LENGTH,
+ .offset = 0,
+ .overflow = false};
+ int message_length =
+ call_stream_merkleized_map_value(dc,
+ &st->global_map,
+ (uint8_t[]) {PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE},
+ 1,
+ NULL,
+ message_copy_callback,
+ ©_state);
+ if (message_length < 0 || copy_state.overflow ||
+ (uint64_t) message_length != st->bip322.message_length) {
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+ message[copy_state.offset] = '\0';
+ } else {
+ // The message is too long or not printable: show its sha256 hash instead.
+ for (int i = 0; i < 32; i++) {
+ snprintf(message + 2 * i, 3, "%02X", st->bip322.message_sha256[i]);
+ }
+ }
+
+ // For a proof-of-funds, also show the total amount of the coins whose control is proven.
+ bool is_proof_of_funds = st->n_inputs > 1;
+
+ ui_set_processing_screen_text(GA_SIGNING_MESSAGE);
+ if (!ui_display_bip322_message_and_confirm(dc,
+ account_label,
+ address,
+ message,
+ is_hash,
+ is_proof_of_funds,
+ st->inputs_total_amount)) {
+ SEND_SW(dc, SW_DENY);
+ return false;
+ }
+
+ return true;
+}
### src/handler/sign_psbt/transaction_display.h
@@ -44,3 +44,12 @@ bool display_transaction(
dispatcher_context_t *dc,
sign_psbt_state_t *st,
const uint8_t internal_outputs[static BITVECTOR_REAL_SIZE(MAX_N_OUTPUTS_CAN_SIGN)]);
+
+/**
+ * Shows the BIP-322 message review (account, address being proven, the total amount of the
+ * proven coins for a proof-of-funds, and the message text or its sha256 hash) and asks for
+ * user confirmation.
+ *
+ * Returns true if the user approved; returns false and sends an error status word otherwise.
+ */
+bool display_bip322_message(dispatcher_context_t *dc, sign_psbt_state_t *st);
### src/ui/display.c
@@ -113,6 +113,40 @@ bool ui_display_message_and_confirm(dispatcher_context_t *context,
return io_ui_process(context);
}
+bool ui_display_bip322_message_and_confirm(dispatcher_context_t *context,
+ const char *account,
+ const char *address,
+ const char *message,
+ bool is_hash,
+ bool has_proven_funds,
+ uint64_t proven_amount) {
+#ifdef HAVE_AUTOAPPROVE_FOR_PERF_TESTS
+ return true;
+#endif
+
+ ui_bip322_message_state_t *state = (ui_bip322_message_state_t *) &g_ui_state;
+
+ copy_ui_string(state->message, message, sizeof(state->message));
+ if (account != NULL) {
+ strncpy(state->account, account, sizeof(state->account));
+ state->account[sizeof(state->account) - 1] = '\0';
+ } else {
+ state->account[0] = '\0';
+ }
+ strncpy(state->address, address, sizeof(state->address));
+ state->address[sizeof(state->address) - 1] = '\0';
+
+ if (has_proven_funds) {
+ format_sats_amount(COIN_COINID_SHORT, proven_amount, state->proven_amount);
+ } else {
+ state->proven_amount[0] = '\0';
+ }
+
+ ui_display_bip322_message_flow(account != NULL, is_hash, has_proven_funds);
+
+ return io_ui_process(context);
+}
+
bool ui_display_register_wallet_policy(
dispatcher_context_t *context,
const policy_map_wallet_header_t *wallet_header,
### src/ui/display.h
@@ -79,6 +79,14 @@ typedef struct {
char message[MAX_DISPLAYBLE_MESSAGE_LENGTH + 1];
} ui_path_and_message_state_t;
+// State for the BIP-322 message review.
+typedef struct {
+ char account[MAX_WALLET_NAME_LENGTH + 1];
+ char address[MAX_ADDRESS_LENGTH_STR + 1];
+ char proven_amount[MAX_AMOUNT_LENGTH + 1]; // total of the proof-of-funds inputs
+ char message[MAX_DISPLAYBLE_MESSAGE_LENGTH + 1];
+} ui_bip322_message_state_t;
+
typedef struct {
char wallet_name[MAX_WALLET_NAME_LENGTH + 1];
@@ -175,6 +183,7 @@ typedef union {
ui_path_and_pubkey_state_t path_and_pubkey;
ui_path_and_address_state_t path_and_address;
ui_path_and_message_state_t path_and_message;
+ ui_bip322_message_state_t bip322_message;
ui_wallet_state_t wallet;
ui_cosigner_pubkey_and_index_state_t cosigner_pubkey_and_index;
ui_register_wallet_policy_state_t register_wallet_policy;
@@ -201,6 +210,21 @@ bool ui_display_message_and_confirm(dispatcher_context_t *context,
const char *message,
bool is_hash);
+/**
+ * Shows the BIP-322 message review and asks for confirmation to sign.
+ * account (NULL to hide the row), address and message are copied into the UI state. If is_hash
+ * is true, message is the hex-encoded hash of the message, and is labeled as such.
+ * If has_proven_funds is true, a "Proving funds" row with the formatted proven_amount is
+ * shown (proof-of-funds variant).
+ */
+bool ui_display_bip322_message_and_confirm(dispatcher_context_t *context,
+ const char *account,
+ const char *address,
+ const char *message,
+ bool is_hash,
+ bool has_proven_funds,
+ uint64_t proven_amount);
+
// Reviews a wallet policy to register. Pass `descriptor_template == NULL` to
// hide the raw descriptor template (when the cleartext lines already fully
// capture the policy); otherwise it is shown after the cleartext block.
@@ -268,6 +292,8 @@ void ui_display_pubkey_flow(void);
void ui_sign_message_and_confirm_flow(bool is_hash);
+void ui_display_bip322_message_flow(bool has_account, bool is_hash, bool has_proven_funds);
+
void ui_display_receive_in_wallet_flow(void);
void ui_display_default_wallet_address_flow(void);
@@ -302,3 +328,7 @@ void ui_display_post_processing_confirm_transaction(bool success);
*/
char const *ui_get_processing_screen_text(void);
void ui_set_processing_screen_text(const char *text);
+
+// Processing screen texts for the message signing flows (SIGN_MESSAGE and BIP-322)
+extern const char GA_LOADING_MESSAGE[];
+extern const char GA_SIGNING_MESSAGE[];
### src/ui/display_nbgl.c
@@ -54,6 +54,7 @@ const char GA_REVIEW_MESSAGE[] = "Review message";
const char GA_LOADING_TRANSACTION[] = "Loading transaction";
const char GA_SIGNING_TRANSACTION[] = "Signing transaction";
const char GA_LOADING_MESSAGE[] = "Loading message";
+const char GA_SIGNING_MESSAGE[] = "Signing message";
// Non-default-sighash transaction summary labels (trustworthy-or-bust display)
const char GA_FEE_NOT_AVAILABLE[] = "Not available";
@@ -606,6 +607,48 @@ void ui_sign_message_and_confirm_flow(bool is_hash) {
start_processing_message_callback);
}
+// BIP-322 message review: account (optional), the address being proven, the total amount of
+// the coins being proven (proof-of-funds only), and the message.
+void ui_display_bip322_message_flow(bool has_account, bool is_hash, bool has_proven_funds) {
+ reset_flow_state();
+
+ unsigned int np = 0;
+
+ if (has_account) {
+ pairs[np++] =
+ (nbgl_layoutTagValue_t) {.item = "Account", .value = g_ui_state.bip322_message.account};
+ }
+
+ pairs[np++] =
+ (nbgl_layoutTagValue_t) {.item = "Address", .value = g_ui_state.bip322_message.address};
+
+ if (has_proven_funds) {
+ pairs[np++] = (nbgl_layoutTagValue_t) {.item = "Proving funds",
+ .value = g_ui_state.bip322_message.proven_amount};
+ }
+
+ const char *message_label;
+ if (!is_hash) {
+#ifdef SCREEN_SIZE_WALLET
+ message_label = "Message content";
+#else
+ message_label = "Message";
+#endif
+ } else {
+ message_label = "Message hash";
+ }
+ pairs[np++] =
+ (nbgl_layoutTagValue_t) {.item = message_label, .value = g_ui_state.bip322_message.message};
+
+ nbgl_useCaseReview(TYPE_MESSAGE,
+ make_pair_list(np, true),
+ &ICON_APP_ACTION,
+ GA_REVIEW_MESSAGE,
+ NULL,
+ GA_SIGN_MESSAGE,
+ start_processing_message_callback);
+}
+
// Address flow
void ui_display_default_wallet_address_flow(void) {
reset_flow_state();
### test_utils/bip0322.py
@@ -0,0 +1,256 @@
+"""Helpers to build and verify BIP-322 message-signing PSBTs for the tests.
+
+BIP-322 defines message signing as signing a virtual "to_sign" transaction that spends a
+virtual "to_spend" transaction committing to the message. The PSBT-based flow (BIP-322 v2.0.0)
+carries the message in the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) global field, which the
+signing device uses to recognize the request and display a message-signing UI.
+
+In a proof of funds, the first input is still the message_challenge (spending to_spend); the
+additional inputs spend real UTXOs. BIP-322 v2.0.0 clarifies that the first input is not
+optional.
+"""
+
+import base64
+import struct
+from hashlib import sha256
+from typing import List, Tuple
+
+from bitcoin_client.ledger_bitcoin import WalletPolicy
+from bitcoin_client.ledger_bitcoin.key import ExtendedKey, KeyOriginInfo
+from bitcoin_client.ledger_bitcoin.psbt import PSBT, PartiallySignedInput, PartiallySignedOutput
+from bitcoin_client.ledger_bitcoin.tx import (CTransaction, CTxIn, CTxOut, COutPoint, CTxWitness)
+from bitcoin_client.ledger_bitcoin._serialize import ser_compact_size, ser_string, ser_uint256
+
+from test_utils import hash160, hash256
+from test_utils.txmaker import (getScriptPubkeyFromWallet, fill_inout,
+ createFakeWalletTransaction)
+from test_utils.wallet_policy import DescriptorTemplate
+
+BIP0322_TAG = b"BIP0322-signed-message"
+
+
+def bip0322_message_hash(message: bytes) -> bytes:
+ """The BIP-340 tagged hash of the message, with tag "BIP0322-signed-message"."""
+ tag_hash = sha256(BIP0322_TAG).digest()
+ return sha256(tag_hash + tag_hash + message).digest()
+
+
+def build_to_spend_tx(message: bytes, challenge_script: bytes) -> CTransaction:
+ """Builds the virtual to_spend transaction for the given message and scriptPubKey."""
+ to_spend = CTransaction()
+ to_spend.nVersion = 0
+ to_spend.nLockTime = 0
+
+ txin = CTxIn()
+ txin.prevout = COutPoint(0, 0xFFFFFFFF)
+ txin.scriptSig = b"\x00\x20" + bip0322_message_hash(message)
+ txin.nSequence = 0
+
+ to_spend.vin = [txin]
+ to_spend.vout = [CTxOut(0, challenge_script)]
+ to_spend.wit = CTxWitness()
+ to_spend.rehash()
+ return to_spend
+
+
+def build_bip322_psbt(wallet_policy: WalletPolicy,
+ message: bytes,
+ *,
+ is_change: bool = False,
+ address_index: int = 0,
+ tx_version: int = 0) -> PSBT:
+ """Builds the PSBT of the BIP-322 to_sign transaction for the given wallet policy address
+ and message, with the PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE global field set."""
+
+ challenge_script = bytes(getScriptPubkeyFromWallet(
+ wallet_policy, is_change, address_index).data)
+ to_spend = build_to_spend_tx(message, challenge_script)
+
+ tx = CTransaction()
+ tx.nVersion = tx_version
+ tx.nLockTime = 0
+
+ txin = CTxIn()
+ txin.prevout = COutPoint(to_spend.sha256, 0)
+ txin.scriptSig = b""
+ txin.nSequence = 0
+
+ tx.vin = [txin]
+ tx.vout = [CTxOut(0, b"\x6a")]
+ tx.wit = CTxWitness()
+
+ psbt = PSBT()
+ psbt.version = 0
+
+ # Fill the global xpub map: each root key in the wallet policy maps to its key origin info
+ for key_info_str in wallet_policy.keys_info:
+ key_origin_end_pos = key_info_str.find("]")
+ if key_origin_end_pos == -1:
+ root_pubkey = ExtendedKey.deserialize(key_info_str)
+ fpr = hash160(root_pubkey.pubkey)[:4]
+ key_origin = KeyOriginInfo(fpr, [])
+ else:
+ root_pubkey = ExtendedKey.deserialize(key_info_str[key_origin_end_pos + 1:])
+ key_origin = KeyOriginInfo.from_string(key_info_str[1:key_origin_end_pos])
+ psbt.xpub[root_pubkey.serialize()] = key_origin
+
+ psbt_input = PartiallySignedInput(0)
+
+ desc_tmpl = DescriptorTemplate.from_string(wallet_policy.descriptor_template)
+ if desc_tmpl.is_segwit():
+ psbt_input.witness_utxo = to_spend.vout[0]
+ if desc_tmpl.is_legacy() or (desc_tmpl.is_segwit() and not desc_tmpl.is_taproot()):
+ psbt_input.non_witness_utxo = to_spend
+
+ fill_inout(wallet_policy, psbt_input, is_change=is_change, address_index=address_index)
+
+ psbt.inputs = [psbt_input]
+ psbt.outputs = [PartiallySignedOutput(0)]
+ psbt.tx = tx
+
+ psbt.generic_signed_message = message
+
+ return psbt
+
+
+def build_bip322_pof_psbt(wallet_policy: WalletPolicy,
+ message: bytes,
+ utxo_amounts: List[int],
+ *,
+ is_change: bool = False,
+ address_index: int = 0) -> PSBT:
+ """Builds a BIP-322 proof-of-funds PSBT: the to_sign transaction additionally spends one
+ real (fake, wallet-owned) UTXO per entry of utxo_amounts."""
+
+ psbt = build_bip322_psbt(wallet_policy, message,
+ is_change=is_change, address_index=address_index)
+
+ for amount in utxo_amounts:
+ txin, psbt_input = build_wallet_utxo_input(wallet_policy, amount)
+ psbt.tx.vin.append(txin)
+ psbt.inputs.append(psbt_input)
+
+ return psbt
+
+
+def build_wallet_utxo_input(wallet_policy: WalletPolicy,
+ amount: int,
+ *,
+ n_outputs: int = 2) -> Tuple[CTxIn, PartiallySignedInput]:
+ """Builds a transaction input (with sequence 0) spending a real (fake, wallet-owned) UTXO
+ of the given amount, together with its PSBT input map. The UTXO is one of the n_outputs
+ outputs of a fake transaction; with n_outputs=1, the spent output index is always 0."""
+
+ desc_tmpl = DescriptorTemplate.from_string(wallet_policy.descriptor_template)
+
+ prevout, prevout_n, prevout_is_change, prevout_addr_idx = createFakeWalletTransaction(
+ 1, n_outputs, amount, wallet_policy)
+
+ txin = CTxIn()
+ txin.prevout = COutPoint(prevout.sha256, prevout_n)
+ txin.scriptSig = b""
+ txin.nSequence = 0
+
+ psbt_input = PartiallySignedInput(0)
+ if desc_tmpl.is_segwit():
+ psbt_input.witness_utxo = prevout.vout[prevout_n]
+ if desc_tmpl.is_legacy() or (desc_tmpl.is_segwit() and not desc_tmpl.is_taproot()):
+ psbt_input.non_witness_utxo = prevout
+
+ fill_inout(wallet_policy, psbt_input, is_change=bool(prevout_is_change),
+ address_index=prevout_addr_idx)
+
+ return txin, psbt_input
+
+
+def bip322_pof_segwitv0_sighash_all(to_sign_tx: CTransaction,
+ input_index: int,
+ script_code: bytes,
+ amount: int) -> bytes:
+ """BIP-143 SIGHASH_ALL sighash of one input of a (possibly multi-input, proof-of-funds)
+ to_sign transaction."""
+ hash_prevouts = hash256(
+ b"".join(txin.prevout.serialize() for txin in to_sign_tx.vin))
+ hash_sequence = hash256(
+ b"".join(struct.pack("<I", txin.nSequence) for txin in to_sign_tx.vin))
+ hash_outputs = hash256(b"".join(
+ struct.pack("<q", txout.nValue) + ser_string(txout.scriptPubKey)
+ for txout in to_sign_tx.vout))
+
+ this_input = to_sign_tx.vin[input_index]
+ preimage = b"".join([
+ struct.pack("<i", to_sign_tx.nVersion),
+ hash_prevouts,
+ hash_sequence,
+ this_input.prevout.serialize(),
+ ser_string(script_code),
+ struct.pack("<q", amount),
+ struct.pack("<I", this_input.nSequence),
+ hash_outputs,
+ struct.pack("<I", to_sign_tx.nLockTime),
+ struct.pack("<I", 1), # SIGHASH_ALL
+ ])
+ return hash256(preimage)
+
+
+def bip322_segwitv0_sighash_all(to_spend: CTransaction,
+ script_code: bytes,
+ tx_version: int = 0) -> bytes:
+ """BIP-143 SIGHASH_ALL sighash of the (fixed-form) to_sign transaction spending to_spend."""
+ outpoint = ser_uint256(to_spend.sha256) + struct.pack("<I", 0)
+ hash_prevouts = hash256(outpoint)
+ hash_sequence = hash256(struct.pack("<I", 0))
+ hash_outputs = hash256(struct.pack("<q", 0) + ser_string(b"\x6a"))
+
+ preimage = b"".join([
+ struct.pack("<i", tx_version),
+ hash_prevouts,
+ hash_sequence,
+ outpoint,
+ ser_string(script_code),
+ struct.pack("<q", 0), # amount of the spent output
+ struct.pack("<I", 0), # nSequence
+ hash_outputs,
+ struct.pack("<I", 0), # nLockTime
+ struct.pack("<I", 1), # SIGHASH_ALL
+ ])
+ return hash256(preimage)
+
+
+def bip322_legacy_sighash_all(to_spend: CTransaction,
+ script_code: bytes,
+ tx_version: int = 0) -> bytes:
+ """Legacy SIGHASH_ALL sighash of the (fixed-form) to_sign transaction spending to_spend."""
+ ser = b"".join([
+ struct.pack("<i", tx_version),
+ b"\x01", # 1 input
+ ser_uint256(to_spend.sha256),
+ struct.pack("<I", 0), # prevout index
+ ser_string(script_code), # scriptSig replaced with the script code
+ struct.pack("<I", 0), # nSequence
+ b"\x01", # 1 output
+ struct.pack("<q", 0), # value
+ ser_string(b"\x6a"),
+ struct.pack("<I", 0), # nLockTime
+ struct.pack("<I", 1), # SIGHASH_ALL
+ ])
+ return hash256(ser)
+
+
+def p2wpkh_script_code(challenge_script: bytes) -> bytes:
+ """The BIP-143 script code for a P2WPKH scriptPubKey (0x0014{20-byte key hash})."""
+ assert len(challenge_script) == 22 and challenge_script[0:2] == b"\x00\x14"
+ return b"\x76\xa9\x14" + challenge_script[2:22] + b"\x88\xac"
+
+
+def ecdsa_verify(pubkey: bytes, sighash: bytes, sig_der: bytes) -> bool:
+ """Verifies a DER-encoded ECDSA signature (without the sighash-type byte)."""
+ from embit import ec
+ return ec.PublicKey.parse(pubkey).verify(ec.Signature.parse(sig_der), sighash)
+
+
+def encode_simple_signature(witness_stack: List[bytes]) -> str:
+ """Encodes a witness stack as a BIP-322 "simple" signature string (smp prefix)."""
+ ser = ser_compact_size(len(witness_stack)) + \
+ b"".join(ser_string(item) for item in witness_stack)
+ return "smp" + base64.b64encode(ser).decode()
### test_utils/musig2.py
@@ -19,12 +19,10 @@
import hashlib
import hmac
from io import BytesIO
-import re
-from re import Match
import secrets
import struct
-from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union
+from typing import Dict, Iterator, List, Optional, Tuple, Union
from abc import ABC, abstractmethod
import sys
@@ -34,8 +32,6 @@
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
-import base58
-
from test_utils.taproot_sighash import SIGHASH_DEFAULT, TaprootSignatureHash
from test_utils import bip0327, bip0340, hash160, sha256
from test_utils import taproot
@@ -44,6 +40,13 @@
MuSig2KeyPlaceholder,
KeyPlaceholder,
extract_placeholders,
+ # the musig() helpers below are also re-exported from this module
+ aggregate_musig_pubkey,
+ derive_from_key_info,
+ derive_plain_descriptor,
+ musig,
+ tapleaf_hash,
+ unsorted_musig,
)
from bitcoin_client.ledger_bitcoin._embit.descriptor.miniscript import Miniscript
@@ -55,122 +58,6 @@
HARDENED_INDEX = 0x80000000
-def tapleaf_hash(script: Optional[bytes], leaf_version=b'\xC0') -> Optional[bytes]:
- if script is None:
- return None
- return taproot.tagged_hash(
- "TapLeaf",
- leaf_version + taproot.ser_script(script)
- )
-
-
-
-
-def unsorted_musig(pubkeys: Iterable[bytes], version_bytes: bytes) -> Tuple[str, bip0327.KeyAggContext]:
- """
- Constructs the musig2 aggregated extended public key from an unsorted list of
- compressed public keys, and the version bytes.
- """
-
- assert all(len(pk) == 33 for pk in pubkeys)
- assert len(version_bytes) == 4
-
- depth = b'\x00'
- fingerprint = b'\x00\x00\x00\x00'
- child_number = b'\x00\x00\x00\x00'
-
- key_agg_ctx = bip0327.key_agg(pubkeys)
- Q = key_agg_ctx.Q
- compressed_pubkey = (
- b'\x02' if Q[1] % 2 == 0 else b'\x03') + bip0327.get_xonly_pk(key_agg_ctx)
- chaincode = bytes.fromhex(
- "868087ca02a6f974c4598924c36b57762d32cb45717167e300622c7167e38965")
- ext_pubkey = version_bytes + depth + fingerprint + \
- child_number + chaincode + compressed_pubkey
- return base58.b58encode_check(ext_pubkey).decode(), key_agg_ctx
-
-
-def musig(pubkeys: Iterable[bytes], version_bytes: bytes) -> Tuple[str, bip0327.KeyAggContext]:
- """
- Constructs the musig2 aggregated extended public key from a list of compressed public keys,
- and the version bytes. The keys are sorted, as required by the `the musig()` key expression
- in descriptors.
- """
- return unsorted_musig(sorted(pubkeys), version_bytes)
-
-
-def aggregate_musig_pubkey(keys_info: Iterable[str]) -> Tuple[str, bip0327.KeyAggContext]:
- """
- Constructs the musig2 aggregated extended public key from the list of keys info
- of the participating keys.
- """
-
- pubkeys: list[bytes] = []
- versions: Set[str] = set()
- for ki in keys_info:
- start = ki.find(']')
- xpub = ki[start + 1:]
- xpub_bytes = base58.b58decode_check(xpub)
- versions.add(xpub_bytes[:4])
- pubkeys.append(xpub_bytes[-33:])
-
- if len(versions) > 1:
- raise ValueError(
- "All the extended public keys should be from the same network")
-
- return musig(pubkeys, versions.pop())
-
-
-def derive_from_key_info(key_info: str, steps: List[int]) -> str:
- start = key_info.find(']')
- pk = ExtendedKey.deserialize(key_info[start + 1:])
- return pk.derive_pub_path(steps).to_string()
-
-
-def derive_plain_descriptor(desc_tmpl: str, keys_info: List[str], is_change: bool, address_index: int):
- """
- Given a wallet policy, and the change/address_index combination, computes the corresponding descriptor.
- It replaces /** with /<0;1>/*
- It also replaces each musig() key expression with the corresponding xpub.
- The resulting descriptor can be used with descriptor libraries that do not support musig or wallet policies.
- """
-
- desc_tmpl = desc_tmpl.replace("/**", "/<0;1>/*")
- desc_tmpl = desc_tmpl.replace("*", str(address_index))
-
- # Replace each <M;N> with M if is_change is False, otherwise with N
- def replace_m_n(match: Match[str]):
- m, n = match.groups()
- return m if not is_change else n
-
- desc_tmpl = re.sub(r'<([^;]+);([^>]+)>', replace_m_n, desc_tmpl)
-
- # Replace musig(...) expressions
- def replace_musig(match: Match[str]):
- musig_content = match.group(1)
- steps = [int(x) for x in match.group(2).split("/")]
-
- assert len(steps) == 2
-
- key_indexes = [int(i.strip('@')) for i in musig_content.split(',')]
- key_infos = [keys_info[i] for i in key_indexes]
- agg_xpub = aggregate_musig_pubkey(key_infos)[0]
-
- return derive_from_key_info(agg_xpub, steps)
-
- desc_tmpl = re.sub(r'musig\(([^)]+)\)/(\d+/\d+)', replace_musig, desc_tmpl)
-
- # Replace @i/a/b with the i-th element in keys_info, deriving the key appropriately
- # to get a plain xpub
- def replace_key_index(match):
- index, step1, step2 = [int(x) for x in match.group(1).split('/')]
- return derive_from_key_info(keys_info[index], [step1, step2])
-
- desc_tmpl = re.sub(r'@(\d+/\d+/\d+)', replace_key_index, desc_tmpl)
-
- return desc_tmpl
-
-
class Tree:
"""
Recursive structure that represents a taptree, or one of its subtrees.
### test_utils/txmaker.py
@@ -32,15 +32,7 @@
from bitcoin_client.ledger_bitcoin._embit.descriptor.miniscript import Miniscript
from test_utils import bip0340, sha256, hash160
from test_utils.bip0327 import cbytes, key_agg
-from test_utils.wallet_policy import DescriptorTemplate, KeyPlaceholder, MuSig2KeyPlaceholder, PlainKeyPlaceholder, ShDescriptorTemplate, ShWpkhDescriptorTemplate, ShWshDescriptorTemplate, TrDescriptorTemplate, WshDescriptorTemplate, WpkhDescriptorTemplate, PkhDescriptorTemplate, derive_plain_descriptor, tapleaf_hash
-
-
-# BIP-328 chaincode used by BIP-388 to derive a synthetic xpub from an
-# aggregated musig2 public key. Pinned constant; see the reference
-# implementation in bitcoin_client.ledger_bitcoin.client.aggr_xpub.
-_BIP328_CHAINCODE = bytes.fromhex(
- "868087ca02a6f974c4598924c36b57762d32cb45717167e300622c7167e38965"
-)
+from test_utils.wallet_policy import BIP328_CHAINCODE, DescriptorTemplate, KeyPlaceholder, MuSig2KeyPlaceholder, PlainKeyPlaceholder, ShDescriptorTemplate, ShWpkhDescriptorTemplate, ShWshDescriptorTemplate, TrDescriptorTemplate, WshDescriptorTemplate, WpkhDescriptorTemplate, PkhDescriptorTemplate, derive_plain_descriptor, tapleaf_hash
def _musig_root_aggregate(placeholder: MuSig2KeyPlaceholder, keys_info: List[str]) -> Tuple[bytes, List[bytes]]:
@@ -257,7 +249,7 @@ def get_placeholder_root_key(placeholder: KeyPlaceholder, keys_info: List[str])
0,
b"\x00\x00\x00\x00",
0,
- _BIP328_CHAINCODE,
+ BIP328_CHAINCODE,
None,
aggregated_pubkey,
)
### test_utils/wallet_policy.py
@@ -9,15 +9,75 @@
from dataclasses import dataclass
from io import BytesIO
import re
-from typing import Iterator, List, Optional, Tuple, Type, Union
+from typing import Iterable, Iterator, List, Optional, Set, Tuple, Type, Union
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
+import base58
+
from bitcoin_client.ledger_bitcoin._embit.descriptor.miniscript import Miniscript
from bitcoin_client.ledger_bitcoin.key import ExtendedKey
from test_utils.taproot import ser_script, tagged_hash
+from test_utils import bip0327
+
+# BIP-328 chaincode of the synthetic xpub derived from an aggregated musig2 public key.
+BIP328_CHAINCODE = bytes.fromhex(
+ "868087ca02a6f974c4598924c36b57762d32cb45717167e300622c7167e38965")
+
+
+def unsorted_musig(pubkeys: Iterable[bytes], version_bytes: bytes) -> Tuple[str, bip0327.KeyAggContext]:
+ """
+ Constructs the musig2 aggregated extended public key from an unsorted list of
+ compressed public keys, and the version bytes.
+ """
+
+ assert all(len(pk) == 33 for pk in pubkeys)
+ assert len(version_bytes) == 4
+
+ depth = b'\x00'
+ fingerprint = b'\x00\x00\x00\x00'
+ child_number = b'\x00\x00\x00\x00'
+
+ key_agg_ctx = bip0327.key_agg(pubkeys)
+ Q = key_agg_ctx.Q
+ compressed_pubkey = (
+ b'\x02' if Q[1] % 2 == 0 else b'\x03') + bip0327.get_xonly_pk(key_agg_ctx)
+ ext_pubkey = version_bytes + depth + fingerprint + \
+ child_number + BIP328_CHAINCODE + compressed_pubkey
+ return base58.b58encode_check(ext_pubkey).decode(), key_agg_ctx
+
+
+def musig(pubkeys: Iterable[bytes], version_bytes: bytes) -> Tuple[str, bip0327.KeyAggContext]:
+ """
+ Constructs the musig2 aggregated extended public key from a list of compressed public keys,
+ and the version bytes. The keys are sorted, as required by the `musig()` key expression
+ in descriptors.
+ """
+ return unsorted_musig(sorted(pubkeys), version_bytes)
+
+
+def aggregate_musig_pubkey(keys_info: Iterable[str]) -> Tuple[str, bip0327.KeyAggContext]:
+ """
+ Constructs the musig2 aggregated extended public key from the list of keys info
+ of the participating keys.
+ """
+
+ pubkeys: list[bytes] = []
+ versions: Set[bytes] = set()
+ for ki in keys_info:
+ start = ki.find(']')
+ xpub = ki[start + 1:]
+ xpub_bytes = base58.b58decode_check(xpub)
+ versions.add(xpub_bytes[:4])
+ pubkeys.append(xpub_bytes[-33:])
+
+ if len(versions) > 1:
+ raise ValueError(
+ "All the extended public keys should be from the same network")
+
+ return musig(pubkeys, versions.pop())
def tapleaf_hash(script: Optional[bytes], leaf_version=b'\xC0') -> Optional[bytes]:
@@ -107,6 +167,16 @@ def replace_m_n(match: re.Match[str]):
desc_tmpl = re.sub(r'<([^;]+);([^>]+)>', replace_m_n, desc_tmpl)
+ # Replace each musig(...) expression with the derived aggregate xpub
+ def replace_musig(match: re.Match[str]):
+ key_indexes = [int(i.strip('@')) for i in match.group(1).split(',')]
+ steps = [int(x) for x in match.group(2).split("/")]
+ assert len(steps) == 2
+ agg_xpub = aggregate_musig_pubkey([keys_info[i] for i in key_indexes])[0]
+ return derive_from_key_info(agg_xpub, steps)
+
+ desc_tmpl = re.sub(r'musig\(([^)]+)\)/(\d+/\d+)', replace_musig, desc_tmpl)
+
# Replace @i/a/b with the i-th element in keys_info, deriving the key appropriately
# to get a plain xpub
def replace_key_index(match):
### tests/instructions.py
@@ -20,6 +20,30 @@ def message_instruction_approve(model: Firmware, save_screenshot=True) -> Instru
return instructions
+def bip322_instruction_approve(model: Firmware, save_screenshot=True, *,
+ has_unverifiedwarning: bool = False) -> Instructions:
+ # Navigation for the BIP-322 message review (account/address/message pairs, then sign),
+ # optionally preceded by the warning for segwitv0 inputs missing the non-witness utxo.
+ # The message is streamed from the client between the warning and the review, so they are
+ # in separate request groups.
+ instructions = Instructions(model)
+
+ if model.name.startswith("nano"):
+ if has_unverifiedwarning:
+ instructions.new_request("Continue anyway", save_screenshot=save_screenshot)
+ instructions.nano_skip_screen("Address", save_screenshot=save_screenshot)
+ instructions.same_request("Sign message", save_screenshot=save_screenshot)
+ else:
+ if has_unverifiedwarning:
+ instructions.new_request("Continue anyway", NavInsID.USE_CASE_REVIEW_TAP,
+ NavInsID.USE_CASE_CHOICE_REJECT,
+ save_screenshot=save_screenshot)
+ instructions.review_message(save_screenshot=save_screenshot)
+ instructions.confirm_message(save_screenshot=save_screenshot)
+
+ return instructions
+
+
def message_instruction_approve_long(model: Firmware) -> Instructions:
instructions = Instructions(model)
### tests/snapshots/apex_p/test_sign_bip322_change_address_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_change_address_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_change_address_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_empty_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_empty_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_empty_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_long_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_long_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_long_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_long_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_multisig_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_multisig_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_multisig_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_nonprintable_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_nonprintable_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_nonprintable_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_nonprintable_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2pkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2pkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2pkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2wpkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2wpkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_p2wpkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_pof_unverified_input_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_pof_unverified_input_1_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_pof_unverified_input_1_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_pof_unverified_input_1_1/00001.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_pof_unverified_input_1_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_proof_of_funds_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_reject_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_reject_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/apex_p/test_sign_bip322_reject_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_change_address_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_change_address_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_change_address_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_empty_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_empty_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_empty_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_long_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_long_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_long_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_long_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_multisig_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_multisig_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_multisig_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_nonprintable_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_nonprintable_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_nonprintable_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_nonprintable_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2pkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2pkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2pkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2wpkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2wpkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_p2wpkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_pof_unverified_input_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_pof_unverified_input_1_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_pof_unverified_input_1_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_pof_unverified_input_1_1/00001.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_pof_unverified_input_1_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_proof_of_funds_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_reject_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_reject_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/flex/test_sign_bip322_reject_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_change_address_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_change_address_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_change_address_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_change_address_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_change_address_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_empty_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_empty_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_empty_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_empty_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_empty_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_long_message_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_multisig_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_nonprintable_message_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2pkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2pkh_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2pkh_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2pkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2pkh_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2tr_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2wpkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2wpkh_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2wpkh_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2wpkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_p2wpkh_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_pof_unverified_input_1_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_proof_of_funds_p2tr_0_1/00003.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00003.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00004.png
[binary or diff unavailable]
### tests/snapshots/nanosp/test_sign_bip322_reject_0_0/00005.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_change_address_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_change_address_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_change_address_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_change_address_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_change_address_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_empty_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_empty_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_empty_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_empty_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_empty_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_long_message_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_multisig_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_nonprintable_message_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2pkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2pkh_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2pkh_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2pkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2pkh_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2tr_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2wpkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2wpkh_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2wpkh_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2wpkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_p2wpkh_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_pof_unverified_input_1_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_1/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_1/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_proof_of_funds_p2tr_0_1/00003.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00001.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00002.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00003.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00004.png
[binary or diff unavailable]
### tests/snapshots/nanox/test_sign_bip322_reject_0_0/00005.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_change_address_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_change_address_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_change_address_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_empty_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_empty_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_empty_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_long_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_long_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_long_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_multisig_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_multisig_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_multisig_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_nonprintable_message_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_nonprintable_message_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_nonprintable_message_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2pkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2pkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2pkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2wpkh_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2wpkh_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_p2wpkh_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_pof_unverified_input_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_pof_unverified_input_1_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_pof_unverified_input_1_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_pof_unverified_input_1_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_p2tr_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_p2tr_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_proof_of_funds_p2tr_0_2/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_reject_0_0/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_reject_0_1/00000.png
[binary or diff unavailable]
### tests/snapshots/stax/test_sign_bip322_reject_0_2/00000.png
[binary or diff unavailable]
### tests/test_sign_psbt_bip322.py
@@ -0,0 +1,704 @@
+import hmac
+from hashlib import sha256
+from io import BytesIO
+
+import pytest
+
+from ledger_bitcoin import MultisigWallet, WalletPolicy, AddressType, PartialSignature
+from ledger_bitcoin.exception.errors import (DenyError, IncorrectDataError, NotSupportedError,
+ SecurityStatusNotSatisfiedError)
+from ledger_bitcoin.exception.device_exception import DeviceException
+from ledger_bitcoin.key import ExtendedKey
+from ledger_bitcoin.psbt import PSBT, PartiallySignedInput, PartiallySignedOutput
+from ledger_bitcoin.tx import CTxIn, CTxOut, COutPoint
+from ledger_bitcoin._embit.descriptor.miniscript import Miniscript
+
+from ragger.navigator import Navigator
+from ragger.error import ExceptionRAPDU
+from ragger.firmware import Firmware
+
+from ragger_bitcoin import RaggerClient
+
+from test_utils import bip0340, SpeculosGlobals
+from test_utils.bip0322 import (
+ build_bip322_psbt,
+ build_bip322_pof_psbt,
+ build_wallet_utxo_input,
+ build_to_spend_tx,
+ bip322_segwitv0_sighash_all,
+ bip322_pof_segwitv0_sighash_all,
+ bip322_legacy_sighash_all,
+ p2wpkh_script_code,
+ ecdsa_verify,
+ encode_simple_signature,
+)
+from test_utils.musig2 import HotMusig2Cosigner, derive_plain_descriptor, run_musig2_test
+from test_utils.taproot_sighash import TaprootSignatureHash
+
+from .conftest import toggle_nonstandard_sighash_setting
+from .instructions import bip322_instruction_approve, message_instruction_reject
+from .test_sign_psbt_musig import LedgerMusig2Cosigner
+
+# error codes defined in error_codes.h
+EC_SIGN_PSBT_NONDEFAULT_SIGHASH_NOT_ALLOWED = 0x000d
+EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE = 0x0010
+EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH = 0x0011
+EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH = 0x0012
+EC_SIGN_PSBT_BIP322_UNSUPPORTED = 0x0013
+EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS = 0x0015
+
+SIGHASH_ALL = 0x01
+SIGHASH_NONE = 0x02
+
+
+wallet_wpkh = WalletPolicy(
+ "",
+ "wpkh(@0/**)",
+ [
+ "[f5acc2fd/84'/1'/0']tpubDCtKfsNyRhULjZ9XMS4VKKtVcPdVDi8MKUbcSD9MJDyjRu1A2ND5MiipozyyspBT9bg8upEp7a8EAgFxNxXn1d7QkdbL52Ty5jiSLcxPt1P"
+ ],
+)
+
+wallet_tr = WalletPolicy(
+ "",
+ "tr(@0/**)",
+ [
+ "[f5acc2fd/86'/1'/0']tpubDDKYE6BREvDsSWMazgHoyQWiJwYaDDYPbCFjYxN3HFXJP5fokeiK4hwK5tTLBNEDBwrDXn8cQ4v9b2xdW62Xr5yxoQdMu1v6c7UDXYVH27U",
+ ],
+)
+
+wallet_pkh = WalletPolicy(
+ "",
+ "pkh(@0/**)",
+ [
+ "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+ ],
+)
+
+
+def test_sign_bip322_p2wpkh(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ message = b"Hello World"
+ psbt = build_bip322_psbt(wallet_wpkh, message)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ input_index, partial_sig = result[0]
+ assert input_index == 0
+ assert isinstance(partial_sig, PartialSignature)
+
+ # verify the returned signature against the independently recomputed BIP-143 sighash of
+ # the to_sign transaction
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, p2wpkh_script_code(challenge_script))
+
+ assert partial_sig.signature[-1] == 1 # SIGHASH_ALL
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+ # assemble and print the final BIP-322 "simple" signature, for reference
+ signature = encode_simple_signature(
+ [partial_sig.signature, partial_sig.pubkey])
+ print(f"BIP-322 signature for {test_name}: {signature}")
+
+
+def test_sign_bip322_p2tr(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ message = b"Hello World"
+ psbt = build_bip322_psbt(wallet_tr, message)
+
+ result = client.sign_psbt(psbt, wallet_tr, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ input_index, partial_sig = result[0]
+ assert input_index == 0
+
+ # verify the schnorr signature against the recomputed BIP-341 sighash (SIGHASH_DEFAULT)
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ sighash = TaprootSignatureHash(psbt.tx, [CTxOut(0, challenge_script)], 0, 0)
+
+ assert len(partial_sig.signature) == 64 # SIGHASH_DEFAULT: no sighash byte appended
+ assert bip0340.schnorr_verify(sighash, partial_sig.pubkey, partial_sig.signature)
+
+ signature = encode_simple_signature([partial_sig.signature])
+ print(f"BIP-322 signature for {test_name}: {signature}")
+
+
+def test_sign_bip322_p2pkh(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ # legacy addresses use the "full" variant of BIP-322; the firmware flow is the same
+ message = b"Hello World"
+ psbt = build_bip322_psbt(wallet_pkh, message)
+
+ result = client.sign_psbt(psbt, wallet_pkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].non_witness_utxo.vout[0].scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_legacy_sighash_all(to_spend, challenge_script)
+
+ assert partial_sig.signature[-1] == 1 # SIGHASH_ALL
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_multisig(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, speculos_globals: SpeculosGlobals):
+ # a registered multisig policy, as used by coordinators like Liana
+ wallet = MultisigWallet(
+ name="Cold storage",
+ address_type=AddressType.WIT,
+ threshold=2,
+ keys_info=[
+ "[76223a6e/48'/1'/0'/2']tpubDE7NQymr4AFtewpAsWtnreyq9ghkzQBXpCZjWLFVRAvnbf7vya2eMTvT2fPapNqL8SuVvLQdbUbMfWLVDCZKnsEBqp6UK93QEzL8Ck23AwF",
+ "[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK",
+ ],
+ )
+ wallet_hmac = hmac.new(
+ speculos_globals.wallet_registration_key, wallet.id, sha256).digest()
+
+ message = b"I own this multisig"
+ psbt = build_bip322_psbt(wallet, message)
+
+ result = client.sign_psbt(psbt, wallet, wallet_hmac, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ # the device controls one of the two keys
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ # for P2WSH, the BIP-143 script code is the witness script itself
+ witness_script = bytes(psbt.inputs[0].witness_script)
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ assert challenge_script == b"\x00\x20" + sha256(witness_script).digest()
+
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, witness_script)
+
+ assert partial_sig.signature[-1] == 1 # SIGHASH_ALL
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_long_message(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ # messages that are too long (or not printable) are shown as their sha256 hash
+ message = b"A" * 1000
+ psbt = build_bip322_psbt(wallet_wpkh, message)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, p2wpkh_script_code(challenge_script))
+
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_empty_message(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the empty message is valid (it is one of the BIP's test vectors)
+ message = b""
+ psbt = build_bip322_psbt(wallet_wpkh, message)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, p2wpkh_script_code(challenge_script))
+
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_nonprintable_message(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # a short message that is not printable ASCII (here, UTF-8) is shown as its sha256 hash
+ message = "café".encode()
+ psbt = build_bip322_psbt(wallet_wpkh, message)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, p2wpkh_script_code(challenge_script))
+
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_change_address(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the address being proven may be any address of the account, including a change one
+ message = b"Hello World"
+ psbt = build_bip322_psbt(wallet_wpkh, message, is_change=True, address_index=5)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ to_spend = build_to_spend_tx(message, challenge_script)
+ sighash = bip322_segwitv0_sighash_all(to_spend, p2wpkh_script_code(challenge_script))
+
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_reject(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str):
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=message_instruction_reject(firmware),
+ testname=test_name)
+
+ assert DeviceException.exc.get(e.value.status) == DenyError
+ assert len(e.value.data) == 0
+
+
+def expect_sign_psbt_error(client: RaggerClient, navigator: Navigator, firmware: Firmware,
+ test_name: str, psbt: PSBT, expected_error, expected_ec: int,
+ wallet=wallet_wpkh, wallet_hmac=None):
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.sign_psbt(psbt, wallet, wallet_hmac, navigator,
+ instructions=bip322_instruction_approve(firmware,
+ save_screenshot=False),
+ testname=test_name)
+
+ assert DeviceException.exc.get(e.value.status) == expected_error
+ assert len(e.value.data) == 2
+ error_code = int.from_bytes(e.value.data, byteorder='big')
+ assert error_code == expected_ec
+
+
+def test_sign_bip322_wrong_message(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the message in the global field is not the message committed in the transaction:
+ # the device must refuse (it would otherwise display a message different from the one
+ # being signed)
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.generic_signed_message = b"Another message"
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH)
+
+
+def test_sign_bip322_wrong_prevout_index(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the first input must spend output 0 of to_spend (its only output).
+ # Taproot is used so that no non-witness-utxo cross-check rejects the PSBT before the
+ # BIP-322 validation does (to_spend has no output at index 1).
+ psbt = build_bip322_psbt(wallet_tr, b"Hello World")
+ psbt.tx.vin[0].prevout.n = 1
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE,
+ wallet=wallet_tr)
+
+
+def test_sign_bip322_nonzero_amount(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # a real spend disguised with the BIP-322 global field must be refused.
+ # Taproot is used so that no non-witness-utxo cross-check rejects the PSBT before the
+ # BIP-322 validation does (for segwit v0 inputs, the existing utxo consistency checks
+ # already refuse such a PSBT with a different error code).
+ psbt = build_bip322_psbt(wallet_tr, b"Hello World")
+ psbt.inputs[0].witness_utxo.nValue = 100_000
+ psbt.tx.vout[0].nValue = 100_000
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE,
+ wallet=wallet_tr)
+
+
+def test_sign_bip322_opreturn_with_data(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the output must be a bare OP_RETURN, with no data push
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.tx.vout[0].scriptPubKey = b"\x6a\x04test"
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE)
+
+
+def test_sign_bip322_extra_output(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # exactly one output is allowed
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.tx.vout.append(CTxOut(0, b"\x6a"))
+ psbt.outputs.append(PartiallySignedOutput(0))
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE)
+
+
+def test_sign_bip322_wrong_tx_version(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the to_sign transaction version must be 0 or 2
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World", tx_version=3)
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_INVALID_STRUCTURE)
+
+
+def test_sign_bip322_locktime_unsupported(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # timelocked BIP-322 signatures are valid per the BIP, but not supported yet
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World", tx_version=2)
+ psbt.tx.nLockTime = 800_000
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ NotSupportedError, EC_SIGN_PSBT_BIP322_UNSUPPORTED)
+
+
+def test_sign_bip322_p2tr_explicit_sighash_all(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient):
+ # an explicit SIGHASH_ALL is allowed for taproot inputs too. The review is exactly the one
+ # of test_sign_bip322_p2tr, whose snapshots are therefore reused.
+ message = b"Hello World"
+ psbt = build_bip322_psbt(wallet_tr, message)
+ psbt.inputs[0].sighash = SIGHASH_ALL
+
+ result = client.sign_psbt(psbt, wallet_tr, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname="test_sign_bip322_p2tr")
+
+ assert len(result) == 1
+ _, partial_sig = result[0]
+
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ sighash = TaprootSignatureHash(psbt.tx, [CTxOut(0, challenge_script)], SIGHASH_ALL, 0)
+
+ assert len(partial_sig.signature) == 65
+ assert partial_sig.signature[-1] == SIGHASH_ALL
+ assert bip0340.schnorr_verify(sighash, partial_sig.pubkey, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_nondefault_sighash_setting_disabled(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # BIP-322 requires SIGHASH_ALL (or SIGHASH_DEFAULT). With the non-standard sighash setting
+ # disabled (the default), such a request is refused by the generic sighash gating, before
+ # any BIP-322 check (its on-device status screen is covered by test_sighash_setting.py)
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.inputs[0].sighash = SIGHASH_NONE
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ SecurityStatusNotSatisfiedError,
+ EC_SIGN_PSBT_NONDEFAULT_SIGHASH_NOT_ALLOWED)
+
+
+def test_sign_bip322_nondefault_sighash_setting_enabled(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # with the non-standard sighash setting enabled, the request passes the generic sighash
+ # gating, and is then refused by the BIP-322 validation
+ toggle_nonstandard_sighash_setting(navigator, firmware)
+
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.inputs[0].sighash = SIGHASH_NONE
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_FORBIDDEN_SIGHASH)
+
+
+def test_sign_bip322_proof_of_funds(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # proof-of-funds: the to_sign transaction additionally spends real wallet UTXOs, whose
+ # total amount is shown on the device
+ message = b"I control these coins"
+ utxo_amounts = [123_456, 876_544] # 0.01 BTC total
+ psbt = build_bip322_pof_psbt(wallet_wpkh, message, utxo_amounts)
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ # one signature per input: the virtual to_spend input plus the two real UTXOs
+ assert len(result) == 3
+ assert sorted(idx for idx, _ in result) == [0, 1, 2]
+
+ for input_index, partial_sig in result:
+ challenge_script = bytes(psbt.inputs[input_index].witness_utxo.scriptPubKey)
+ amount = psbt.inputs[input_index].witness_utxo.nValue
+ sighash = bip322_pof_segwitv0_sighash_all(psbt.tx, input_index,
+ p2wpkh_script_code(challenge_script),
+ amount)
+ assert partial_sig.signature[-1] == 1 # SIGHASH_ALL
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_proof_of_funds_p2tr(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ message = b"I control these coins"
+ utxo_amounts = [42_000]
+ psbt = build_bip322_pof_psbt(wallet_tr, message, utxo_amounts)
+
+ result = client.sign_psbt(psbt, wallet_tr, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname=test_name)
+
+ assert len(result) == 2
+ assert sorted(idx for idx, _ in result) == [0, 1]
+
+ spent_utxos = [CTxOut(inp.witness_utxo.nValue, bytes(inp.witness_utxo.scriptPubKey))
+ for inp in psbt.inputs]
+
+ for input_index, partial_sig in result:
+ sighash = TaprootSignatureHash(psbt.tx, spent_utxos, 0, input_index)
+ assert len(partial_sig.signature) == 64 # SIGHASH_DEFAULT
+ assert bip0340.schnorr_verify(sighash, partial_sig.pubkey, partial_sig.signature)
+
+
+def test_sign_bip322_proof_of_funds_witness_only_to_spend(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient):
+ # the to_spend input is recomputed on-device, so it never triggers the warning for
+ # segwitv0 inputs missing the non-witness utxo, even in a proof-of-funds. The review must
+ # be exactly the one of test_sign_bip322_proof_of_funds (same message and amounts), whose
+ # snapshots are therefore reused: a warning screen would make the comparison fail.
+ message = b"I control these coins"
+ utxo_amounts = [123_456, 876_544]
+ psbt = build_bip322_pof_psbt(wallet_wpkh, message, utxo_amounts)
+ psbt.inputs[0].non_witness_utxo = None
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware),
+ testname="test_sign_bip322_proof_of_funds")
+
+ assert len(result) == 3
+ for input_index, partial_sig in result:
+ challenge_script = bytes(psbt.inputs[input_index].witness_utxo.scriptPubKey)
+ amount = psbt.inputs[input_index].witness_utxo.nValue
+ sighash = bip322_pof_segwitv0_sighash_all(psbt.tx, input_index,
+ p2wpkh_script_code(challenge_script),
+ amount)
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_pof_unverified_input(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # a real (proof-of-funds) segwitv0 input missing the non-witness utxo has an unverified
+ # amount, which is part of the total shown to the user: the usual warning is shown first
+ message = b"I control these coins"
+ psbt = build_bip322_pof_psbt(wallet_wpkh, message, [123_456, 876_544])
+ psbt.inputs[2].non_witness_utxo = None
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(
+ firmware, has_unverifiedwarning=True),
+ testname=test_name)
+
+ assert len(result) == 3
+ for input_index, partial_sig in result:
+ challenge_script = bytes(psbt.inputs[input_index].witness_utxo.scriptPubKey)
+ amount = psbt.inputs[input_index].witness_utxo.nValue
+ sighash = bip322_pof_segwitv0_sighash_all(psbt.tx, input_index,
+ p2wpkh_script_code(challenge_script),
+ amount)
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+def test_sign_bip322_pof_external_input(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # every input of a proof-of-funds must belong to the wallet policy: the proven total
+ # shown to the user must be trustworthy
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+
+ txin = CTxIn()
+ txin.prevout = COutPoint(12345, 0)
+ txin.scriptSig = b""
+ txin.nSequence = 0
+ psbt.tx.vin.append(txin)
+
+ external_input = PartiallySignedInput(0)
+ external_input.witness_utxo = CTxOut(5000, b"\x00\x14" + bytes(20))
+ psbt.inputs.append(external_input)
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_EXTERNAL_INPUTS)
+
+
+def test_sign_bip322_pof_missing_challenge(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # BIP-322 v2.0.0 clarifies that the message_challenge (the first input, spending the
+ # virtual to_spend transaction) is not optional in a proof of funds. A request whose
+ # inputs are all real UTXOs, with no virtual input, must be refused.
+ # The real UTXO used as the first input has zero value and output index 0, so that the
+ # request passes the amount and prevout index checks and is rejected by the to_spend
+ # txid binding itself, rather than by an earlier structural check.
+ psbt = build_bip322_pof_psbt(wallet_wpkh, b"I control these coins", [50_000])
+
+ txin, psbt_input = build_wallet_utxo_input(wallet_wpkh, 0, n_outputs=1)
+ assert txin.prevout.n == 0
+ psbt.tx.vin[0] = txin
+ psbt.inputs[0] = psbt_input
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ IncorrectDataError, EC_SIGN_PSBT_BIP322_TOSPEND_MISMATCH)
+
+
+def test_sign_bip322_pof_final_sequences(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # BIP-322 gives a timelock meaning only to the first input's sequence: the proof-of-funds
+ # inputs may use the final sequence (explicit, or implied by an omitted PSBT_IN_SEQUENCE,
+ # which is its PSBTv2 default) or any sequence with the BIP-68 disable flag set.
+ # The review is identical to test_sign_bip322_proof_of_funds, so no screenshots are taken.
+ message = b"I control these coins"
+ for sequences in ([0xFFFFFFFF, 0xFFFFFFFE], [None, 0]):
+ psbt = build_bip322_pof_psbt(wallet_wpkh, message, [100_000, 200_000])
+ tx = psbt.tx
+ psbt.convert_to_v2() # so that the client sends the input maps exactly as set below
+ for input_index, sequence in enumerate(sequences, start=1):
+ tx.vin[input_index].nSequence = 0xFFFFFFFF if sequence is None else sequence
+ psbt.inputs[input_index].sequence = sequence # None: PSBT_IN_SEQUENCE is omitted
+
+ result = client.sign_psbt(psbt, wallet_wpkh, None, navigator,
+ instructions=bip322_instruction_approve(firmware,
+ save_screenshot=False),
+ testname=test_name)
+
+ assert len(result) == 3
+ for input_index, partial_sig in result:
+ challenge_script = bytes(psbt.inputs[input_index].witness_utxo.scriptPubKey)
+ amount = psbt.inputs[input_index].witness_utxo.nValue
+ sighash = bip322_pof_segwitv0_sighash_all(tx, input_index,
+ p2wpkh_script_code(challenge_script),
+ amount)
+ assert ecdsa_verify(partial_sig.pubkey, sighash, partial_sig.signature[:-1])
+
+
+@pytest.mark.parametrize("tx_version", [2, 0])
+def test_sign_bip322_pof_relative_timelock_unsupported(navigator: Navigator,
+ firmware: Firmware,
+ client: RaggerClient, test_name: str,
+ tx_version: int):
+ # a proof-of-funds input whose sequence is neither 0 nor has the BIP-68 disable flag set:
+ # with version 2 it would impose a relative timelock on to_sign (a timelocked variant, not
+ # supported); with version 0 it is meaningless, and rejected too
+ psbt = build_bip322_pof_psbt(wallet_wpkh, b"I control these coins", [100_000])
+ psbt.tx.nVersion = tx_version
+ psbt.tx.vin[1].nSequence = 10
+
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ NotSupportedError, EC_SIGN_PSBT_BIP322_UNSUPPORTED)
+
+
+def test_sign_bip322_challenge_sequence_unsupported(navigator: Navigator, firmware: Firmware,
+ client: RaggerClient, test_name: str):
+ # the first input's sequence is the "age" of a timelocked signature: only an explicit 0 is
+ # supported, whether the final sequence is given explicitly or implied by an omitted field
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.tx.vin[0].nSequence = 0xFFFFFFFF
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ NotSupportedError, EC_SIGN_PSBT_BIP322_UNSUPPORTED)
+
+ psbt = build_bip322_psbt(wallet_wpkh, b"Hello World")
+ psbt.convert_to_v2()
+ psbt.inputs[0].sequence = None # PSBT_IN_SEQUENCE omitted
+ expect_sign_psbt_error(client, navigator, firmware, test_name, psbt,
+ NotSupportedError, EC_SIGN_PSBT_BIP322_UNSUPPORTED)
+
+
+def test_sign_bip322_musig_keypath(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, speculos_globals: SpeculosGlobals):
+ # BIP-322 message signing for a taproot keypath musig() policy. The device controls one of
+ # the two musig participants; the two-round MuSig2 flow runs exactly as for a transaction,
+ # except the request is reviewed (and displayed) as a message signature.
+ cosigner_1_xpub = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+
+ cosigner_2_xpriv = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
+ cosigner_2_xpub = "tpubDCwYjpDhUdPGQWG6wG6hkBJuWFZEtrn7j3xwG3i8XcQabcGC53xWZm1hSXrUPFS5UvZ3QhdPSjXWNfWmFGTioARHuG5J7XguEjgg7p8PxAm"
+
+ wallet_policy = WalletPolicy(
+ name="Musig message signer",
+ descriptor_template="tr(musig(@0,@1)/**)",
+ keys_info=[cosigner_1_xpub, cosigner_2_xpub],
+ )
+ wallet_hmac = hmac.new(
+ speculos_globals.wallet_registration_key, wallet_policy.id, sha256).digest()
+
+ message = b"I control this musig address"
+ psbt = build_bip322_psbt(wallet_policy, message)
+
+ # the to_sign transaction spends the single virtual to_spend output (a zero-value taproot
+ # output), so the sighash is the SIGHASH_DEFAULT taproot keypath sighash over it
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ sighash = TaprootSignatureHash(psbt.tx, [CTxOut(0, challenge_script)], 0, 0)
+
+ # the device controls one musig participant; a hot cosigner supplies the other
+ signer_1 = LedgerMusig2Cosigner(
+ client, wallet_policy, wallet_hmac, navigator=navigator,
+ instructions=bip322_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, cosigner_2_xpriv)
+
+ run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], [sighash])
+
+
+def test_sign_bip322_musig_scriptpath(navigator: Navigator, firmware: Firmware, client: RaggerClient,
+ test_name: str, speculos_globals: SpeculosGlobals):
+ # BIP-322 message signing for a musig() key expression sitting in a tapscript leaf. The
+ # device signs the leaf script path, so the sighash uses the tapscript (leaf) variant.
+ cosigner_1_xpub = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+
+ cosigner_2_xpriv = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
+ cosigner_2_xpub = ExtendedKey.deserialize(cosigner_2_xpriv).neutered().to_string()
+
+ wallet_policy = WalletPolicy(
+ name="Musig script msg",
+ descriptor_template="tr(@0/**,pk(musig(@1,@2)/**))",
+ keys_info=[
+ "tpubD6NzVbkrYhZ4WLczPJWReQycCJdd6YVWXubbVUFnJ5KgU5MDQrD998ZJLSmaB7GVcCnJSDWprxmrGkJ6SvgQC6QAffVpqSvonXmeizXcrkN",
+ cosigner_1_xpub,
+ cosigner_2_xpub,
+ ],
+ )
+ wallet_hmac = hmac.new(
+ speculos_globals.wallet_registration_key, wallet_policy.id, sha256).digest()
+
+ message = b"Musig in a tapscript leaf"
+ psbt = build_bip322_psbt(wallet_policy, message)
+
+ # the device signs the tapscript leaf that contains the musig() key; recompute the leaf
+ # script (change=0, address_index=0) to derive the corresponding tapscript sighash
+ challenge_script = bytes(psbt.inputs[0].witness_utxo.scriptPubKey)
+ leaf_desc = derive_plain_descriptor(
+ "pk(musig(@1,@2)/**)", wallet_policy.keys_info, False, 0)
+ leaf_script = Miniscript.read_from(
+ BytesIO(leaf_desc.encode()), taproot=True).compile()
+ sighash = TaprootSignatureHash(psbt.tx, [CTxOut(0, challenge_script)], 0, 0,
+ scriptpath=True, script=leaf_script)
+
+ signer_1 = LedgerMusig2Cosigner(
+ client, wallet_policy, wallet_hmac, navigator=navigator,
+ instructions=bip322_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, cosigner_2_xpriv)
+
+ run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], [sighash])
### unit-tests/CMakeLists.txt
@@ -332,6 +332,14 @@ if(SPECULOS AND SPECULOS_SRC)
target_link_libraries(test_script PRIVATE cmocka app_crypto)
add_test(test_script test_script)
+ # test_bip322 exercises the BIP-322 primitives of src/common/bip322.c (message hash, to_spend
+ # serialization and txid) against the vectors of the BIP (specs/bip322/, rendered into
+ # bip322_vectors.inc.c by gen.py).
+ add_executable(test_bip322 test_bip322.c ../src/common/bip322.c)
+ app_apply_real_sdk_config(test_bip322)
+ target_link_libraries(test_bip322 PRIVATE cmocka app_crypto write)
+ add_test(test_bip322 test_bip322)
+
# test_sighash exercises classify_sighash (src/common/sighash.h), which only
# depends on constants.h (and therefore the real SDK headers). No app object
# code or syscalls are needed, so it just links cmocka.
### unit-tests/bip322_vectors.inc.c
@@ -0,0 +1,107 @@
+// Generated by specs/bip322/gen.py. DO NOT EDIT.
+// Source: specs/bip322/basic-test-vectors.json (the "tx_hashes" table), a pinned
+// copy of bip-0322/basic-test-vectors.json from the bips repository.
+// clang-format off
+
+typedef struct {
+ const char *message_str; // the message, as a C string literal (for test output)
+ const uint8_t *message; // the UTF-8 encoded message
+ size_t message_len;
+ const char *address;
+ const uint8_t *challenge_script; // scriptPubKey of address
+ size_t challenge_script_len;
+ uint8_t message_hash[32]; // BIP0322-signed-message tagged hash of message
+ uint8_t to_spend_txid[32]; // txid of to_spend, in internal byte order
+ uint8_t to_sign_txid[32]; // txid of to_sign, in internal byte order
+} bip322_tx_hashes_vector_t;
+
+static const uint8_t vec_000_message[] = {
+ 0
+};
+static const uint8_t vec_000_script[] = {
+ 0x00, 0x14, 0x2b, 0x05, 0xd5, 0x64, 0xe6, 0xa7, 0xa3, 0x3c, 0x08, 0x7f, 0x16, 0xe0, 0xf7, 0x30,
+ 0xd1, 0x44, 0x01, 0x23, 0x79, 0x9d,
+};
+
+static const uint8_t vec_001_message[] = {
+ 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64,
+};
+static const uint8_t vec_001_script[] = {
+ 0x00, 0x14, 0x2b, 0x05, 0xd5, 0x64, 0xe6, 0xa7, 0xa3, 0x3c, 0x08, 0x7f, 0x16, 0xe0, 0xf7, 0x30,
+ 0xd1, 0x44, 0x01, 0x23, 0x79, 0x9d,
+};
+
+static const uint8_t vec_002_message[] = {
+ 0x55, 0x54, 0x46, 0x2d, 0x38, 0x20, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x3a, 0x20, 0xc3,
+ 0xb6, 0xc3, 0xa4, 0xc3, 0xbc, 0xc3, 0xa9, 0xc3, 0xa0, 0xc3, 0xa8, 0x20, 0xe6, 0xb5, 0x8b, 0xe8,
+ 0xaf, 0x95, 0xe6, 0x96, 0x87, 0xe6, 0x9c, 0xac, 0x20, 0xf0, 0x9f, 0x98, 0x84,
+};
+static const uint8_t vec_002_script[] = {
+ 0x00, 0x14, 0x2b, 0x05, 0xd5, 0x64, 0xe6, 0xa7, 0xa3, 0x3c, 0x08, 0x7f, 0x16, 0xe0, 0xf7, 0x30,
+ 0xd1, 0x44, 0x01, 0x23, 0x79, 0x9d,
+};
+
+static const bip322_tx_hashes_vector_t BIP322_TX_HASHES_VECTORS[] = {
+ {
+ .message_str = "",
+ .message = vec_000_message,
+ .message_len = 0,
+ .address = "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ .challenge_script = vec_000_script,
+ .challenge_script_len = 22,
+ .message_hash = {
+ 0xc9, 0x0c, 0x26, 0x9c, 0x4f, 0x8f, 0xcb, 0xe6, 0x88, 0x0f, 0x72, 0xa7, 0x21, 0xdd, 0xfb, 0xf1,
+ 0x91, 0x42, 0x68, 0xa7, 0x94, 0xcb, 0xb2, 0x1c, 0xfa, 0xfe, 0xe1, 0x37, 0x70, 0xae, 0x19, 0xf1,
+ },
+ .to_spend_txid = {
+ 0xa7, 0x99, 0x5a, 0x54, 0x3c, 0x28, 0x66, 0xb5, 0x1b, 0xa9, 0x65, 0xe7, 0x8d, 0x01, 0xde, 0x5d,
+ 0xb5, 0x04, 0x35, 0xcd, 0xe9, 0xd4, 0x82, 0xbf, 0x60, 0xd8, 0xb8, 0x9b, 0xa6, 0x0a, 0x68, 0xc5,
+ },
+ .to_sign_txid = {
+ 0xd6, 0xae, 0xeb, 0xaa, 0xa3, 0x8b, 0x63, 0xdf, 0x1e, 0xfe, 0xdc, 0xca, 0x1d, 0xe8, 0x27, 0x8a,
+ 0xd7, 0x7f, 0xc6, 0xe6, 0x4d, 0x4c, 0x60, 0xc8, 0x44, 0xba, 0xa5, 0x51, 0xe9, 0x54, 0x96, 0x1e,
+ },
+ },
+ {
+ .message_str = "Hello World",
+ .message = vec_001_message,
+ .message_len = 11,
+ .address = "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ .challenge_script = vec_001_script,
+ .challenge_script_len = 22,
+ .message_hash = {
+ 0xf0, 0xeb, 0x03, 0xb1, 0xa7, 0x5a, 0xc6, 0xd9, 0x84, 0x7f, 0x55, 0xc6, 0x24, 0xa9, 0x91, 0x69,
+ 0xb5, 0xdc, 0xcb, 0xa2, 0xa3, 0x1f, 0x5b, 0x23, 0xbe, 0xa7, 0x7b, 0xa2, 0x70, 0xde, 0x0a, 0x7a,
+ },
+ .to_spend_txid = {
+ 0x2b, 0x35, 0x03, 0xd6, 0xa2, 0x61, 0x4d, 0xea, 0xf1, 0x71, 0x6c, 0x23, 0x32, 0x5c, 0x53, 0xe0,
+ 0x51, 0x4b, 0x4a, 0xfc, 0x98, 0x10, 0x1c, 0x77, 0x17, 0x52, 0xad, 0x40, 0x67, 0x19, 0x9d, 0xb7,
+ },
+ .to_sign_txid = {
+ 0xdf, 0xbd, 0x93, 0x9d, 0x8c, 0x5c, 0x9c, 0x14, 0x8c, 0x98, 0x11, 0xa5, 0xaf, 0x56, 0x8d, 0xcb,
+ 0xa1, 0xe9, 0x3a, 0x15, 0x4b, 0xcc, 0x93, 0x5f, 0x14, 0x77, 0x20, 0x6f, 0xe8, 0x7a, 0x73, 0x88,
+ },
+ },
+ {
+ .message_str = "UTF-8 support: \303\266\303\244\303\274\303\251\303\240\303\250 \346\265\213\350\257\225\346\226\207\346\234\254 \360\237\230\204",
+ .message = vec_002_message,
+ .message_len = 45,
+ .address = "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l",
+ .challenge_script = vec_002_script,
+ .challenge_script_len = 22,
+ .message_hash = {
+ 0x43, 0x93, 0x6b, 0x23, 0x7e, 0xa3, 0x8c, 0x77, 0x94, 0xeb, 0x5d, 0x75, 0x5e, 0x0d, 0x22, 0x0b,
+ 0x6d, 0xb9, 0x2e, 0xbf, 0xc5, 0xc8, 0xf4, 0x82, 0x75, 0x9d, 0x22, 0xb1, 0x28, 0x63, 0x76, 0xd7,
+ },
+ .to_spend_txid = {
+ 0xf0, 0x62, 0xfb, 0xd1, 0xad, 0x12, 0xb7, 0x76, 0xe6, 0x1b, 0x8a, 0x5e, 0x42, 0x98, 0x9c, 0x07,
+ 0x6f, 0x09, 0xd2, 0x5b, 0x17, 0x44, 0x9b, 0xc0, 0x1b, 0xfb, 0x8a, 0xfe, 0x25, 0xf5, 0xf4, 0xc8,
+ },
+ .to_sign_txid = {
+ 0x86, 0x65, 0xbe, 0xc1, 0xf1, 0x58, 0x96, 0xeb, 0x1e, 0xaa, 0x17, 0xd6, 0x1f, 0x81, 0xd1, 0xf1,
+ 0xf7, 0xfa, 0xea, 0x09, 0x81, 0x52, 0xec, 0x19, 0xd0, 0xaf, 0x9e, 0xb8, 0x06, 0x8e, 0x48, 0x8f,
+ },
+ },
+};
+
+#define BIP322_TX_HASHES_VECTORS_COUNT (sizeof(BIP322_TX_HASHES_VECTORS) / sizeof(BIP322_TX_HASHES_VECTORS[0]))
### unit-tests/test_bip322.c
@@ -0,0 +1,307 @@
+#include <stdarg.h>
+#include <stddef.h>
+#include <setjmp.h>
+#include <cmocka.h>
+
+#include <stdint.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include "common/bip322.h"
+#include "crypto.h"
+
+// ---------------------------------------------------------------------------
+// Test vectors
+// ---------------------------------------------------------------------------
+
+// The "tx_hashes" table of bip-0322/basic-test-vectors.json (pinned copy in specs/bip322/),
+// as generated by specs/bip322/gen.py.
+#include "bip322_vectors.inc.c"
+
+// The one hand-written vector: the full serialization of to_spend for "Hello World", spelled
+// out field by field. The vectors above only pin txids, which would not tell *which* field is
+// wrong if the layout ever regressed; this one does. Its inputs are taken from the vectors.
+static const uint8_t TO_SPEND_HELLO_WORLD[] = {
+ // nVersion = 0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ // 1 input: null prevout hash, index 0xffffffff
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0xff,
+ 0xff,
+ 0xff,
+ 0xff,
+ // scriptSig: OP_0 PUSH32(message_hash)
+ 0x22,
+ 0x00,
+ 0x20,
+ 0xf0,
+ 0xeb,
+ 0x03,
+ 0xb1,
+ 0xa7,
+ 0x5a,
+ 0xc6,
+ 0xd9,
+ 0x84,
+ 0x7f,
+ 0x55,
+ 0xc6,
+ 0x24,
+ 0xa9,
+ 0x91,
+ 0x69,
+ 0xb5,
+ 0xdc,
+ 0xcb,
+ 0xa2,
+ 0xa3,
+ 0x1f,
+ 0x5b,
+ 0x23,
+ 0xbe,
+ 0xa7,
+ 0x7b,
+ 0xa2,
+ 0x70,
+ 0xde,
+ 0x0a,
+ 0x7a,
+ // nSequence = 0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ // 1 output: value 0, the challenge scriptPubKey
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x16,
+ 0x00,
+ 0x14,
+ 0x2b,
+ 0x05,
+ 0xd5,
+ 0x64,
+ 0xe6,
+ 0xa7,
+ 0xa3,
+ 0x3c,
+ 0x08,
+ 0x7f,
+ 0x16,
+ 0xe0,
+ 0xf7,
+ 0x30,
+ 0xd1,
+ 0x44,
+ 0x01,
+ 0x23,
+ 0x79,
+ 0x9d,
+ // nLockTime = 0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+static const bip322_tx_hashes_vector_t *find_vector(const char *message_str) {
+ for (size_t i = 0; i < BIP322_TX_HASHES_VECTORS_COUNT; i++) {
+ if (strcmp(BIP322_TX_HASHES_VECTORS[i].message_str, message_str) == 0) {
+ return &BIP322_TX_HASHES_VECTORS[i];
+ }
+ }
+ return NULL;
+}
+
+static void sha256d(const uint8_t *data, size_t len, uint8_t out[32]) {
+ cx_hash_sha256(data, len, out, 32);
+ cx_hash_sha256(out, 32, out, 32);
+}
+
+// Serializes the BIP-322 to_sign transaction spending to_spend_txid:0 (test-side
+// reimplementation; the app never builds to_sign itself, the client does).
+static size_t serialize_to_sign(const uint8_t to_spend_txid[32], uint8_t out[128]) {
+ size_t off = 0;
+ memset(out + off, 0, 4); // nVersion = 0
+ off += 4;
+ out[off++] = 0x01; // 1 input
+ memcpy(out + off, to_spend_txid, 32);
+ off += 32;
+ memset(out + off, 0, 4); // prevout index 0
+ off += 4;
+ out[off++] = 0x00; // empty scriptSig
+ memset(out + off, 0, 4); // nSequence = 0
+ off += 4;
+ out[off++] = 0x01; // 1 output
+ memset(out + off, 0, 8); // value 0
+ off += 8;
+ out[off++] = 0x01; // OP_RETURN script
+ out[off++] = 0x6a;
+ memset(out + off, 0, 4); // nLockTime = 0
+ off += 4;
+ return off;
+}
+
+// ---------------------------------------------------------------------------
+// Tests: to_spend construction
+// ---------------------------------------------------------------------------
+
+static void test_bip322_serialize_to_spend_layout(void **state) {
+ (void) state;
+
+ const bip322_tx_hashes_vector_t *v = find_vector("Hello World");
+ assert_non_null(v);
+
+ uint8_t out[BIP322_TO_SPEND_MAX_LEN];
+ int len = bip322_serialize_to_spend(v->message_hash,
+ v->challenge_script,
+ v->challenge_script_len,
+ out);
+
+ assert_int_equal(len, sizeof(TO_SPEND_HELLO_WORLD));
+ assert_memory_equal(out, TO_SPEND_HELLO_WORLD, sizeof(TO_SPEND_HELLO_WORLD));
+}
+
+static void test_bip322_serialize_to_spend_rejects_long_script(void **state) {
+ (void) state;
+
+ uint8_t long_script[MAX_PREVOUT_SCRIPTPUBKEY_LEN + 1] = {0};
+ uint8_t out[BIP322_TO_SPEND_MAX_LEN];
+ assert_int_equal(bip322_serialize_to_spend(BIP322_TX_HASHES_VECTORS[0].message_hash,
+ long_script,
+ sizeof(long_script),
+ out),
+ -1);
+}
+
+// For every vector: the serialized to_spend hashes to the vector's to_spend txid, and so does
+// the txid computed by bip322_compute_to_spend_txid().
+static void test_bip322_vectors_to_spend_txid(void **state) {
+ (void) state;
+
+ for (size_t i = 0; i < BIP322_TX_HASHES_VECTORS_COUNT; i++) {
+ const bip322_tx_hashes_vector_t *v = &BIP322_TX_HASHES_VECTORS[i];
+ print_message("vector %zu: %s\n", i, v->message_str);
+
+ uint8_t to_spend[BIP322_TO_SPEND_MAX_LEN];
+ int len = bip322_serialize_to_spend(v->message_hash,
+ v->challenge_script,
+ v->challenge_script_len,
+ to_spend);
+ assert_true(len > 0);
+
+ uint8_t txid[32];
+ sha256d(to_spend, (size_t) len, txid);
+ assert_memory_equal(txid, v->to_spend_txid, 32);
+
+ memset(txid, 0, sizeof(txid));
+ assert_int_equal(bip322_compute_to_spend_txid(v->message_hash,
+ v->challenge_script,
+ v->challenge_script_len,
+ txid),
+ 0);
+ assert_memory_equal(txid, v->to_spend_txid, 32);
+ }
+}
+
+// For every vector: the to_sign transaction built on the txid the app computes hashes to the
+// vector's to_sign txid. This is what the app's signature ultimately commits to.
+static void test_bip322_vectors_to_sign_txid(void **state) {
+ (void) state;
+
+ for (size_t i = 0; i < BIP322_TX_HASHES_VECTORS_COUNT; i++) {
+ const bip322_tx_hashes_vector_t *v = &BIP322_TX_HASHES_VECTORS[i];
+
+ uint8_t to_spend_txid[32];
+ assert_int_equal(bip322_compute_to_spend_txid(v->message_hash,
+ v->challenge_script,
+ v->challenge_script_len,
+ to_spend_txid),
+ 0);
+
+ uint8_t to_sign[128];
+ size_t len = serialize_to_sign(to_spend_txid, to_sign);
+
+ uint8_t txid[32];
+ sha256d(to_sign, len, txid);
+ assert_memory_equal(txid, v->to_sign_txid, 32);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests: message hash
+// ---------------------------------------------------------------------------
+
+// For every vector: the message hashes to the vector's message_hash.
+static void test_bip322_vectors_message_hash(void **state) {
+ (void) state;
+
+ for (size_t i = 0; i < BIP322_TX_HASHES_VECTORS_COUNT; i++) {
+ const bip322_tx_hashes_vector_t *v = &BIP322_TX_HASHES_VECTORS[i];
+ print_message("vector %zu: %s\n", i, v->message_str);
+
+ cx_sha256_t hash_context;
+ bip322_message_hash_init(&hash_context);
+ crypto_hash_update(&hash_context.header, v->message, v->message_len);
+ uint8_t hash[32];
+ crypto_hash_digest(&hash_context.header, hash, 32);
+ assert_memory_equal(hash, v->message_hash, 32);
+ }
+}
+
+int main(void) {
+ const struct CMUnitTest tests[] = {
+ cmocka_unit_test(test_bip322_serialize_to_spend_layout),
+ cmocka_unit_test(test_bip322_serialize_to_spend_rejects_long_script),
+ cmocka_unit_test(test_bip322_vectors_to_spend_txid),
+ cmocka_unit_test(test_bip322_vectors_to_sign_txid),
+ cmocka_unit_test(test_bip322_vectors_message_hash),
+ };
+
+ return cmocka_run_group_tests(tests, NULL, NULL);
+}
### unit-tests/test_check_merkle_tree_sorted.c
@@ -6,6 +6,10 @@
* - Rejects elements that are not in strict lexicographic order.
* - Invokes the callback once per element in order.
* - Handles edge cases (single element, empty tree, duplicate elements).
+ *
+ * Also tests the map-level wrappers, call_check_merkleized_map_sorted_with_callback and
+ * call_check_merkleized_map_sorted: they walk the keys tree of a merkleized map commitment, and
+ * mark the commitment as validated if and only if the walk succeeds.
*/
#include <stdarg.h>
@@ -395,6 +399,166 @@ static void test_map_commitment_passed(void **state) {
assert_ptr_equal(tracker.received_commitment, &dummy_commitment);
}
+/* ---------- Map-level wrappers ---------- */
+
+#define MAX_MAP_CALLBACK_CALLS 8
+
+typedef struct {
+ size_t n_calls;
+ int indices[MAX_MAP_CALLBACK_CALLS];
+ uint8_t keys[MAX_MAP_CALLBACK_CALLS][8];
+ size_t key_lens[MAX_MAP_CALLBACK_CALLS];
+ const merkleized_map_commitment_t *received_commitment;
+ bool validated_during_walk; // true if any call saw the map already marked as validated
+} map_tracker_t;
+
+static void map_tracking_callback(dispatcher_context_t *dc,
+ void *state,
+ const merkleized_map_commitment_t *map_commitment,
+ int index,
+ buffer_t *buf) {
+ (void) dc;
+
+ map_tracker_t *tracker = (map_tracker_t *) state;
+ assert_true(tracker->n_calls < MAX_MAP_CALLBACK_CALLS);
+
+ size_t i = tracker->n_calls++;
+ tracker->indices[i] = index;
+ size_t len = buf->size - buf->offset;
+ assert_true(len <= sizeof(tracker->keys[0]));
+ memcpy(tracker->keys[i], buf->ptr + buf->offset, len);
+ tracker->key_lens[i] = len;
+ tracker->received_commitment = map_commitment;
+ if (map_commitment->_keys_are_sorted) {
+ tracker->validated_during_walk = true;
+ }
+}
+
+/**
+ * Registers a map with keys {0x01}, {0x02}, {0x03} (given to the mock in a different order,
+ * which sorts them) and fills in its commitment.
+ */
+static void add_three_key_map(mock_dispatcher_t *mock, merkleized_map_commitment_t *map) {
+ const uint8_t k0[] = {0x03};
+ const uint8_t k1[] = {0x01};
+ const uint8_t k2[] = {0x02};
+ const uint8_t v0[] = {0xC0};
+ const uint8_t v1[] = {0xA0};
+ const uint8_t v2[] = {0xB0};
+
+ const uint8_t *keys[] = {k0, k1, k2};
+ const size_t key_lens[] = {sizeof(k0), sizeof(k1), sizeof(k2)};
+ const uint8_t *values[] = {v0, v1, v2};
+ const size_t value_lens[] = {sizeof(v0), sizeof(v1), sizeof(v2)};
+
+ memset(map, 0, sizeof(*map));
+ assert_int_equal(mock_dispatcher_add_map(mock, keys, key_lens, values, value_lens, 3, map), 0);
+}
+
+/**
+ * Happy path: the callback is invoked once per key, in order, with the map commitment; the map is
+ * only marked as validated once the whole walk succeeded.
+ */
+static void test_map_sorted_with_callback(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ add_three_key_map(mock, &map);
+
+ map_tracker_t tracker;
+ memset(&tracker, 0, sizeof(tracker));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result =
+ call_check_merkleized_map_sorted_with_callback(dc, &map, &tracker, map_tracking_callback);
+ assert_int_equal(result, 0);
+ assert_true(map._keys_are_sorted);
+
+ assert_int_equal(tracker.n_calls, 3);
+ assert_ptr_equal(tracker.received_commitment, &map);
+ assert_false(tracker.validated_during_walk);
+ for (size_t i = 0; i < 3; i++) {
+ assert_int_equal(tracker.indices[i], (int) i);
+ assert_int_equal(tracker.key_lens[i], 1);
+ assert_int_equal(tracker.keys[i][0], (uint8_t) (i + 1));
+ }
+}
+
+/**
+ * Happy path: the wrapper without callback validates the map too.
+ */
+static void test_map_sorted_without_callback(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ add_three_key_map(mock, &map);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ assert_int_equal(call_check_merkleized_map_sorted(dc, &map), 0);
+ assert_true(map._keys_are_sorted);
+}
+
+/**
+ * Happy path: an empty map is trivially sorted; the callback is never invoked.
+ */
+static void test_map_sorted_empty(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ memset(&map, 0, sizeof(map));
+
+ map_tracker_t tracker;
+ memset(&tracker, 0, sizeof(tracker));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result =
+ call_check_merkleized_map_sorted_with_callback(dc, &map, &tracker, map_tracking_callback);
+ assert_int_equal(result, 0);
+ assert_true(map._keys_are_sorted);
+ assert_int_equal(tracker.n_calls, 0);
+}
+
+/**
+ * Error: the keys tree of the commitment is not sorted. The map must end up marked as not
+ * validated, even if it was (wrongly) marked as validated before the call.
+ */
+static void test_map_unsorted_keys(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ const uint8_t k0[] = {0x02};
+ const uint8_t k1[] = {0x01};
+ const uint8_t *keys[] = {k0, k1};
+ const size_t key_lens[] = {1, 1};
+ mock_dispatcher_add_list(mock, keys, key_lens, 2);
+
+ merkleized_map_commitment_t map;
+ memset(&map, 0, sizeof(map));
+ map.size = 2;
+ memcpy(map.keys_root, mock->trees[mock->n_trees - 1].root, 32);
+ map._keys_are_sorted = true;
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ assert_true(call_check_merkleized_map_sorted(dc, &map) < 0);
+ assert_false(map._keys_are_sorted);
+}
+
+/**
+ * Error: the size of the commitment does not match its keys tree. The map must end up marked as
+ * not validated.
+ */
+static void test_map_wrong_size(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ merkleized_map_commitment_t map;
+ add_three_key_map(mock, &map);
+ map.size = 5;
+ map._keys_are_sorted = true;
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ assert_true(call_check_merkleized_map_sorted(dc, &map) < 0);
+ assert_false(map._keys_are_sorted);
+}
+
/* ---------- Main ---------- */
int main(void) {
@@ -411,6 +575,11 @@ int main(void) {
T(test_sorted_many_elements),
T(test_wrong_tree_size),
T(test_map_commitment_passed),
+ T(test_map_sorted_with_callback),
+ T(test_map_sorted_without_callback),
+ T(test_map_sorted_empty),
+ T(test_map_unsorted_keys),
+ T(test_map_wrong_size),
};
#undef T
Why this scored 35/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.