From f59183be586c595da9a0f78b3168017394d71413 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 16:00:34 -0300 Subject: [PATCH 01/16] feat(taproot): BIP-340 signer, build wiring and official test vectors First of three steps toward Taproot support. Adds nothing to the signing path yet -- this lands the primitive and proves it against the spec. - pin deps/crypto/trezor-firmware at the BIP-340 implementation - compile bip340.c into trezorcrypto - unittests/crypto/bip340.cpp: all 19 official BIP-340 vectors Byte-exact signatures for the 8 vectors with secret keys, correct rejection of all 10 must-fail cases (pubkey off-curve, pubkey >= field size, has_even_y(R) false, sG - eP infinite with x(inf) as both 0 and 1, sig[0:32] = field size, sig[32:64] = curve order), plus out-of-range private keys and a check that the output buffer is zeroed on failure. ROM cost measured with arm-none-eabi-gcc -Os -mcpu=cortex-m3 -mthumb: 1278 bytes of .text, no data, no bss. Taproot is already further along in this tree than it looks: coins.def carries taproot=true for Bitcoin and Testnet, segwit_addr.c selects bech32m for witness versions above 0, and PAYTOTAPROOT outputs already build and size correctly. What is missing is the input side, which the next two steps cover: 2. BIP-86 output key tweak + SPENDTAPROOT in compute_address, which removes the `return 0` at transaction.c:188 and unblocks GetAddress for bc1p 3. BIP-341 sighash + the SPENDTAPROOT signing path + confirm UX Depends on keepkey/trezor-firmware#5; the submodule pin points at that branch and needs re-pointing at its merge commit before this lands. --- deps/crypto/CMakeLists.txt | 1 + deps/crypto/trezor-firmware | 2 +- unittests/crypto/CMakeLists.txt | 1 + unittests/crypto/bip340.cpp | 286 ++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 unittests/crypto/bip340.cpp diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index 758a019fd..4a1d9d880 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -32,6 +32,7 @@ set(sources #trezor-firmware/crypto/tests/test_openssl.c #trezor-firmware/crypto/tests/test_speed.c trezor-firmware/crypto/secp256k1.c + trezor-firmware/crypto/bip340.c trezor-firmware/crypto/bignum.c trezor-firmware/crypto/segwit_addr.c trezor-firmware/crypto/ripemd160.c diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 62230ee62..fa1344ae4 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 62230ee620fa9ea2bdd2c8067be7fde3f9362248 +Subproject commit fa1344ae4d1f9e45f8ba043b60a7a156cdbab1a2 diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 6b4cbf8d6..6b2ace1ec 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + bip340.cpp rand.cpp vuln1845.cpp) diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp new file mode 100644 index 000000000..5e8b05d28 --- /dev/null +++ b/unittests/crypto/bip340.cpp @@ -0,0 +1,286 @@ +// Official BIP-340 test vectors, verbatim from +// https://github.com/bitcoin/bips/blob/master/bip-0340/test-vectors.csv +// +// Vectors with a secret key are signed and the signature compared byte for +// byte (BIP-340 signing is deterministic given aux_rand). Every vector, +// with or without a secret key, is run through verification. + +#include + +extern "C" { +#include "trezor/crypto/bip340.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include + +namespace { + +std::vector unhex(const std::string &s) { + std::vector out; + out.reserve(s.size() / 2); + for (size_t i = 0; i + 1 < s.size(); i += 2) { + out.push_back((uint8_t)std::stoul(s.substr(i, 2), nullptr, 16)); + } + return out; +} + +std::string hex(const uint8_t *p, size_t len) { + static const char *digits = "0123456789ABCDEF"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += digits[p[i] >> 4]; + out += digits[p[i] & 0x0f]; + } + return out; +} + +struct Vector { + int index; + const char *seckey; // empty when the vector is verify-only + const char *pubkey; + const char *aux; + const char *msg; + const char *sig; + bool valid; + const char *comment; +}; + +// 100 bytes of 0x99, the message of vector 18. +const char *kMsg100 = + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "99999999"; + +const Vector kVectors[] = { + {0, + "0000000000000000000000000000000000000000000000000000000000000003", + "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215" + "25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0", + true, ""}, + {1, + "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "0000000000000000000000000000000000000000000000000000000000000001", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE3341" + "8906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A", + true, ""}, + {2, + "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", + "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8", + "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906", + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", + "5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1B" + "AB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7", + true, ""}, + {3, + "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710", + "25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC" + "97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3", + true, "test fails if msg is reduced modulo p or n"}, + {4, "", + "D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9", "", + "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703", + "00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C63" + "76AFB1548AF603B3EB45C9F8207DEE1060CB71C04E80F593060B07D28308D7F4", + true, ""}, + {5, "", + "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key not on the curve"}, + {6, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFF97BD5755EEEA420453A14355235D382F6472F8568A18B2F057A1460297556" + "3CC27944640AC607CD107AE10923D9EF7A73C643E166BE5EBEAFA34B1AC553E2", + false, "has_even_y(R) is false"}, + {7, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "1FA62E331EDBC21C394792D2AB1100A7B432B013DF3F6FF4F99FCB33E0E1515F" + "28890B3EDB6E7189B630448B515CE4F8622A954CFE545735AAEA5134FCCDB2BD", + false, "negated message"}, + {8, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "961764B3AA9B2FFCB6EF947B6887A226E8D7C93E00C5ED0C1834FF0D0C2E6DA6", + false, "negated s value"}, + {9, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000000" + "123DDA8328AF9C23A94C1FEECFD123BA4FB73476F0D594DCB65C6425BD186051", + false, "sG - eP is infinite, x(inf) as 0"}, + {10, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000001" + "7615FBAF5AE28864013C099742DEADB4DBA87F11AC6754F93780D5A1837CF197", + false, "sG - eP is infinite, x(inf) as 1"}, + {11, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is not an X coordinate on the curve"}, + {12, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is equal to field size"}, + {13, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + false, "sig[32:64] is equal to curve order"}, + {14, "", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key exceeds the field size"}, + {15, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "", + "71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF" + "6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63", + true, "message of size 0"}, + {16, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "11", + "08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303" + "EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF", + true, "message of size 1"}, + {17, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + "0102030405060708090A0B0C0D0E0F1011", + "5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370" + "C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5", + true, "message of size 17"}, + {18, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + kMsg100, + "403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8" + "585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367", + true, "message of size 100"}, +}; + +} // namespace + +TEST(BIP340, TaggedHash) { + // tagged_hash("BIP0340/challenge", "") == SHA256(h || h) where + // h = SHA256("BIP0340/challenge"). Pins the double-tag construction. + uint8_t out[SHA256_DIGEST_LENGTH] = {0}; + uint8_t tag_hash[SHA256_DIGEST_LENGTH] = {0}; + uint8_t expected[SHA256_DIGEST_LENGTH] = {0}; + uint8_t doubled[2 * SHA256_DIGEST_LENGTH] = {0}; + + bip340_tagged_hash("BIP0340/challenge", nullptr, 0, out); + + sha256_Raw((const uint8_t *)"BIP0340/challenge", 17, tag_hash); + memcpy(doubled, tag_hash, sizeof(tag_hash)); + memcpy(doubled + sizeof(tag_hash), tag_hash, sizeof(tag_hash)); + sha256_Raw(doubled, sizeof(doubled), expected); + + ASSERT_EQ(0, memcmp(out, expected, sizeof(expected))); +} + +TEST(BIP340, XOnlyPubkey) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)) + << "vector " << v.index; + ASSERT_EQ(std::string(v.pubkey), hex(pk, sizeof(pk))) + << "vector " << v.index; + } +} + +TEST(BIP340, Sign) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + std::vector aux = unhex(v.aux); + std::vector msg = unhex(v.msg); + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + aux.data(), sig)) + << "vector " << v.index << ": " << v.comment; + ASSERT_EQ(std::string(v.sig), hex(sig, sizeof(sig))) + << "vector " << v.index << ": " << v.comment; + } +} + +TEST(BIP340, Verify) { + for (const auto &v : kVectors) { + std::vector pk = unhex(v.pubkey); + std::vector msg = unhex(v.msg); + std::vector sig = unhex(v.sig); + + int ret = bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data()); + if (v.valid) { + ASSERT_EQ(0, ret) << "vector " << v.index << ": " << v.comment; + } else { + ASSERT_NE(0, ret) << "vector " << v.index << ": " << v.comment; + } + } +} + +TEST(BIP340, SignRejectsOutOfRangeKeys) { + const uint8_t zero[32] = {0}; + // n, the curve order -- the first scalar that is out of range. + const uint8_t order[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, + 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41}; + const uint8_t msg[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_NE(0, bip340_sign(&secp256k1, zero, msg, sizeof(msg), nullptr, sig)); + ASSERT_NE(0, bip340_sign(&secp256k1, order, msg, sizeof(msg), nullptr, sig)); + + // A rejected signing attempt must not leave anything in the output buffer. + uint8_t empty[BIP340_SIG_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(sig, empty, sizeof(sig))); +} + +TEST(BIP340, NullAuxMatchesZeroAux) { + std::vector sk = unhex(kVectors[0].seckey); + std::vector msg = unhex(kVectors[0].msg); + uint8_t with_null[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + nullptr, with_null)); + // Vector 0 uses an all-zero aux_rand, so NULL must reproduce it exactly. + ASSERT_EQ(std::string(kVectors[0].sig), hex(with_null, sizeof(with_null))); +} From 958b41ffdccc3cd1548b4008787658d1aba37edf Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 16:21:47 -0300 Subject: [PATCH 02/16] test(taproot): make the zero-on-failure checks non-vacuous The buffer-zeroing assertion in SignRejectsOutOfRangeKeys passed whether or not bip340_sign() cleared the output, because sig started zero-initialised. Pre-fill with 0xFF so it proves something. Adds two cases: - XOnlyPubkeyZeroesOnFailure, covering the matching contract now that bip340_get_xonly_pubkey() zeroes on failure too - ZeroSTakesTheSpecPath, pinning the ABSENCE of an s == 0 guard. s == 0 is in range per BIP-340 and must reject on the x-coordinate comparison after computing R = -eP, not bail out early. Bumps the crypto pin to pick up the guard removal and the restored Bitcoin ABC copyright notice. 8/8 green. --- deps/crypto/trezor-firmware | 2 +- unittests/crypto/bip340.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index fa1344ae4..45ecd8d8a 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit fa1344ae4d1f9e45f8ba043b60a7a156cdbab1a2 +Subproject commit 45ecd8d8ab600a7b1aaa6428031a17042b7723a4 diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp index 5e8b05d28..e449dbfb8 100644 --- a/unittests/crypto/bip340.cpp +++ b/unittests/crypto/bip340.cpp @@ -266,6 +266,9 @@ TEST(BIP340, SignRejectsOutOfRangeKeys) { const uint8_t msg[32] = {0}; uint8_t sig[BIP340_SIG_LENGTH] = {0}; + // Pre-fill, so the zero-on-failure check below cannot pass vacuously. + memset(sig, 0xFF, sizeof(sig)); + ASSERT_NE(0, bip340_sign(&secp256k1, zero, msg, sizeof(msg), nullptr, sig)); ASSERT_NE(0, bip340_sign(&secp256k1, order, msg, sizeof(msg), nullptr, sig)); @@ -274,6 +277,30 @@ TEST(BIP340, SignRejectsOutOfRangeKeys) { ASSERT_EQ(0, memcmp(sig, empty, sizeof(sig))); } +TEST(BIP340, XOnlyPubkeyZeroesOnFailure) { + const uint8_t zero[32] = {0}; + uint8_t pk[BIP340_XONLY_LENGTH]; + + memset(pk, 0xFF, sizeof(pk)); + ASSERT_NE(0, bip340_get_xonly_pubkey(&secp256k1, zero, pk)); + + uint8_t empty[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(pk, empty, sizeof(pk))); +} + +TEST(BIP340, ZeroSTakesTheSpecPath) { + // s == 0 is in range per BIP-340 and carries no special guard: verification + // must compute R = -eP and reject on the x-coordinate comparison, not bail + // out early. Pins the absence of a guard that would deviate from the spec. + std::vector pk = unhex(kVectors[1].pubkey); + std::vector msg = unhex(kVectors[1].msg); + std::vector sig = unhex(kVectors[1].sig); + memset(sig.data() + 32, 0, 32); + + ASSERT_NE(0, bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data())); +} + TEST(BIP340, NullAuxMatchesZeroAux) { std::vector sk = unhex(kVectors[0].seckey); std::vector msg = unhex(kVectors[0].msg); From d5b27702f63cbb3ed11c175ca3630b86f333ccea Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 16:52:06 -0300 Subject: [PATCH 03/16] feat(taproot): P2TR addresses via GetAddress Second of three steps. Removes the `return 0` at transaction.c:188 and makes GetAddress return bc1p addresses for m/86' paths. - compute_address() handles SPENDTAPROOT: tweak the x-only internal key per BIP-86, then bech32m encode it at witness version 1 - path_mismatched() gains an m/86' branch, in BOTH copies (fsm_msg_coin.h and coins.c, per the keep-in-sync note above them) - three official BIP-86 vectors, driven from the published internal keys and again end to end from the mnemonic Two guards worth calling out, because both would have failed silently: - taproot multisig is rejected up front. Without it the request fell through to the p2sh branch and returned a p2sh address for a taproot ask -- a wrong address, not an error. - exactly 32 bytes are passed to segwit_addr_encode(), which only length-checks the witness program for version 0 (segwit_addr.c:182). Any other length would have encoded into a plausible-looking bc1p. Also fixes a pre-existing bug in the PAYTOTAPROOT output gate. It tested `!coin->has_taproot`, but has_taproot is the nanopb presence flag and every coin in coins.def sets it -- only the `taproot` VALUE distinguishes them. Bitcoin and Testnet have taproot=true; the other 41 coins have taproot=false and were all passing the gate, building p2tr outputs for chains that cannot spend them. Now tests the value. Device build clean (MAKE_EXIT=0, no warnings, all variants). Retained cost in firmware.keepkey.elf is 306 bytes; bip340_sign and bip340_verify still garbage-collect out until step 3 references them. 11/11 unit tests green. Depends on keepkey/trezor-firmware#6. --- deps/crypto/trezor-firmware | 2 +- lib/firmware/coins.c | 16 ++++++ lib/firmware/fsm_msg_coin.h | 15 +++++ lib/firmware/signing.c | 5 +- lib/firmware/transaction.c | 32 ++++++++++- unittests/crypto/bip340.cpp | 106 ++++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 5 deletions(-) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 45ecd8d8a..faf4f3fe3 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 45ecd8d8ab600a7b1aaa6428031a17042b7723a4 +Subproject commit faf4f3fe348a2c690ba6cd22b561313bcc792b98 diff --git a/lib/firmware/coins.c b/lib/firmware/coins.c index a231bc41a..964b9cf52 100644 --- a/lib/firmware/coins.c +++ b/lib/firmware/coins.c @@ -228,6 +228,22 @@ static bool path_mismatched(const CoinType* coin, const uint32_t* address_n, return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (address_n[0] == (0x80000000 + 86)) { + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (address_n_count != (whole_account ? 3 : 5)); + mismatch |= (address_n[1] != coin->bip44_account_path); + mismatch |= (address_n[2] & 0x80000000) == 0; + if (!whole_account) { + mismatch |= (address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (address_n[4] & 0x80000000) == 0x80000000; + } + return mismatch; + } + return false; } diff --git a/lib/firmware/fsm_msg_coin.h b/lib/firmware/fsm_msg_coin.h index 978c80712..d1b002a1d 100644 --- a/lib/firmware/fsm_msg_coin.h +++ b/lib/firmware/fsm_msg_coin.h @@ -188,6 +188,21 @@ static bool path_mismatched(const CoinType* coin, const GetAddress* msg) { return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (msg->address_n[0] == (0x80000000 + 86)) { + mismatch |= (msg->script_type != InputScriptType_SPENDTAPROOT); + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (msg->address_n_count != 5); + mismatch |= (msg->address_n[1] != coin->bip44_account_path); + mismatch |= (msg->address_n[2] & 0x80000000) == 0; + mismatch |= (msg->address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (msg->address_n[4] & 0x80000000) == 0x80000000; + return mismatch; + } + return false; } diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index a7b367e0f..c91945b4b 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -822,8 +822,11 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } + // has_taproot is the nanopb presence flag, not the value. Every coin in + // coins.def sets it, so testing it alone let all 41 taproot=false coins + // through and built a p2tr output for them. if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && - !coin->has_taproot) { + (!coin->has_taproot || !coin->taproot)) { fsm_sendFailure(FailureType_Failure_Other, _("Taproot not enabled on this coin.")); signing_abort(); diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index 9d30f1acd..aca2490e0 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -32,6 +32,7 @@ #include "keepkey/transport/interface.h" #include "trezor/crypto/address.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/cash_addr.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" @@ -43,6 +44,7 @@ #define _(X) (X) #define SEGWIT_VERSION_0 0 +#define SEGWIT_VERSION_1 1 #define CASHADDR_P2KH (0) #define CASHADDR_P2SH (8) @@ -125,6 +127,11 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, if (has_multisig) { size_t prelen; + // No taproot multisig. Without this the request would fall through to + // the p2sh branch below and hand back a p2sh address for a taproot ask. + if (script_type == InputScriptType_SPENDTAPROOT) { + return 0; + } if (cryptoMultisigPubkeyIndex(coin, multisig, node->public_key) < 0) { return 0; } @@ -186,9 +193,28 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, return 0; } } else if (script_type == InputScriptType_SPENDTAPROOT) { - // we don't handle spendtaproot input types - return 0; - + // p2tr: the witness program is the BIP-86 tweaked output key, bech32m + // encoded at witness version 1. + if ((!coin->has_segwit || !coin->segwit) || !coin->has_bech32_prefix) { + return 0; + } + if (!coin->has_taproot || !coin->taproot) { + return 0; + } + uint8_t output_key[32]; + // node->public_key is compressed; bytes 1..33 are the x-only internal key. + // BIP-341 defines the internal key as x-only, so the odd-y case resolves + // to its even-y counterpart here and in the signer alike. + if (bip340_tweak_pubkey(curve->params, node->public_key + 1, output_key) != + 0) { + return 0; + } + // Exactly 32 bytes: segwit_addr_encode only length-checks the witness + // program for version 0, so a wrong length would encode silently. + if (!segwit_addr_encode(address, coin->bech32_prefix, SEGWIT_VERSION_1, + output_key, sizeof(output_key))) { + return 0; + } } else if (script_type == InputScriptType_SPENDP2SHWITNESS) { // segwit p2wpkh embedded in p2sh if (!coin->has_segwit || !coin->segwit) { diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp index e449dbfb8..dd157cbb5 100644 --- a/unittests/crypto/bip340.cpp +++ b/unittests/crypto/bip340.cpp @@ -8,13 +8,18 @@ #include extern "C" { +#include "trezor/crypto/bip32.h" #include "trezor/crypto/bip340.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/segwit_addr.h" } #include "gtest/gtest.h" +#include #include #include #include @@ -191,6 +196,107 @@ const Vector kVectors[] = { } // namespace +// Official BIP-86 test vectors, from +// https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki +// (mnemonic "abandon abandon ... about", account m/86'/0'/0'). +// HD derivation is covered at the firmware level; what is pinned here is the +// tweak and the bech32m encoding that turn an internal key into an address. +TEST(BIP340, BIP86Vectors) { + const struct { + const char *path; + const char *internal_key; + const char *output_key; + const char *address; + } vectors[] = { + {"m/86'/0'/0'/0/0", + "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115", + "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c", + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {"m/86'/0'/0'/0/1", + "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", + "a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb", + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {"m/86'/0'/0'/1/0", + "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef", + "882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc", + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + for (const auto &v : vectors) { + std::vector internal = unhex(v.internal_key); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, internal.data(), out)) + << v.path; + + std::string got = hex(out, sizeof(out)); + std::transform(got.begin(), got.end(), got.begin(), ::tolower); + ASSERT_EQ(std::string(v.output_key), got) << v.path; + + // Witness version 1 + 32 bytes must come out bech32m, i.e. a bc1p address. + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))) + << v.path; + ASSERT_EQ(std::string(v.address), std::string(address)) << v.path; + } +} + +// The same BIP-86 vectors driven from the mnemonic, so HD derivation and the +// x-only convention are covered too. compute_address() feeds +// node->public_key + 1 to bip340_tweak_pubkey(); this pins that the byte after +// the compressed prefix really is the internal key BIP-86 expects, which an +// off-by-one would otherwise turn into a valid-looking wrong address. +TEST(BIP340, BIP86FromMnemonic) { + const char *mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon about"; + const struct { + uint32_t change; + uint32_t index; + const char *address; + } vectors[] = { + {0, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {0, 1, "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {1, 0, "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + uint8_t seed[64] = {0}; + mnemonic_to_seed(mnemonic, "", seed, nullptr); + + for (const auto &v : vectors) { + HDNode node = {0}; + ASSERT_EQ(1, hdnode_from_seed(seed, sizeof(seed), SECP256K1_NAME, &node)); + // m/86'/0'/0'/change/index + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 86)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.change)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.index)); + hdnode_fill_public_key(&node); + + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, node.public_key + 1, out)); + + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))); + ASSERT_EQ(std::string(v.address), std::string(address)) + << "change=" << v.change << " index=" << v.index; + } +} + +TEST(BIP340, TweakRejectsInvalidInternalKey) { + // Vector 5's x coordinate, which is not on the curve. + std::vector bad = + unhex("EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34"); + // And an x coordinate past the field size (vector 14). + std::vector too_big = + unhex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, bad.data(), out)); + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, too_big.data(), out)); +} + TEST(BIP340, TaggedHash) { // tagged_hash("BIP0340/challenge", "") == SHA256(h || h) where // h = SHA256("BIP0340/challenge"). Pins the double-tag construction. From 1030ed4ffe906a376edd26ca71c6d65ad613615a Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 17:22:27 -0300 Subject: [PATCH 04/16] test(taproot): pin python-keepkey at the BIP-86 GetAddress test Bumps deps/python-keepkey by one commit to pick up tests/test_msg_getaddress_taproot.py (BitHighlander/python-keepkey#28), which drives the emulator through the full SPENDTAPROOT GetAddress path and asserts the three official BIP-86 addresses. Verified against a locally built kkemu: 1 passed, and mutation checked so the assertions are known not to be vacuous. The test gates on firmware 7.16.0 and CMakeLists is 7.15.0, so it SKIPS until the project version bumps. Deliberate: gating at 7.15.0 would make released 7.15.0-rcN firmware without taproot fail rather than skip. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index d88a073a5..7423fcfc6 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d88a073a5af2d83e0f1f19574665ca1a44789414 +Subproject commit 7423fcfc647a4622628fe376f3faf2624a310468 From db50b8870752e58aa65340bdbdd0b41e8a792a8e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 20:20:20 -0300 Subject: [PATCH 05/16] feat(taproot): spend from P2TR inputs (BIP-341 key path) Third and last step: SPENDTAPROOT inputs are now signed. - signing.c accumulates sha_amounts and sha_scriptpubkeys over every input. BIP-341 commits to the amount and scriptPubKey of ALL inputs, not just the taproot ones, which BIP-143 never required. - signing_hash_bip341() delegates the SigMsg assembly to bip341_sighash() in the crypto lib, so the field ordering is unit-testable against the published vectors instead of only reviewable. - the witness is a single 64-byte element. SIGHASH_DEFAULT omits the trailing sighash byte; appending 0x00 would be a different signature and would fail verification. - signing keys go through bip340_tweak_seckey(), so the signature verifies against the output key in the scriptPubKey rather than the internal key. - transaction.c gains address_to_script_pubkey() and fill_input_script_pubkey() to derive each input's scriptPubKey. - taproot inputs are rejected on coins with taproot=false, matching the output-side gate. SRAM: +448 B bss, which is ABOVE the 256 B single-commit threshold that tools/sram-budgets.json says needs explicit review -- flagging rather than sneaking it through. It was +1072 B until the two new accumulators were changed from Hasher to SHA256_CTX: BIP-341 fixes them to plain SHA256, while a Hasher carries a union sized by GROESTL512_CTX and cost ~1.2 KB for no benefit. The remainder is two SHA256 contexts, two 32-byte digests, the tweaked key and one HDNode. ROM: +2928 B text, 1900 B of it bip340/bip341. Nothing garbage-collects out now that the signing path references it. Device build clean, no warnings, all variants. Crypto validated against BIP-341's published transaction: tweaked privkey, sigHash and the 64-byte witness all match byte for byte. NOT yet verified: signing on the emulator, and no OLED proof. --- deps/crypto/trezor-firmware | 2 +- include/keepkey/firmware/transaction.h | 9 +++ lib/firmware/signing.c | 106 ++++++++++++++++++++++++- lib/firmware/transaction.c | 92 ++++++++++++++++++++- unittests/crypto/bip340.cpp | 105 +++++++++++++++++++++++- 5 files changed, 306 insertions(+), 8 deletions(-) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index faf4f3fe3..ebbd21ece 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit faf4f3fe348a2c690ba6cd22b561313bcc792b98 +Subproject commit ebbd21ececde59341d3ad265f08ff6534b7f8d14 diff --git a/include/keepkey/firmware/transaction.h b/include/keepkey/firmware/transaction.h index fd699554c..5b3525a17 100644 --- a/include/keepkey/firmware/transaction.h +++ b/include/keepkey/firmware/transaction.h @@ -78,6 +78,15 @@ uint32_t serialize_script_multisig(const CoinType* coin, int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, TxOutputBinType* out, bool needs_confirm); +bool address_to_script_pubkey(const CoinType* coin, const char* address, + uint8_t* script_pubkey, size_t* script_pubkey_len, + size_t script_pubkey_size); + +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size); + uint32_t tx_prevout_hash(Hasher* hasher, const TxInputType* input); uint32_t tx_script_hash(Hasher* hasher, uint32_t size, const uint8_t* data); uint32_t tx_sequence_hash(Hasher* hasher, const TxInputType* input); diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index c91945b4b..6458f4c46 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -33,6 +33,7 @@ #include "keepkey/firmware/signing.h" #include "keepkey/firmware/txin_check.h" #include "keepkey/firmware/transaction.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" @@ -82,9 +83,16 @@ static TxInputType input; static TxOutputBinType bin_output; static TxStruct to, tp, ti; static Hasher hasher_prevouts, hasher_sequence, hasher_outputs, hasher_check; +/* BIP-341 commits to every input's amount and scriptPubKey, which BIP-143 + does not, so taproot needs two accumulators segwit never required. These + are SHA256_CTX rather than Hasher because BIP-341 fixes them to plain + SHA256: a Hasher carries a union sized by GROESTL512_CTX and would cost + ~1.2 KB of SRAM here for no benefit. */ +static SHA256_CTX ctx_amounts, ctx_scriptpubkeys; static uint8_t CONFIDENTIAL privkey[32]; static uint8_t pubkey[33], sig[64]; static uint8_t hash_prevouts[32], hash_sequence[32], hash_outputs[32]; +static uint8_t hash_amounts[32], hash_scriptpubkeys[32]; static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; @@ -113,6 +121,10 @@ static uint32_t tx_weight; use and still allow to quickly brute-force the correct bip32 path. */ #define BIP32_MAX_LAST_ELEMENT 1000000 +/* BIP-341 SIGHASH_DEFAULT: commits to all outputs, and unlike SIGHASH_ALL + its byte is omitted from the witness entirely. */ +#define SIGHASH_ALL_TAPROOT 0 + /* transaction header size: 4 byte version */ #define TXSIZE_HEADER 4 /* transaction footer size: 4 byte lock time */ @@ -408,6 +420,8 @@ void phase1_request_next_input(void) { // compute segwit hashPrevouts & hashSequence hasher_Final(&hasher_prevouts, hash_prevouts); hasher_Final(&hasher_sequence, hash_sequence); + sha256_Final(&ctx_amounts, hash_amounts); + sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); hasher_Final(&hasher_check, hash_check); // init hashOutputs hasher_Reset(&hasher_outputs); @@ -690,6 +704,10 @@ void signing_init(const SignTx* msg, const CoinType* _coin, hasher_Init(&hasher_sequence, curve->hasher_sign); hasher_Init(&hasher_outputs, curve->hasher_sign); hasher_Init(&hasher_check, curve->hasher_sign); + /* BIP-341 fixes these two as plain SHA256, independent of the coin's + signing hasher, so they are initialised separately on purpose. */ + sha256_Init(&ctx_amounts); + sha256_Init(&ctx_scriptpubkeys); } layoutProgressSwipe(_("Signing transaction"), 0); @@ -737,7 +755,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { static bool is_segwit_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -779,6 +798,13 @@ static bool signing_validate_input(const TxInputType* txinput) { } } + if (txinput->script_type == InputScriptType_SPENDTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } if (is_segwit_input_script_type(txinput)) { if (!coin->has_segwit) { fsm_sendFailure(FailureType_Failure_Other, @@ -901,6 +927,28 @@ static bool signing_check_input(TxInputType* txinput) { // compute segwit hashPrevouts & hashSequence tx_prevout_hash(&hasher_prevouts, txinput); tx_sequence_hash(&hasher_sequence, txinput); + // BIP-341 commits to the amount and scriptPubKey of EVERY input, not just + // the taproot ones, so these must be accumulated for all of them. Only + // coins with taproot enabled pay the per-input derivation, and + // hdnode_private_ckd_cached keeps repeated derivations along one account + // path cheap. + if (coin->has_taproot && coin->taproot) { + uint8_t script_pubkey[64]; + size_t script_pubkey_len = 0; + if (!fill_input_script_pubkey(coin, root, txinput, script_pubkey, + &script_pubkey_len, + sizeof(script_pubkey))) { + fsm_sendFailure(FailureType_Failure_Other, + _("Failed to derive input scriptPubKey")); + signing_abort(); + return false; + } + sha256_Update(&ctx_amounts, (const uint8_t*)&txinput->amount, 8); + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(script_pubkey_len, lenbuf); + sha256_Update(&ctx_scriptpubkeys, lenbuf, lenlen); + sha256_Update(&ctx_scriptpubkeys, script_pubkey, script_pubkey_len); + } if (coin->decred) { if (txinput->decred_script_version > 0) { fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -1080,6 +1128,14 @@ static void phase1_request_next_output(void) { } } +/* BIP-341 key-path sighash. The assembly itself lives in bip341_sighash() + so the field ordering is unit-testable against the published vectors. */ +static void signing_hash_bip341(uint32_t input_index, uint8_t* hash) { + bip341_sighash(SIGHASH_ALL_TAPROOT, version, lock_time, hash_prevouts, + hash_amounts, hash_scriptpubkeys, hash_sequence, hash_outputs, + input_index, hash); +} + static void signing_hash_bip143(const TxInputType* txinput, uint8_t* hash) { uint32_t hash_type = signing_hash_type(); Hasher hasher_preimage; @@ -1253,7 +1309,53 @@ static bool signing_sign_input(void) { static bool signing_sign_segwit_input(TxInputType* txinput) { // idx1: index to sign - if (is_segwit_input_script_type(txinput)) { + if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + if (txinput->amount > authorized_bip143_in) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transaction has changed during signing")); + signing_abort(); + return false; + } + authorized_bip143_in -= txinput->amount; + + uint8_t hash[32]; + signing_hash_bip341(idx1, hash); + + /* Sign with the BIP-86 tweaked key, so the signature verifies against the + output key committed to in the scriptPubKey rather than the internal + key. */ + static CONFIDENTIAL uint8_t tweaked[32]; + if (bip340_tweak_seckey(curve->params, node.private_key, + /*merkle_root=*/NULL, tweaked) != 0) { + memzero(tweaked, sizeof(tweaked)); + fsm_sendFailure(FailureType_Failure_Other, _("Failed to tweak key")); + signing_abort(); + return false; + } + int sign_ret = bip340_sign(curve->params, tweaked, hash, sizeof(hash), + /*aux=*/NULL, sig); + memzero(tweaked, sizeof(tweaked)); + if (sign_ret != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("Signing failed")); + signing_abort(); + return false; + } + + resp.has_serialized = true; + resp.serialized.has_signature_index = true; + resp.serialized.signature_index = idx1; + resp.serialized.has_signature = true; + resp.serialized.signature.size = 64; + memcpy(resp.serialized.signature.bytes, sig, 64); + + /* Witness is a single 64-byte element. SIGHASH_DEFAULT omits the trailing + sighash byte entirely -- appending 0x00 would be a different, invalid + signature. */ + uint32_t r = 0; + r += ser_length(1, resp.serialized.serialized_tx.bytes + r); + r += tx_serialize_script(64, sig, resp.serialized.serialized_tx.bytes + r); + resp.serialized.serialized_tx.size = r; + } else if (is_segwit_input_script_type(txinput)) { if (!compile_input_script_sig(txinput)) { fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); signing_abort(); diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index aca2490e0..4f9bfc49f 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -115,6 +115,95 @@ uint32_t op_push(uint32_t i, uint8_t* out) { return 5; } +bool address_to_script_pubkey(const CoinType* coin, const char* address, + uint8_t* script_pubkey, size_t* script_pubkey_len, + size_t script_pubkey_size) { + uint8_t addr_raw[MAX_ADDR_RAW_SIZE]; + size_t addr_raw_len; + int witver; + + const curve_info* curve = get_curve_by_name(coin->curve_name); + if (!curve) return false; + + // Deliberately narrower than compile_output's output-side decoding: this is + // only reached for inputs of a coin with taproot enabled, i.e. Bitcoin and + // Testnet, so cashaddr and the BCH burn warning do not apply. Kept separate + // rather than factored out of compile_output because that block interleaves + // confirm-and-cancel UX with the script building. + if (coin->has_bech32_prefix && + segwit_addr_decode(&witver, addr_raw, &addr_raw_len, coin->bech32_prefix, + address)) { + // push witness version (OP_0 = 0, OP_i = 80 + i), then the program + if (addr_raw_len + 2 > script_pubkey_size) { + return false; + } + script_pubkey[0] = witver == 0 ? 0 : 80 + witver; + script_pubkey[1] = addr_raw_len; + memcpy(script_pubkey + 2, addr_raw, addr_raw_len); + *script_pubkey_len = addr_raw_len + 2; + return true; + } + + addr_raw_len = base58_decode_check(address, curve->hasher_base58, addr_raw, + MAX_ADDR_RAW_SIZE); + + if (coin->has_address_type && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type) && + address_check_prefix(addr_raw, coin->address_type)) { + if (25 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0x76; // OP_DUP + script_pubkey[1] = 0xA9; // OP_HASH_160 + script_pubkey[2] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 3, + addr_raw + address_prefix_bytes_len(coin->address_type), 20); + script_pubkey[23] = 0x88; // OP_EQUALVERIFY + script_pubkey[24] = 0xAC; // OP_CHECKSIG + *script_pubkey_len = 25; + return true; + } + + if (coin->has_address_type_p2sh && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type_p2sh) && + address_check_prefix(addr_raw, coin->address_type_p2sh)) { + if (23 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0xA9; // OP_HASH_160 + script_pubkey[1] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 2, + addr_raw + address_prefix_bytes_len(coin->address_type_p2sh), 20); + script_pubkey[22] = 0x87; // OP_EQUAL + *script_pubkey_len = 23; + return true; + } + + return false; +} + +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size) { + static CONFIDENTIAL HDNode node; + char address[MAX_ADDR_SIZE] = {0}; + bool res; + + memcpy(&node, root, sizeof(HDNode)); + res = hdnode_private_ckd_cached(&node, in->address_n, in->address_n_count, + NULL) != 0; + if (res) { + hdnode_fill_public_key(&node); // returns void in this tree + } + res = res && compute_address(coin, in->script_type, &node, in->has_multisig, + &in->multisig, address); + memzero(&node, sizeof(node)); + + return res && address_to_script_pubkey(coin, address, script_pubkey, + script_pubkey_len, script_pubkey_size); +} + bool compute_address(const CoinType* coin, InputScriptType script_type, const HDNode* node, bool has_multisig, const MultisigRedeemScriptType* multisig, @@ -205,7 +294,8 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, // node->public_key is compressed; bytes 1..33 are the x-only internal key. // BIP-341 defines the internal key as x-only, so the odd-y case resolves // to its even-y counterpart here and in the signer alike. - if (bip340_tweak_pubkey(curve->params, node->public_key + 1, output_key) != + if (bip340_tweak_pubkey(curve->params, node->public_key + 1, + /*merkle_root=*/NULL, output_key) != 0) { return 0; } diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp index dd157cbb5..3dbb363c4 100644 --- a/unittests/crypto/bip340.cpp +++ b/unittests/crypto/bip340.cpp @@ -226,7 +226,7 @@ TEST(BIP340, BIP86Vectors) { std::vector internal = unhex(v.internal_key); uint8_t out[BIP340_XONLY_LENGTH] = {0}; - ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, internal.data(), out)) + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, internal.data(), nullptr, out)) << v.path; std::string got = hex(out, sizeof(out)); @@ -275,7 +275,7 @@ TEST(BIP340, BIP86FromMnemonic) { hdnode_fill_public_key(&node); uint8_t out[BIP340_XONLY_LENGTH] = {0}; - ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, node.public_key + 1, out)); + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, node.public_key + 1, nullptr, out)); char address[MAX_ADDR_SIZE] = {0}; ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))); @@ -284,6 +284,103 @@ TEST(BIP340, BIP86FromMnemonic) { } } +// Official BIP-341 key-path spending vector, input index 4, from +// https://github.com/bitcoin/bips/blob/master/bip-0341/wallet-test-vectors.json +// +// This is the only published input that uses SIGHASH_DEFAULT (hashType 0), so +// it is the one that pins our signing configuration end to end. It carries a +// merkle root, which is why bip340_tweak_seckey/pubkey take one -- without it +// there is no published witness to check the sigmsg field ordering against, +// and a transposed field yields a perfectly valid signature over the wrong +// transaction. +namespace bip341 { +const char *kInternalPrivkey = + "f36bb07a11e469ce941d16b63b11b9b9120a84d9d87cff2c84a8d4affb438f4e"; +const char *kInternalPubkey = + "e0dfe2300b0dd746a3f8674dfd4525623639042569d829c7f0eed9602d263e6f"; +const char *kMerkleRoot = + "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2"; +const char *kTweakedPrivkey = + "a8e7aa924f0d58854185a490e6c41f6efb7b675c0f3331b7f14b549400b4d501"; +const char *kSigHash = + "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef"; +const char *kWitness = + "b4010dd48a617db09926f729e79c33ae0b4e94b79f04a1ae93ede6315eb3669d" + "e185a17d2b0ac9ee09fd4c64b678a0b61a0a86fa888a273c8511be83bfd6810f"; +const char *kHashPrevouts = + "e3b33bb4ef3a52ad1fffb555c0d82828eb22737036eaeb02a235d82b909c4c3f"; +const char *kHashAmounts = + "58a6964a4f5f8f0b642ded0a8a553be7622a719da71d1f5befcefcdee8e0fde6"; +const char *kHashScriptPubkeys = + "23ad0f61ad2bca5ba6a7693f50fce988e17c3780bf2b1e720cfbb38fbdd52e21"; +const char *kHashSequences = + "18959c7221ab5ce9e26c3cd67b22c24f8baa54bac281d8e6b05e400e6c3a957e"; +const char *kHashOutputs = + "a2e6dab7c1f0dcd297c8d61647fd17d821541ea69c3cc37dcbad7f90d4eb4bc5"; +const uint32_t kVersion = 2; +const uint32_t kLockTime = 500000000; +const uint32_t kInputIndex = 4; + +std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), ::tolower); + return s; +} +} // namespace bip341 + +TEST(BIP341, TweakSeckey) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + uint8_t tweaked[32] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)); + ASSERT_EQ(std::string(bip341::kInternalPubkey), + bip341::lower(hex(pk, sizeof(pk)))); + + ASSERT_EQ(0, bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), + tweaked)); + ASSERT_EQ(std::string(bip341::kTweakedPrivkey), + bip341::lower(hex(tweaked, sizeof(tweaked)))); + + // The tweaked private key must correspond to the tweaked public key, or the + // signature verifies under a key that does not own the output. + uint8_t from_seckey[BIP340_XONLY_LENGTH] = {0}; + uint8_t from_pubkey[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, tweaked, from_seckey)); + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, pk, root.data(), from_pubkey)); + ASSERT_EQ(0, memcmp(from_seckey, from_pubkey, BIP340_XONLY_LENGTH)); +} + +TEST(BIP341, Sighash) { + std::vector prevouts = unhex(bip341::kHashPrevouts); + std::vector amounts = unhex(bip341::kHashAmounts); + std::vector spks = unhex(bip341::kHashScriptPubkeys); + std::vector seqs = unhex(bip341::kHashSequences); + std::vector outs = unhex(bip341::kHashOutputs); + uint8_t hash[SHA256_DIGEST_LENGTH] = {0}; + + bip341_sighash(/*hash_type=*/0, bip341::kVersion, bip341::kLockTime, + prevouts.data(), amounts.data(), spks.data(), seqs.data(), + outs.data(), bip341::kInputIndex, hash); + + ASSERT_EQ(std::string(bip341::kSigHash), bip341::lower(hex(hash, sizeof(hash)))); +} + +TEST(BIP341, KeyPathSignatureMatchesPublishedWitness) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + std::vector sighash = unhex(bip341::kSigHash); + uint8_t tweaked[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), + tweaked)); + // BIP-341's vectors are generated with an all-zero aux_rand. + ASSERT_EQ(0, bip340_sign(&secp256k1, tweaked, sighash.data(), sighash.size(), + nullptr, sig)); + ASSERT_EQ(std::string(bip341::kWitness), bip341::lower(hex(sig, sizeof(sig)))); +} + TEST(BIP340, TweakRejectsInvalidInternalKey) { // Vector 5's x coordinate, which is not on the curve. std::vector bad = @@ -293,8 +390,8 @@ TEST(BIP340, TweakRejectsInvalidInternalKey) { unhex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"); uint8_t out[BIP340_XONLY_LENGTH] = {0}; - ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, bad.data(), out)); - ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, too_big.data(), out)); + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, bad.data(), nullptr, out)); + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, too_big.data(), nullptr, out)); } TEST(BIP340, TaggedHash) { From a5c8924e723d34c04f2f0e7ef047fc93a727da9b Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 31 Jul 2026 20:51:31 -0300 Subject: [PATCH 06/16] fix(taproot): BIP-341 needs single-sha256 accumulators, not BIP-143's Emulator cross-check against an independent BIP-340/341 implementation caught the device signing a valid signature over the wrong commitment. BIP-143 hashes prevouts, sequences and outputs with DOUBLE sha256 -- curve->hasher_sign is HASHER_SHA2D for Bitcoin -- while BIP-341 specifies SINGLE sha256. Reusing hash_prevouts/hash_sequence/hash_outputs was therefore wrong in a way nothing self-consistent could detect: the signature verified fine against the sighash the device computed, and that sighash committed to a transaction nobody had authorised. Trezor's reference keeps hash_prevouts143 next to hash_prevouts for this reason. Adds the parallel single-sha256 set and points the sighash at it. Also wires taproot through three classifiers it was missing, each of which failed closed rather than silently: - is_internal_input_script_type: a taproot input may carry address_n - is_change_output_script_type: taproot CHANGE was being rejected - the phase-1 dispatch, which routed only SPENDWITNESS/SPENDP2SHWITNESS down the segwit path and extracts prepare_input_node() from compile_input_script_sig(), so a taproot input gets the same re-validation and derivation without building a scriptSig it does not have. Skipping that would also have skipped the guard that the host has not swapped address_n between phases. Emulator: witness matches the independent implementation byte for byte. Both device variants build clean, no warnings. SRAM +856 B bss over the step-2 baseline, above the 256 B single-commit threshold in tools/sram-budgets.json and flagged accordingly: five SHA256 contexts, five digests, the tweaked key and one HDNode. ROM +3216 B text. --- deps/python-keepkey | 2 +- lib/firmware/signing.c | 62 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 7423fcfc6..211ab9938 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 7423fcfc647a4622628fe376f3faf2624a310468 +Subproject commit 211ab9938587959092cc94a72133f7b2fb1b8077 diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index 6458f4c46..8ad55d11a 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -89,10 +89,16 @@ static Hasher hasher_prevouts, hasher_sequence, hasher_outputs, hasher_check; SHA256: a Hasher carries a union sized by GROESTL512_CTX and would cost ~1.2 KB of SRAM here for no benefit. */ static SHA256_CTX ctx_amounts, ctx_scriptpubkeys; +/* BIP-143 hashes prevouts/sequences/outputs with DOUBLE sha256 (hasher_sign is + HASHER_SHA2D for Bitcoin); BIP-341 specifies SINGLE sha256. The BIP-143 + accumulators therefore cannot be reused -- doing so yields a valid signature + over the wrong commitment. Hence a parallel set. */ +static SHA256_CTX ctx_prevouts_tr, ctx_sequences_tr, ctx_outputs_tr; static uint8_t CONFIDENTIAL privkey[32]; static uint8_t pubkey[33], sig[64]; static uint8_t hash_prevouts[32], hash_sequence[32], hash_outputs[32]; static uint8_t hash_amounts[32], hash_scriptpubkeys[32]; +static uint8_t hash_prevouts_tr[32], hash_sequences_tr[32], hash_outputs_tr[32]; static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; @@ -422,6 +428,8 @@ void phase1_request_next_input(void) { hasher_Final(&hasher_sequence, hash_sequence); sha256_Final(&ctx_amounts, hash_amounts); sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); + sha256_Final(&ctx_prevouts_tr, hash_prevouts_tr); + sha256_Final(&ctx_sequences_tr, hash_sequences_tr); hasher_Final(&hasher_check, hash_check); // init hashOutputs hasher_Reset(&hasher_outputs); @@ -578,7 +586,12 @@ bool check_change_bip32_path(const TxOutputType* toutput) { toutput->address_n[count - 1] <= BIP32_MAX_LAST_ELEMENT); } -bool compile_input_script_sig(TxInputType* tinput) { +/* Re-validates the input against what phase 1 saw, then derives its node into + `node`. Split out of compile_input_script_sig() so taproot can reuse the + checks and the derivation without building a scriptSig -- a taproot input + has an empty one, and skipping this would also skip the guard that the + host has not swapped address_n between phases. */ +static bool prepare_input_node(TxInputType* tinput) { if (!multisig_fp_mismatch) { // check that this is still multisig uint8_t h[32]; @@ -608,6 +621,13 @@ bool compile_input_script_sig(TxInputType* tinput) { return false; } hdnode_fill_public_key(&node); + return true; +} + +bool compile_input_script_sig(TxInputType* tinput) { + if (!prepare_input_node(tinput)) { + return false; + } if (tinput->has_multisig) { tinput->script_sig.size = compile_script_multisig(coin, &(tinput->multisig), tinput->script_sig.bytes); @@ -708,6 +728,9 @@ void signing_init(const SignTx* msg, const CoinType* _coin, signing hasher, so they are initialised separately on purpose. */ sha256_Init(&ctx_amounts); sha256_Init(&ctx_scriptpubkeys); + sha256_Init(&ctx_prevouts_tr); + sha256_Init(&ctx_sequences_tr); + sha256_Init(&ctx_outputs_tr); } layoutProgressSwipe(_("Signing transaction"), 0); @@ -727,7 +750,8 @@ static bool is_multisig_input_script_type(const TxInputType* txinput) { static bool is_multisig_output_script_type(const TxOutputType* txoutput) { if (txoutput->script_type == OutputScriptType_PAYTOMULTISIG || txoutput->script_type == OutputScriptType_PAYTOP2SHWITNESS || - txoutput->script_type == OutputScriptType_PAYTOWITNESS) { + txoutput->script_type == OutputScriptType_PAYTOWITNESS || + txoutput->script_type == OutputScriptType_PAYTOTAPROOT) { return true; } return false; @@ -737,7 +761,8 @@ static bool is_internal_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDADDRESS || txinput->script_type == InputScriptType_SPENDMULTISIG || txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -943,6 +968,13 @@ static bool signing_check_input(TxInputType* txinput) { signing_abort(); return false; } + /* outpoint: prev_hash is carried display-order and goes out reversed, + matching tx_prevout_hash() */ + for (int i = 0; i < 32; i++) { + sha256_Update(&ctx_prevouts_tr, &txinput->prev_hash.bytes[31 - i], 1); + } + sha256_Update(&ctx_prevouts_tr, (const uint8_t*)&txinput->prev_index, 4); + sha256_Update(&ctx_sequences_tr, (const uint8_t*)&txinput->sequence, 4); sha256_Update(&ctx_amounts, (const uint8_t*)&txinput->amount, 8); uint8_t lenbuf[5]; uint32_t lenlen = ser_length(script_pubkey_len, lenbuf); @@ -1053,6 +1085,15 @@ static bool signing_check_output(TxOutputType* txoutput) { } // compute segwit hashOuts tx_output_hash(&hasher_outputs, &bin_output, coin->decred); + /* BIP-341's sha_outputs: single sha256 over amount || ser_script */ + sha256_Update(&ctx_outputs_tr, (const uint8_t*)&bin_output.amount, 8); + { + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(bin_output.script_pubkey.size, lenbuf); + sha256_Update(&ctx_outputs_tr, lenbuf, lenlen); + sha256_Update(&ctx_outputs_tr, bin_output.script_pubkey.bytes, + bin_output.script_pubkey.size); + } return true; } @@ -1112,6 +1153,7 @@ static void phase1_request_next_output(void) { tx_hash_final(&ti, hash_prefix, false); } hasher_Final(&hasher_outputs, hash_outputs); + sha256_Final(&ctx_outputs_tr, hash_outputs_tr); if (!signing_check_fee()) { return; } @@ -1131,9 +1173,9 @@ static void phase1_request_next_output(void) { /* BIP-341 key-path sighash. The assembly itself lives in bip341_sighash() so the field ordering is unit-testable against the published vectors. */ static void signing_hash_bip341(uint32_t input_index, uint8_t* hash) { - bip341_sighash(SIGHASH_ALL_TAPROOT, version, lock_time, hash_prevouts, - hash_amounts, hash_scriptpubkeys, hash_sequence, hash_outputs, - input_index, hash); + bip341_sighash(SIGHASH_ALL_TAPROOT, version, lock_time, hash_prevouts_tr, + hash_amounts, hash_scriptpubkeys, hash_sequences_tr, + hash_outputs_tr, input_index, hash); } static void signing_hash_bip143(const TxInputType* txinput, uint8_t* hash) { @@ -1310,6 +1352,13 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { // idx1: index to sign if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + /* No scriptSig to build, but the same re-validation and derivation the + other input types get. */ + if (!prepare_input_node(txinput)) { + fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); + signing_abort(); + return false; + } if (txinput->amount > authorized_bip143_in) { fsm_sendFailure(FailureType_Failure_SyntaxError, _("Transaction has changed during signing")); @@ -1508,6 +1557,7 @@ void signing_txack(TransactionType* tx) { send_req_2_prev_meta(); } } else if (tx->inputs[0].script_type == InputScriptType_SPENDWITNESS || + tx->inputs[0].script_type == InputScriptType_SPENDTAPROOT || tx->inputs[0].script_type == InputScriptType_SPENDP2SHWITNESS) { if (coin->decred) { From ac208f6eccc9f6adf881b7da8fb580164dbc062f Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 1 Aug 2026 19:29:57 -0300 Subject: [PATCH 07/16] feat(taproot): report supports_taproot in GetFeatures Lets a host ask the device whether it can derive and spend P2TR instead of inferring it from a firmware version. Version inference breaks the moment the feature is retargeted to a different release, and forces every client to carry a version table. Also unblocks the host side: Pioneer's taproot flag currently has no way to ask whether the connected firmware can verify a bc1p on screen or spend from one, so it has to guess. Bumps device-protocol for Features.supports_taproot (field 27; 19 and 20 are gaps with no reserved markers and are not safe to reuse against historical wire data), and python-keepkey for the regenerated bindings plus the tests that now gate on the capability rather than a version. --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- lib/firmware/fsm_msg_common.h | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index f2246cebe..171eaa098 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit f2246cebea8f96fcd7ec2883588a784a60b430ae +Subproject commit 171eaa09804c019e4bd9dcb649c68956f850255a diff --git a/deps/python-keepkey b/deps/python-keepkey index 211ab9938..dd0a59dfa 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 211ab9938587959092cc94a72133f7b2fb1b8077 +Subproject commit dd0a59dfa31e65ed8bb1170700b43669c8e2f215 diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 1547486a9..82c9ec4d4 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -41,6 +41,12 @@ void fsm_msgGetFeatures(GetFeatures* msg) { resp->has_model = true; strlcpy(resp->model, model(), sizeof(resp->model)); + /* Taproot capability. Reported directly so a host does not have to infer + P2TR support from a firmware version -- that inference breaks whenever the + feature is retargeted to a different release. */ + resp->has_supports_taproot = true; + resp->supports_taproot = true; + /* Variant Name */ resp->has_firmware_variant = true; #if BITCOIN_ONLY From 97ed1a775a141780952ba49bf2643d47d74ea17f Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 1 Aug 2026 19:31:32 -0300 Subject: [PATCH 08/16] docs(taproot): say why signing uses a zero aux_rand Deterministic signing is a choice a reviewer will question. It is spec-permitted, matches this firmware's RFC6979 ECDSA, and the nonce still depends on key and message so it is never reused across transactions. Fresh randomness would only add side-channel hardening, at the cost of making signatures unreproducible and therefore untestable against a published vector. --- lib/firmware/signing.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index 8ad55d11a..48b03e67b 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -1381,6 +1381,13 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { signing_abort(); return false; } + /* aux = NULL means an all-zero aux_rand, i.e. deterministic signing. That + is spec-permitted and matches this firmware's ECDSA, which is RFC6979 + deterministic; the nonce still depends on the private key and the + message, so it is never reused across different transactions. Fresh + randomness would only add side-channel hardening, at the cost of making + signatures unreproducible and so untestable against a published + vector. */ int sign_ret = bip340_sign(curve->params, tweaked, hash, sizeof(hash), /*aux=*/NULL, sig); memzero(tweaked, sizeof(tweaked)); From e513bc8ccb8345b661fd60889ddd4478688a0da6 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 12:42:24 -0300 Subject: [PATCH 09/16] style: clang-format the two taproot call sites --- lib/firmware/signing.c | 3 +-- lib/firmware/transaction.c | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index 48b03e67b..b8dace5bb 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -961,8 +961,7 @@ static bool signing_check_input(TxInputType* txinput) { uint8_t script_pubkey[64]; size_t script_pubkey_len = 0; if (!fill_input_script_pubkey(coin, root, txinput, script_pubkey, - &script_pubkey_len, - sizeof(script_pubkey))) { + &script_pubkey_len, sizeof(script_pubkey))) { fsm_sendFailure(FailureType_Failure_Other, _("Failed to derive input scriptPubKey")); signing_abort(); diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index 4f9bfc49f..db40707c1 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -295,8 +295,7 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, // BIP-341 defines the internal key as x-only, so the odd-y case resolves // to its even-y counterpart here and in the signer alike. if (bip340_tweak_pubkey(curve->params, node->public_key + 1, - /*merkle_root=*/NULL, output_key) != - 0) { + /*merkle_root=*/NULL, output_key) != 0) { return 0; } // Exactly 32 bytes: segwit_addr_encode only length-checks the witness From 41afb48e99cc3cd544a4a9d541aea77ef3aad9e7 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 18:28:05 -0300 Subject: [PATCH 10/16] test(report): fail CI on missing release evidence --- scripts/emulator/python-keepkey-tests.sh | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index e23497084..905496dd7 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -93,19 +93,43 @@ fi # Tests that skip via requires_message/requires_firmware are OK. # Tests that fail or are missing from JUnit = CI failure. echo "=== Phase 2: Full test suite ===" +set +e KK_EXPECT_PERSIST_REJECTED=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml PYTEST_RC=$? +echo "=== Phase 2: Validate report catalog ===" +python3 ../scripts/generate-test-report.py \ + --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --validate-junit +CATALOG_RC=$? + echo "=== Phase 2: Generate test report ===" python3 ../scripts/generate-test-report.py \ --junit=/kkemu/test-reports/python-keepkey/junit.xml \ - ${FW_VERSION:+--fw-version=$FW_VERSION} || true + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --screenshots=/kkemu/test-reports/screenshots \ + --output=/kkemu/test-reports/test-report.pdf +REPORT_RC=$? +set -e -echo "$PYTEST_RC" > /kkemu/test-reports/python-keepkey/status +if [ "$PYTEST_RC" -eq 0 ] && [ "$CATALOG_RC" -eq 0 ] && [ "$REPORT_RC" -eq 0 ]; then + echo "0" > /kkemu/test-reports/python-keepkey/status +else + echo "1" > /kkemu/test-reports/python-keepkey/status +fi if [ "$PYTEST_RC" -ne 0 ]; then echo "pytest failed with exit code $PYTEST_RC" exit "$PYTEST_RC" fi +if [ "$CATALOG_RC" -ne 0 ]; then + echo "report catalog validation failed with exit code $CATALOG_RC" + exit "$CATALOG_RC" +fi +if [ "$REPORT_RC" -ne 0 ]; then + echo "test report generation failed with exit code $REPORT_RC" + exit "$REPORT_RC" +fi From 1091873eba726eb47d9ca7176fdffbf3e81a8295 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 18:55:23 -0300 Subject: [PATCH 11/16] fix(taproot): validate complete BIP-341 input commitments --- lib/firmware/signing.c | 97 ++++++++++++++++++++---------- lib/firmware/transaction.c | 6 ++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/transaction.cpp | 15 +++++ 4 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 unittests/firmware/transaction.cpp diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index b8dace5bb..b05b6df7f 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -26,7 +26,6 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/crypto.h" -#include "keepkey/firmware/crypto.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/policy.h" @@ -102,6 +101,7 @@ static uint8_t hash_prevouts_tr[32], hash_sequences_tr[32], hash_outputs_tr[32]; static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; +static bool has_taproot_input, missing_bip341_input_amount; static uint32_t version = 1; static uint32_t lock_time = 0; static uint32_t expiry = 0; @@ -129,7 +129,7 @@ static uint32_t tx_weight; /* BIP-341 SIGHASH_DEFAULT: commits to all outputs, and unlike SIGHASH_ALL its byte is omitted from the witness entirely. */ -#define SIGHASH_ALL_TAPROOT 0 +#define SIGHASH_DEFAULT_TAPROOT 0 /* transaction header size: 4 byte version */ #define TXSIZE_HEADER 4 @@ -426,10 +426,18 @@ void phase1_request_next_input(void) { // compute segwit hashPrevouts & hashSequence hasher_Final(&hasher_prevouts, hash_prevouts); hasher_Final(&hasher_sequence, hash_sequence); - sha256_Final(&ctx_amounts, hash_amounts); - sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); - sha256_Final(&ctx_prevouts_tr, hash_prevouts_tr); - sha256_Final(&ctx_sequences_tr, hash_sequences_tr); + if (has_taproot_input && missing_bip341_input_amount) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Taproot transaction input without amount")); + signing_abort(); + return; + } + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_amounts, hash_amounts); + sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); + sha256_Final(&ctx_prevouts_tr, hash_prevouts_tr); + sha256_Final(&ctx_sequences_tr, hash_sequences_tr); + } hasher_Final(&hasher_check, hash_check); // init hashOutputs hasher_Reset(&hasher_outputs); @@ -680,6 +688,8 @@ void signing_init(const SignTx* msg, const CoinType* _coin, spending = 0; change_spend = 0; authorized_bip143_in = 0; + has_taproot_input = false; + missing_bip341_input_amount = false; memset(&input, 0, sizeof(TxInputType)); memset(&resp, 0, sizeof(TxRequest)); @@ -724,13 +734,15 @@ void signing_init(const SignTx* msg, const CoinType* _coin, hasher_Init(&hasher_sequence, curve->hasher_sign); hasher_Init(&hasher_outputs, curve->hasher_sign); hasher_Init(&hasher_check, curve->hasher_sign); - /* BIP-341 fixes these two as plain SHA256, independent of the coin's - signing hasher, so they are initialised separately on purpose. */ - sha256_Init(&ctx_amounts); - sha256_Init(&ctx_scriptpubkeys); - sha256_Init(&ctx_prevouts_tr); - sha256_Init(&ctx_sequences_tr); - sha256_Init(&ctx_outputs_tr); + if (coin->has_taproot && coin->taproot) { + /* BIP-341 fixes these as plain SHA256, independent of the coin's + signing hasher, so they are initialised separately on purpose. */ + sha256_Init(&ctx_amounts); + sha256_Init(&ctx_scriptpubkeys); + sha256_Init(&ctx_prevouts_tr); + sha256_Init(&ctx_sequences_tr); + sha256_Init(&ctx_outputs_tr); + } } layoutProgressSwipe(_("Signing transaction"), 0); @@ -750,8 +762,7 @@ static bool is_multisig_input_script_type(const TxInputType* txinput) { static bool is_multisig_output_script_type(const TxOutputType* txoutput) { if (txoutput->script_type == OutputScriptType_PAYTOMULTISIG || txoutput->script_type == OutputScriptType_PAYTOP2SHWITNESS || - txoutput->script_type == OutputScriptType_PAYTOWITNESS || - txoutput->script_type == OutputScriptType_PAYTOTAPROOT) { + txoutput->script_type == OutputScriptType_PAYTOWITNESS) { return true; } return false; @@ -772,7 +783,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { if (txoutput->script_type == OutputScriptType_PAYTOADDRESS || txoutput->script_type == OutputScriptType_PAYTOMULTISIG || txoutput->script_type == OutputScriptType_PAYTOP2SHWITNESS || - txoutput->script_type == OutputScriptType_PAYTOWITNESS) { + txoutput->script_type == OutputScriptType_PAYTOWITNESS || + txoutput->script_type == OutputScriptType_PAYTOTAPROOT) { return true; } return false; @@ -864,6 +876,16 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } + // has_taproot is the nanopb presence flag, not the value. Every coin in + // coins.def sets it, so both fields must be checked. + if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } + if (txoutput->script_type == OutputScriptType_PAYTOOPRETURN) { if (txoutput->has_address || (txoutput->address_n_count > 0) || txoutput->has_multisig) { @@ -873,17 +895,6 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } - // has_taproot is the nanopb presence flag, not the value. Every coin in - // coins.def sets it, so testing it alone let all 41 taproot=false coins - // through and built a p2tr output for them. - if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && - (!coin->has_taproot || !coin->taproot)) { - fsm_sendFailure(FailureType_Failure_Other, - _("Taproot not enabled on this coin.")); - signing_abort(); - return false; - } - if (txoutput->amount != 0) { fsm_sendFailure(FailureType_Failure_Other, _("OP_RETURN output with non-zero amount")); @@ -958,6 +969,8 @@ static bool signing_check_input(TxInputType* txinput) { // hdnode_private_ckd_cached keeps repeated derivations along one account // path cheap. if (coin->has_taproot && coin->taproot) { + has_taproot_input |= txinput->script_type == InputScriptType_SPENDTAPROOT; + missing_bip341_input_amount |= !txinput->has_amount; uint8_t script_pubkey[64]; size_t script_pubkey_len = 0; if (!fill_input_script_pubkey(coin, root, txinput, script_pubkey, @@ -1085,8 +1098,8 @@ static bool signing_check_output(TxOutputType* txoutput) { // compute segwit hashOuts tx_output_hash(&hasher_outputs, &bin_output, coin->decred); /* BIP-341's sha_outputs: single sha256 over amount || ser_script */ - sha256_Update(&ctx_outputs_tr, (const uint8_t*)&bin_output.amount, 8); - { + if (coin->has_taproot && coin->taproot) { + sha256_Update(&ctx_outputs_tr, (const uint8_t*)&bin_output.amount, 8); uint8_t lenbuf[5]; uint32_t lenlen = ser_length(bin_output.script_pubkey.size, lenbuf); sha256_Update(&ctx_outputs_tr, lenbuf, lenlen); @@ -1152,7 +1165,9 @@ static void phase1_request_next_output(void) { tx_hash_final(&ti, hash_prefix, false); } hasher_Final(&hasher_outputs, hash_outputs); - sha256_Final(&ctx_outputs_tr, hash_outputs_tr); + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_outputs_tr, hash_outputs_tr); + } if (!signing_check_fee()) { return; } @@ -1172,7 +1187,7 @@ static void phase1_request_next_output(void) { /* BIP-341 key-path sighash. The assembly itself lives in bip341_sighash() so the field ordering is unit-testable against the published vectors. */ static void signing_hash_bip341(uint32_t input_index, uint8_t* hash) { - bip341_sighash(SIGHASH_ALL_TAPROOT, version, lock_time, hash_prevouts_tr, + bip341_sighash(SIGHASH_DEFAULT_TAPROOT, version, lock_time, hash_prevouts_tr, hash_amounts, hash_scriptpubkeys, hash_sequences_tr, hash_outputs_tr, input_index, hash); } @@ -1696,6 +1711,26 @@ void signing_txack(TransactionType* tx) { signing_abort(); return; } + /* BIP-341 commits to every input's amount and scriptPubKey. For a + mixed legacy+Taproot transaction the host must provide the legacy + amount, and it must describe the actual prevout rather than an + invented commitment that would produce an invalid signature. */ + if (coin->has_taproot && coin->taproot && input.has_amount) { + uint8_t expected_script[64]; + size_t expected_script_len = 0; + if (input.amount != tx->bin_outputs[0].amount || + !fill_input_script_pubkey(coin, root, &input, expected_script, + &expected_script_len, + sizeof(expected_script)) || + expected_script_len != tx->bin_outputs[0].script_pubkey.size || + memcmp(expected_script, tx->bin_outputs[0].script_pubkey.bytes, + expected_script_len) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Input amount or script does not match prevout")); + signing_abort(); + return; + } + } to_spend += tx->bin_outputs[0].amount; } if (idx2 < tp.outputs_len - 1) { diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index db40707c1..8022ae603 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -400,6 +400,9 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, case OutputScriptType_PAYTOP2SHWITNESS: input_script_type = InputScriptType_SPENDP2SHWITNESS; break; + case OutputScriptType_PAYTOTAPROOT: + input_script_type = InputScriptType_SPENDTAPROOT; + break; default: return 0; // failed to compile output } @@ -1084,6 +1087,9 @@ uint32_t tx_input_weight(const CoinType* coin, const TxInputType* txinput) { weight += 4; // empty input script } weight += input_script_size; // discounted witness + } else if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + weight += 4; // empty scriptSig length in the non-witness serialization + weight += 2 + TXSIZE_SCHNORR_SIGNATURE; // stack count, item length, sig } return weight; } diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 667911741..f9fdf7064 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -2,6 +2,7 @@ set(sources app_confirm.cpp recovery.cpp storage.cpp + transaction.cpp usb_rx.cpp u2f.cpp) diff --git a/unittests/firmware/transaction.cpp b/unittests/firmware/transaction.cpp new file mode 100644 index 000000000..1946e3694 --- /dev/null +++ b/unittests/firmware/transaction.cpp @@ -0,0 +1,15 @@ +extern "C" { +#include "keepkey/firmware/transaction.h" +} + +#include "gtest/gtest.h" + +TEST(Transaction, TaprootInputWeightIncludesWitness) { + CoinType coin = CoinType_init_zero; + TxInputType input = TxInputType_init_zero; + input.script_type = InputScriptType_SPENDTAPROOT; + + // 41 non-witness bytes * 4 plus a one-item witness containing the fixed + // 64-byte SIGHASH_DEFAULT Schnorr signature. + ASSERT_EQ(230U, tx_input_weight(&coin, &input)); +} From 74bbe50b194cf68c9ebdf080dd496339d858eb9b Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 18:57:17 -0300 Subject: [PATCH 12/16] chore(deps): pin canonical Taproot staging merges --- deps/crypto/trezor-firmware | 2 +- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- include/keepkey/transport/messages-solana.options | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index ebbd21ece..9c882485e 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit ebbd21ececde59341d3ad265f08ff6534b7f8d14 +Subproject commit 9c882485e6bed1dae7538ec4d43a4f9a12c428aa diff --git a/deps/device-protocol b/deps/device-protocol index 171eaa098..674777f6d 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 171eaa09804c019e4bd9dcb649c68956f850255a +Subproject commit 674777f6d4dd16e2b8c4c2df10608976375ee879 diff --git a/deps/python-keepkey b/deps/python-keepkey index dd0a59dfa..9c58e6771 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit dd0a59dfa31e65ed8bb1170700b43669c8e2f215 +Subproject commit 9c58e677135e24e59b5d867348073d47dae61785 diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index 4b08e771f..0886fb61b 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -13,6 +13,8 @@ SolanaSignTx.raw_tx max_size:2048 SolanaSignTx.token_info max_count:4 SolanaSignTx.schema_payload max_size:256 SolanaSignTx.schema_signature max_size:64 +SolanaSignTx.token_recipient_owner max_count:4 +SolanaSignTx.token_recipient_owner max_size:32 SolanaSignedTx.signature max_size:64 From 86614372103456ba1ecf2c307a2b45b9cd2d3fa1 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 1 Aug 2026 19:17:47 -0300 Subject: [PATCH 13/16] fix(layout): show the whole address, not the first 42 characters Gate-3 OLED capture found the address verification screen silently truncating any bech32 address longer than one line. displayed bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20 actual bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr 20 characters dropped, with no indication anything was missing -- and the QR beside it encodes the full address, so the two disagreed. A user "verifying" a receive address was checking two thirds of it. NOT a taproot bug. The threshold is ~42 characters at body font, so it already affects native segwit MULTISIG (p2wsh, 62 chars) on shipping firmware; p2wpkh is 42 and fits exactly, which is why it went unnoticed. Confirmed on the emulator for both p2wsh and p2tr. Cause: the address is drawn at TOP_MARGIN_FOR_ONE_LINE + font_height + ADDRESS_TOP_MARGIN = y=46 with line height 14 on a 64px canvas, so a second line starts at 60 and draw_char_with_shift() refuses to draw it -- it checks img->height + p->y <= canvas->height and returns false, and draw_string() then stops without reporting anything. Fix, in order of preference so the smallest lever is used first: - close the inter-line padding for multi-line addresses (14 -> 10) - only if that is still short, raise the block by exactly the overflow Raising alone was tried first and is wrong: the QR is drawn after the text and overwrites the start of a raised first line, which cost the first six characters. Closing the padding alone is also not enough -- the font is 10px, so two lines from y=46 need 66. Together they land the second line at y=54..64, clear of the QR at ~41. Single-line addresses are untouched: the whole branch is gated on calc_str_line() > ONE_LINE. Verified on the emulator: both 62-char addresses now render in full and legibly, the p2pkh confirm screen is unchanged, and the existing test_msg_getaddress_segwit suite still passes. Both device variants build clean with no warnings. --- lib/firmware/app_layout.c | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/firmware/app_layout.c b/lib/firmware/app_layout.c index 2499916be..c0f381788 100644 --- a/lib/firmware/app_layout.c +++ b/lib/firmware/app_layout.c @@ -703,8 +703,35 @@ void layout_address_notification(const char* desc, const char* address, sp.y += font_height(address_font) + ADDRESS_TOP_MARGIN; sp.x = LEFT_MARGIN; sp.color = BODY_COLOR; + + /* Bech32 addresses longer than one line (p2wsh and p2tr are both 62 chars) + did not fit: draw_string() stops at the bottom of the canvas and drops the + remainder SILENTLY, so the user verified a prefix while the QR beside it + encoded the whole address. + Close the padding between lines rather than moving the block up -- the QR + is drawn last and would overwrite the start of a raised first line. */ + uint16_t address_line_height = + font_height(address_font) + BODY_FONT_LINE_PADDING; + { + const uint32_t lines = + calc_str_line(address_font, address, TRANSACTION_WIDTH); + if (lines > ONE_LINE) { + /* Close the inter-line padding first: raising the block is what collides + with the QR, which is drawn afterwards and would overwrite the start of + the first line. */ + address_line_height = font_height(address_font); + const uint16_t bottom = sp.y + (lines - 1) * address_line_height + + font_height(address_font); + if (bottom > KEEPKEY_DISPLAY_HEIGHT) { + /* Still short: raise by the minimum that fits, no more. */ + const uint16_t overflow = bottom - KEEPKEY_DISPLAY_HEIGHT; + sp.y = (sp.y > overflow) ? sp.y - overflow : 0; + } + } + } + draw_string(canvas, address_font, address, &sp, TRANSACTION_WIDTH, - font_height(address_font) + BODY_FONT_LINE_PADDING); + address_line_height); /* Draw description */ if (strcmp(desc, "") != 0) { From aae007126120125784ca620786012dff99e482e8 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 19:31:36 -0300 Subject: [PATCH 14/16] style(layout): apply canonical clang-format --- lib/firmware/app_layout.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/firmware/app_layout.c b/lib/firmware/app_layout.c index c0f381788..82bff62b3 100644 --- a/lib/firmware/app_layout.c +++ b/lib/firmware/app_layout.c @@ -720,8 +720,8 @@ void layout_address_notification(const char* desc, const char* address, with the QR, which is drawn afterwards and would overwrite the start of the first line. */ address_line_height = font_height(address_font); - const uint16_t bottom = sp.y + (lines - 1) * address_line_height + - font_height(address_font); + const uint16_t bottom = + sp.y + (lines - 1) * address_line_height + font_height(address_font); if (bottom > KEEPKEY_DISPLAY_HEIGHT) { /* Still short: raise by the minimum that fits, no more. */ const uint16_t overflow = bottom - KEEPKEY_DISPLAY_HEIGHT; From 750e692e8c6ada172f64cb58e3636a69bb6af0d6 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 21:11:53 -0300 Subject: [PATCH 15/16] fix(ci): label dylib with tested head SHA --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95f04a30c..97e396b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -777,7 +777,7 @@ jobs: if: always() && env.DYLIB_PATH != '' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: libkkemu-${{ github.sha }} + name: libkkemu-${{ github.event.pull_request.head.sha || github.sha }} path: ${{ env.DYLIB_PATH }} retention-days: 30 if-no-files-found: error From 441246e043d77ab8e69bbf82f0890e2451c1e40d Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 3 Aug 2026 18:16:39 -0300 Subject: [PATCH 16/16] fix(signing): fail closed before Taproot signature --- deps/python-keepkey | 2 +- lib/firmware/signing.c | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 9c58e6771..84b4a2503 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 9c58e677135e24e59b5d867348073d47dae61785 +Subproject commit 84b4a25030763bbe8f84175b12e7a8a245bbb6d2 diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index b05b6df7f..d0b11c5fb 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -102,6 +102,13 @@ static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; static bool has_taproot_input, missing_bip341_input_amount; +/* + * Taproot signatures must never be reachable before phase 1 has completed + * the physical transaction-summary confirmation. Keep this as independent + * state instead of inferring it from signing_stage so a malformed or + * corrupted stage transition fails closed at the Schnorr signing boundary. + */ +static bool taproot_transaction_confirmed; static uint32_t version = 1; static uint32_t lock_time = 0; static uint32_t expiry = 0; @@ -690,6 +697,7 @@ void signing_init(const SignTx* msg, const CoinType* _coin, authorized_bip143_in = 0; has_taproot_input = false; missing_bip341_input_amount = false; + taproot_transaction_confirmed = false; memset(&input, 0, sizeof(TxInputType)); memset(&resp, 0, sizeof(TxRequest)); @@ -1142,6 +1150,9 @@ static bool signing_check_fee(void) { signing_abort(); return false; } + if (has_taproot_input) { + taproot_transaction_confirmed = true; + } return true; } @@ -1366,6 +1377,12 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { // idx1: index to sign if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + if (!taproot_transaction_confirmed) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot transaction was not confirmed")); + signing_abort(); + return false; + } /* No scriptSig to build, but the same re-validation and derivation the other input types get. */ if (!prepare_input_node(txinput)) { @@ -2098,4 +2115,5 @@ void signing_abort(void) { } memzero(&root, sizeof(root)); memzero(&node, sizeof(node)); + taproot_transaction_confirmed = false; }