Compare commits

...

45 Commits

Author SHA1 Message Date
scgbckbone
542dcd32c7 revert SSSP bypass PIN login 2026-06-25 11:18:33 -04:00
scgbckbone
0ef6413cd8 apply note or pwd as b39 passphrase 2026-06-24 14:07:25 -04:00
Dmitry Monakhov
97d86c9571 Fix BBQr share of Unicode text: encode str to UTF-8 before sizing/splitting
For 'U'/'J' payloads data is a str; planning counted codepoints while
b32encode consumes UTF-8 bytes, so multi-byte text (e.g. paper-wallet QR
art) overflowed target_vers and tripped the assert in show_bbqr_codes.
2026-06-24 13:29:58 -04:00
scgbckbone
edae8c1ee6 Add groups for secure notes 2026-06-24 13:28:43 -04:00
scgbckbone
553405776f Keep scanner reinit state instance-local 2026-06-24 11:12:35 -04:00
scgbckbone
ad2088d231 Fix QR scanner setup and sleep handling 2026-06-24 11:12:35 -04:00
scgbckbone
67a5c6c270 fix bypass_tmp return to master secret with xprv type 2026-06-24 08:23:47 -04:00
scgbckbone
eb112eb3a1 fix tests 2026-06-24 08:22:54 -04:00
scgbckbone
0d04e5e1f8 bugfix: p2pk 2026-06-23 11:43:39 -04:00
scgbckbone
59eb529a20 Reject witness-only UTXO for legacy inputs; Suppress fee for unverified witness UTXOs;normalize legacy inputs to proper utxo 2026-06-23 11:25:53 -04:00
scgbckbone
d5aba396a6 improve USB validation 2026-06-23 10:55:08 -04:00
scgbckbone
6fd256dbdc bugfix: 1of1 multisig 2026-06-23 10:34:44 -04:00
scgbckbone
6716fcbacb keep NFC export tag live for repeated probes 2026-06-23 10:29:26 -04:00
scgbckbone
1dddd88525 WIF Store upgrade 2026-06-22 12:46:50 -04:00
scgbckbone
d656f371c7 BIP-322 changes after BIP got in to the complete state 2026-06-22 11:20:44 -04:00
scgbckbone
7e92e5162a Restore borrowed secret handling in SensitiveValues 2026-06-22 11:16:20 -04:00
scgbckbone
74d34cfcf7 stabilize tests 2026-06-22 10:39:46 -04:00
scgbckbone
64658621bb simulator:attribute catchup with real SCAN 2026-06-22 10:39:30 -04:00
scgbckbone
755353f029 docs: NFC antenna by HW 2026-06-22 09:28:53 -04:00
scgbckbone
2981d15933 build: automatic block height update 2026-06-22 09:28:30 -04:00
scgbckbone
9ff3f5c447 slight menu optimization for long menus 2026-06-19 12:51:07 -04:00
scgbckbone
f5a1ef32c9 test nits 2026-06-19 12:50:54 -04:00
scgbckbone
841e44335e testing: block_h bumped for SSSP too, when CCC overrides SSSP block 2026-06-19 12:50:26 -04:00
scgbckbone
38616234e7 testing: cope with bitcoin core v30 2026-06-19 12:50:10 -04:00
scgbckbone
8e3bbfdf84 docs: index all docs and fix drift vs firmware 2026-06-19 12:48:49 -04:00
Peter D. Gray
f9b65ce968
credit 2026-06-19 11:00:37 -04:00
Dmitry Monakhov
8d71040acf Don't restore cached backup password (bkpw) from backup file
Restore mirrored the write-side strip of bkpw: a crafted backup could inject
setting.bkpw and fixate the password used for future backups. Drop it on restore
2026-06-19 10:59:16 -04:00
dependabot[bot]
5feae87e03 Bump requests from 2.32.4 to 2.33.0 in /testing
Bumps [requests](https://github.com/psf/requests) from 2.32.4 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 10:58:24 -04:00
scgbckbone
0949c0ac86 README.md update build repro steps (were misordered before) 2026-06-19 10:57:08 -04:00
scgbckbone
c36eac23d2 bundle small fixes 2026-06-19 10:56:45 -04:00
scgbckbone
a24a894cfd fix typo in nfc-pushtx.md 2026-05-16 10:59:23 -04:00
scgbckbone
ca06dfd250 unreleased regression introduced in 300323f18d 2026-04-25 10:36:15 -04:00
scgbckbone
3a1ef6fe50 bugfix: NFC verify address wrong error message 2026-04-20 15:21:43 -04:00
scgbckbone
883be60fc5 bugfix: attribute error on exception object + more 7z header tests 2026-04-20 15:19:31 -04:00
scgbckbone
393ebf5b43 bugfix: default menu position in custom path address format menu 2026-04-20 15:18:30 -04:00
scgbckbone
2b5178bd63 bugfix: "Send Password" menu item visibility reversed, do not store password as None, UX fixes 2026-04-20 15:15:19 -04:00
scgbckbone
44e7be3681 fix: correct container type for settings.wifs; proper button text UX with parentheses 2026-04-20 12:40:40 -04:00
scgbckbone
300323f18d final solution can_cancel=True 2026-04-20 12:40:07 -04:00
scgbckbone
c998432fc4 bugfix: exiting nickname entry with nickname already saved deleted previous nickname; fixed settings_get with prelogin arg 2026-04-20 12:37:54 -04:00
scgbckbone
9c6cfcbbd7 bugfix: enable disabled 7z magic check in check_file_headers 2026-04-20 11:28:28 -04:00
scgbckbone
be614dab92 bugfix: Delta Mode Trick PIN restore from backup 2026-04-20 11:17:52 -04:00
scgbckbone
02bd428786 do not repeat HSM_DISABLE_CMDS in HOBBLED_CMDS 2026-04-20 11:17:17 -04:00
scgbckbone
6869ba87b0 typos 2026-04-20 11:17:17 -04:00
scgbckbone
d0f834570b testing: fix bitcoind param list 2026-04-20 11:15:02 -04:00
scgbckbone
00afe533ca better fitting UX message for MK versions 2026-04-20 11:14:36 -04:00
102 changed files with 6192 additions and 1568 deletions

View File

@ -28,9 +28,11 @@ has been automated using Docker. Steps are as follows:
```shell
git clone https://github.com/Coldcard/firmware.git
git checkout 2023-12-21T1526-v5.2.2
# get a copy of that binary into ./releases/2023-12-21T1526-v5.2.2-mk4-coldcard.dfu
cd firmware/stm32
cd firmware
# DOWNLOAD https://coldcard.com/downloads
# get a copy of binary into ./releases/2026-03-05T2052-v5.5.0-mk-coldcard.dfu
git checkout 2026-03-05T2052-v5.5.0
cd stm32
make -f MK4-Makefile repro
```

View File

@ -3,14 +3,32 @@
These docs are meant for you hackers out there... but also for anyone who
wants to understand why it's safe to put your moneys into Coldcard.
- [`security-model.md`](security-model.md) The COLDCARD Mk4/Mk5/Q security model.
- [`pin-entry.md`](pin-entry.md) Huge and detailed discussion of PIN codes and the security element that holds the secrets.
- [`secure-elements.md`](secure-elements.md) How the dual secure elements work together.
- [`dev-access.md`](dev-access.md) How developers can modify Coldcard to extend it.
- [`memory-map.md`](memory-map.md) Memory map highlights
- [`notes-on-repro.md`](notes-on-repro.md) Detailed breakdown of the reproducible build process.
- [`upgrade-recovery.md`](upgrade-recovery.md) Firmware upgrade and recovery process.
- [`backup-files.md`](backup-files.md) Some details of our encrypted backup files.
- [`temporary-seeds.md`](temporary-seeds.md) Temporary (ephemeral) seeds and the Seed Vault.
- [`seed-xor.md`](seed-xor.md) More about _Seed XOR_ feature, including fully worked Seed XOR example, and useful XOR lookup chart.
- [`key-teleport.md`](key-teleport.md) Key Teleport: encrypted transfer of seeds and secrets between Q devices.
- [`spending-policy.md`](spending-policy.md) Spending policy: autonomous signing with configurable limits.
- [`microsd-2fa.md`](microsd-2fa.md) Using a MicroSD card as a second factor for login.
- [`web2fa.md`](web2fa.md) Web 2FA authentication.
- [`bip85-passwords.md`](bip85-passwords.md) Deriving deterministic passwords via BIP-85.
- [`msg-signing.md`](msg-signing.md) COLDCARD message signing.
- [`proof-of-reserves-bip-322.md`](proof-of-reserves-bip-322.md) BIP-322 generic signed message format and proof of reserves.
- [`generic-wallet-export.md`](generic-wallet-export.md) Generic JSON wallet export file format.
- [`bip-21-extensions.md`](bip-21-extensions.md) Coldcard's BIP-21 URI extensions, including multisig ownership address check.
- [`nfc-coldcard.md`](nfc-coldcard.md) NFC support on Coldcard Mk4 and Q.
- [`nfc-pushtx.md`](nfc-pushtx.md) NFC Push Transaction: broadcast a signed transaction via your phone.
- [`usb-batteries.md`](usb-batteries.md) Using USB battery packs with Coldcard.
- [`electrum-usage.md`](electrum-usage.md) Importing seed words into Electrum for funds usage (and other tips).
- [`bitcoin-core-usage.md`](bitcoin-core-usage.md) How to use with Bitcoin Core.
- [`bitcoin-core2of2desc.md`](bitcoin-core2of2desc.md) Airgapped 2-of-2 multisig with Bitcoin Core using descriptors.
- [`limitations.md`](limitations.md) Documented limitations, policy choices, and TODO items.
- [`paperwallet.pdf`](paperwallet.pdf) Example paper wallet template file.
- [`seed-xor.md`](seed-xor.md) More about _Seed XOR_ feature, including fully worked Seed XOR example, and useful XOR lookup chart.
- [`menu-tree.txt`](menu-tree.txt) Dump of the menu system. Incomplete, may be out of date.

View File

@ -5,13 +5,13 @@ according to [BIP-85 PWD BASE64](https://github.com/bitcoin/bips/blob/master/bip
Generated passwords can be sent as keystrokes via USB to the host computer,
effectively using Coldcard as specialized password manager.
In addition to deriving up to 10,000 distinct secure passwords, the Coldcard Mk4
In addition to deriving up to 10,000 distinct secure passwords, the Coldcard
can also type them into a computer by emulating a USB keyboard, and simulating the
keystrokes needed to type the password.
#### Requirements
* Coldcard Mk4 with version 5.0.5 or newer
* Coldcard Mk4 or Mk5 (firmware 5.0.5 or newer), or any Q
* USB-C with data link (won't work with power only cable from Coinkite)
## Type Passwords over USB
@ -32,11 +32,13 @@ to exit. Exiting from "Type Passwords" will cause Coldcard to turn off keyboard
1. Go to Advanced/Tools -> Derive Seed B85 -> Passwords
2. Choose "Password/Index number" (BIP-85 index) and press OK to generate password.
3. Screen shows generated password, path, and entropy from which password was derived
4. A few different options are available at this point:
1. press 1 to save password backup file on MicroSD card (cleartext!)
2. press 2 to send keystrokes (this will first of all enable keyboard emulation, then send keystrokes + enter, and finally disables keyboard emulation)
3. press 3 to view password as QR code
4. press 4 to send over NFC (only appears when NFC is enabled)
4. A few different options are available at this point (on Mk; on Q the NFC and
QR buttons are used instead of (3)/(4)):
1. press (1) to save password backup file on MicroSD card (cleartext!)
2. press (2) to save to Virtual Disk (only when available)
3. press (3) to send over NFC (only appears when NFC is enabled)
4. press (4) to view password as QR code
5. press (6) to send keystrokes over USB (this enables keyboard emulation, sends keystrokes + enter, then disables keyboard emulation)
## Keyboard language settings

View File

@ -5,9 +5,12 @@ wallet systems, but we also have a file format for general purpose
exports, which we hope future wallet makers will leverage.
It contains master XPUB, XFP for that, and derived values for the top hardened
position of BIP44, BIP84 and BIP49.
position of the single-signature schemes BIP44, BIP49 and BIP84, plus the
multisig schemes BIP48 (`bip48_1` = `.../1h` P2SH-P2WSH and `bip48_2` = `.../2h` P2WSH).
When the account number is zero, a BIP45 (`m/45h`) multisig section is also included
(it is omitted for non-zero accounts, as in the example below).
The feature can be found here: _Advanced > MicroSD > Export Wallet > Generic JSON_
The feature can be found here: _Advanced/Tools > Export Wallet > Generic JSON_
Please contact us (or better yet, make a pull request), if you need something
more in this file.
@ -18,32 +21,51 @@ Here is an example, produced by the Simulator for account number 123.
```javascript
{
"chain": "XTN",
"chain": "BTC",
"xfp": "0F056943",
"xpub": "tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh",
"account": 123,
"xpub": "xpub661MyMwAqRbcGC9DmWbtbAmuUjpMYxw4BWE88NSDHB3jSjfUK7KtYJuKa52GbowD3DVLkgsxH9QwPnTx5mjdHykYFEncnmAsNsCTbWzBhA7",
"bip44": {
"deriv": "m/44'/1'/123'",
"first": "n44vs1Rv7T8SANrg2PFGQhzVkhr5Q6jMMD",
"name": "p2pkh",
"xfp": "B7908B26",
"xpub": "tpubDCiHGUNYdRRGoSH22j8YnruUKgguCK1CC2NFQUf9PApeZh8ewAJJWGMUrhggDNK73iCTanWXv1RN5FYemUH8UrVUBjqDb8WF2VoKmDh9UTo"
"xfp": "5F898064",
"deriv": "m/44h/0h/123h",
"xpub": "xpub6DStQXfAgHuLbMpCf86ruVkF4yT9pSLyWsFiqQTWY9osuinq8Dyee4W5jCjMfyku5LNkRB9oFinrY5ufn9XXEn8Vvzc2jnifKMaQCNV7RBZ",
"desc": "pkh([0f056943/44h/0h/123h]xpub6DStQXfAgHuLbMpCf86ruVkF4yT9pSLyWsFiqQTWY9osuinq8Dyee4W5jCjMfyku5LNkRB9oFinrY5ufn9XXEn8Vvzc2jnifKMaQCNV7RBZ/<0;1>/*)#4tl8jryn",
"first": "1GTNtzG5xX2UhdD5e3Nu7i1WPxFdjxQMJt"
},
"bip49": {
"_pub": "upub5DMRSsh6mNak9KbcVjJ7xAgHJvbE3Nx22CBTier5C35kv8j7g2q58ywxskBe6JCcAE2VH86CE2aL4MifJyKbRw8Gj9ay7SWvUBkp2DJ7y52",
"deriv": "m/49'/1'/123'",
"first": "2N87V39riUUCd4vmXfDjMWAu9gUCiBji5jB",
"name": "p2wpkh-p2sh",
"xfp": "CEE1D809",
"xpub": "tpubDCDqt7XXvhAdy1MpSze5nMJA9x8DrdRaKALRRPasfxyHpiqWWEAr9cbDBQ9BcX7cB3up98Pk97U2QQ3xrvQsi5dNPmRYYhdcsKY9wwEY87T"
"name": "p2sh-p2wpkh",
"xfp": "A748B1FC",
"deriv": "m/49h/0h/123h",
"xpub": "xpub6DDm8WzH5a9qjKkttzqSB3uGofNohU9D3n3UG8WMxkUZzJEMPTYiQRf1dvTFCQR82MjGW4LUMVuTtnW4hF17RpzCqVwhf6Z2fnJPWtjG164",
"desc": "sh(wpkh([0f056943/49h/0h/123h]xpub6DDm8WzH5a9qjKkttzqSB3uGofNohU9D3n3UG8WMxkUZzJEMPTYiQRf1dvTFCQR82MjGW4LUMVuTtnW4hF17RpzCqVwhf6Z2fnJPWtjG164/<0;1>/*))#5j7t2n2u",
"_pub": "ypub6Y42SBfCEFhKacx1jMd4P8zmydXFe68hxtZh3XQFLkrT3Q3ae7iH2VK9f8QqCK53Rzr5FXw2pAG1n57dQwR8E4fohqe8F1NWwWN2uVRfBry",
"first": "3CeBRbJKCpg7BpJME2vM8ZxhCjBnhG4toy"
},
"bip84": {
"_pub": "vpub5Y5a91QvDT45EnXQaKeuvJupVvX8f9BiywDcadSTtaeJ1VgJPPXMitnYsqd9k7GnEqh44FKJ5McJfu6KrihFXhAmvSWgm7BAVVK8Gupu4fL",
"deriv": "m/84'/1'/123'",
"first": "tb1qc58ys2dphtphg6yuugdf3d0kufmk0tye044g3l",
"name": "p2wpkh",
"xfp": "78CF94E5",
"xpub": "tpubDC7jGaaSE66VDB6VhEDFYQSCAyugXmfnMnrMVyHNzW9wryyTxvha7TmfAHd7GRXrr2TaAn2HXn9T8ep4gyNX1bzGiieqcTUNcu2poyntrET"
"xfp": "2C5207AA",
"deriv": "m/84h/0h/123h",
"xpub": "xpub6CaWStGvcXqSW9BzU2vpCoP7aWjz9VfR5DS2nuYWVvKV2nug2dESg3HdFsaWHeoZaxuAhNcPB3TH2gq8MugS3JX1yGuhB4QbC2BneaYqB16",
"desc": "wpkh([0f056943/84h/0h/123h]xpub6CaWStGvcXqSW9BzU2vpCoP7aWjz9VfR5DS2nuYWVvKV2nug2dESg3HdFsaWHeoZaxuAhNcPB3TH2gq8MugS3JX1yGuhB4QbC2BneaYqB16/<0;1>/*)#yk84tprf",
"_pub": "zpub6rF34DckutvQCjaE8kW4cya7vT2t2jeQuSUUMhLHFw5F8zY8XwZZvAbuJHVgHU7QQF8nCKoW6NANoG4FoJWTdmtDhxJYLt3ZjUK5RqUSMdF",
"first": "bc1qhj6avwmp5lhpgqwm6dgxrf3v5lf67rjm99a8an"
},
"bip48_1": {
"name": "p2sh-p2wsh",
"xfp": "845A3542",
"deriv": "m/48h/0h/123h/1h",
"xpub": "xpub6EkcQSTygvxVnBP2X2fM6HY5D7wv46tWbBc54ADaypuCr47vQh1GPdPAZFdx81ou5Rp4vBnzeJT5MDWDZstzijxkHfrofXRycpt1ASfg1La",
"desc": "sh(wsh(sortedmulti(M,[0f056943/48h/0h/123h/1h]xpub6EkcQSTygvxVnBP2X2fM6HY5D7wv46tWbBc54ADaypuCr47vQh1GPdPAZFdx81ou5Rp4vBnzeJT5MDWDZstzijxkHfrofXRycpt1ASfg1La/0/*,...)))",
"_pub": "Ypub6kUxqLsLQa4M43jXJ3ux8SyP6t8dD5ZbpZmxkpP1jc7VXLW4RkZ76ouEPAZ1gMgiiXzrYFPfzBC8MfjYaoTxfTm1zUfdeqiTnHDX8raCfeg"
},
"bip48_2": {
"name": "p2wsh",
"xfp": "2A01C6B0",
"deriv": "m/48h/0h/123h/2h",
"xpub": "xpub6EkcQSTygvxVneXmk3ywiS2PFhBdiPxeMxYf6RFxHCHH36NxdcN7DjUpudCppAAxs58CG6DQLjtqZNmyC3MpgVob6wpdeATjpZZ1woX92EF",
"desc": "wsh(sortedmulti(M,[0f056943/48h/0h/123h/2h]xpub6EkcQSTygvxVneXmk3ywiS2PFhBdiPxeMxYf6RFxHCHH36NxdcN7DjUpudCppAAxs58CG6DQLjtqZNmyC3MpgVob6wpdeATjpZZ1woX92EF/0/*,...))",
"_pub": "Zpub75KE91YFZFbpup5PMS2AxgZCKRWnozdEWTEmaUKGQysSmUaKuL5WYyf2kk5UNQhhupRnddQe9GzST7crvfLoRTHTg6KtDPZiFjxBJobzcUz"
}
}
```
@ -51,7 +73,14 @@ Here is an example, produced by the Simulator for account number 123.
## Notes
1. The `first` address is formed by added `/0/0` onto the given derivation, and is assumed
to be the first (non-change) receive address for the wallet.
to be the first (non-change) receive address for the wallet. It is only present on the
single-signature sections (`bip44`, `bip49`, `bip84`); multisig sections omit it.
1a. Each section includes a `desc` field: a ready-to-import Bitcoin output descriptor
(with `#checksum`). Single-sig descriptors use the `<0;1>/*` multipath form. Multisig
sections (`bip48_1`, `bip48_2`, and `bip45` when present) emit a `sortedmulti(...)`
template with `M` and a trailing `...` as placeholders, to be completed with your
threshold and the other co-signers' keys.
2. The user may specify any value (up to 9999) for the account number, and it's meant to
segregate funds into sub-wallets. Don't assume it's zero.
@ -59,8 +88,8 @@ segregate funds into sub-wallets. Don't assume it's zero.
3. When making your PSBT files to spend these amounts, remember that the XFP of the master
(`0F056943` in this example) is the root of the subkey paths found in the file, and
you must include the full derivation path from master. So based on this example,
to spend a UTXO on `tb1qc58ys2dphtphg6yuugdf3d0kufmk0tye044g3l`, the input section
of your PSBT would need to specify `(m=0F056943)/84'/1'/123'/0/0`.
to spend a UTXO on `bc1qhj6avwmp5lhpgqwm6dgxrf3v5lf67rjm99a8an`, the input section
of your PSBT would need to specify `(m=0F056943)/84'/0'/123'/0/0`.
4. The `_pub` value is the [SLIP-132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) style "ypub/zpub/etc" which some systems might want. It implies
a specific address format.

View File

@ -38,7 +38,7 @@ directly from python programs.
| Start | Size | Notes
|---------------|-----------|--------------------------
| 0x0800 0000 | 128k | Bootloader code, including reset vector. See `stm32/mk4-bootloader`
| 0x0800 0000 | 112k | Bootloader code, including reset vector. See `stm32/mk4-bootloader`
| 0x0801 c000 | 8k | Sensitive "pairing secrets" for SE1 and SE2
| 0x0801 e000 | 8k | MCU keys, consumable; 256 32-bit write-once slots.
| 0x0802 0000 | 16k | Interrupt handlers, file header (Micropython and Coldcard code)

View File

@ -247,13 +247,14 @@
Bitcoin Core
Nunchuk
Bull Bitcoin
Zeus
Blue Wallet
Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya
Bitcoin Safe
Zeus
Samourai Postmix
Samourai Premix
Descriptor
@ -279,13 +280,14 @@
Bitcoin Core
Nunchuk
Bull Bitcoin
Zeus
Blue Wallet
Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya
Bitcoin Safe
Zeus
Samourai Postmix
Samourai Premix
Descriptor
@ -360,6 +362,7 @@
Enable
User Management [MAYBE]
Paper Wallets
WIF Store
NFC Tools [IF NFC ENABLED]
Sign PSBT
Show Address
@ -630,13 +633,14 @@
Bitcoin Core
Nunchuk
Bull Bitcoin
Zeus
Blue Wallet
Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya
Bitcoin Safe
Zeus
Samourai Postmix
Samourai Premix
Descriptor
@ -661,13 +665,14 @@
Bitcoin Core
Nunchuk
Bull Bitcoin
Zeus
Blue Wallet
Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya
Bitcoin Safe
Zeus
Samourai Postmix
Samourai Premix
Descriptor
@ -694,6 +699,7 @@
Coldcard Backup
Restore Seed XOR
Paper Wallets
WIF Store
NFC Tools [IF NFC ENABLED]
Sign PSBT
Show Address

View File

@ -2,15 +2,16 @@
COLDCARD can sign messages send to it via USB with the help of `ckcc` utility,
sign messages provided via specially crafted file on SD card or Vdisk,
and Mk4 can also sign messages sent to COLDCARD via NFC.
and NFC-equipped models (Mk4, Mk5, and Q) can also sign messages sent to COLDCARD via NFC.
The resulting signature can be returned over SD card/Vdisk, NFC, or — on Q — as a QR code.
Signature format follows [BIP-0137](https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki) specification.
COLDCARD Mk3 and COLDCARD Mk4 up to version `5.1.0` used compressed P2PKH header byte for all script types.
From Mk4 `5.1.0` correct header byte is used for corresponding script type.
From version `5.1.0` correct header byte is used for corresponding script type.
### Verification
From COLDCARD Mk4 version `5.1.0` users can verify signed messages directly on the device.
From version `5.1.0` users can verify signed messages directly on the device.
If signature file is on SD card or Virtual disk `Advanced/Tools -> File Management -> Verify Sig File`. In case
signature file is detached signature of signed export (or any other file), COLDCARD can check if digest of file
specified in the message matches contents of file. This requires file signed to be available on SD card or Vdisk.
@ -21,7 +22,7 @@ Bitcoin core can only verify P2PKH.
## Signed Exports
From Mk4 version `5.1.0` most of SD card and Virtual disk exports are accompanied by detached signature file.
From version `5.1.0` most of SD card and Virtual disk exports are accompanied by detached signature file.
If exported file name is `addresses.csv` signature file name will be `addresses.sig`.
### Message construction and signature file format
@ -39,8 +40,6 @@ IFOvGVJrm31S0j+F4dVfQ5kbRKWKcmhmXIn/Lw8iIgaCG5QNZswjrN4X673R7jTZo1kvLmiD4hlIrbuL
-----END BITCOIN SIGNATURE-----
```
### What is signed
### What Is Signed
1. **Single sig address explorer exports:** Signed by the key corresponding to the first (0th) address on the exported list.
@ -60,14 +59,4 @@ IFOvGVJrm31S0j+F4dVfQ5kbRKWKcmhmXIn/Lw8iIgaCG5QNZswjrN4X673R7jTZo1kvLmiD4hlIrbuL
6. **Multisig exports:** public keys are encoded as P2PKH address for all multisg signature exports
* Multisig wallet descriptor: signed by the key corresponding to the first external address of own enrolled extended key `my_key/0/0`
* Generic XPUBs export: signed by the key corresponding to the first external address of own standard P2WSH derivation `m/48h/<coin_type>h/<account>h/2h/0/0`
* Multisig address explorer export: Signed by own key at the same derivation as first (0th) row on exported list. `my_key/<change>/<start_index>`
### What is NOT signed
Multisig exports and generic multisig xpub exports are not signed. It is not clear at this point
whether to sign these exports with some generic single signature key (i.e. `m/44'/<coin_type>'/0'/0/0`)
or with our portion (leg) of script. In both cases script type (address format) would not match as multisignature
message signing is not standardized.
1. **Multisig exports**
2. **Generic multisig exports**
* Multisig address explorer export: Signed by own key at the same derivation as first (0th) row on exported list. `my_key/<change>/<start_index>`

View File

@ -1,10 +1,20 @@
# NFC and Coldcard Mk4
# NFC and Coldcard
(Applies to Coldcard Mk4 only)
(Applies to NFC-equipped models: Mk4, Mk5, and Q)
## Usage
Mk4 NFC antenna is centered under number `8` on the keypad. Before using NFC,
The NFC antenna location depends on the hardware:
- **Mk4**: a PCB trace loop, centered under number `8` on the keypad.
- **Mk5**: a discrete coil (`L6`) in the **top-right corner** of the device
- **Q1**: a flexible "sticker" antenna behind the display. The green LED below the
bottom-right of the display (`D12`) lights up while an NFC transfer is active —
it is the activity indicator, not the antenna.
![nfc tap sweet-spots per model](nfc-tap-locations.png)
Before using NFC,
it is important to locate the position of NFC antenna on your device and point it
correctly towards the Coldcard NFC antenna. Picture below shows an example with iPhone
that has NFC antenna located at the top right edge. The NFC smartphone antenna
@ -36,7 +46,7 @@ in general. Good interoperability is critical with radio standards.
## Lower Layers
The Coldcard Mk4 has an chip that acts as a Type 5 NFC tag. The
The Coldcard has a chip that acts as a Type 5 NFC tag. The
radio standard is called "NFC-V" or ISO-15693, and operates on a
13.56 Mhz carrier wave.
@ -58,9 +68,13 @@ unless we are actively sharing something. We disable the "energy
harvesting" features of the chip, so it will not do anything when
the Coldcard is powered-down, regardless of the NFC setting.
If the above is not enough for you, the antenna can be destroyed
by cutting the trace labeled "NFC" inside the hole for the MicroSD
card. Use the point of a sharp knife to cut and peel up the trace.
If the above is not enough for you, the antenna can be destroyed:
- **Mk4**: cut the trace labeled "NFC" inside the hole for the MicroSD card,
using the point of a sharp knife to cut and peel up the trace.
- **Mk5**: has no such trace — its antenna is the discrete coil `L6` in the
top-right corner, which would have to be physically removed instead.
- **Q1**: cut the trace labeled "NFC DATA" under the batteries.
The NFC traffic is not encrypted and is subject to eavesdropping.
While the NFC feature is active, your Coldcard can be uniquely

View File

@ -34,7 +34,7 @@ The COLDCARD needs a URL prefix. To that it appends some values:
- when RegTest is enabled, the value will be `XRT`
We provide a few default URL values to our customers, including one backend we
will operate on `colcard.com`. The URL can also be directly entered by the
will operate on `coldcard.com`. The URL can also be directly entered by the
customer. On the Q, it can be scanned from a QR code.
For COLDCARD backend, the url used is:

BIN
docs/nfc-tap-locations.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@ -11,11 +11,17 @@ The entrypoint makefile for repro builds.
The `repro` command in `shared.mk` is the first step in the repro build process, which triggers a docker build and run process.
```makefile
repro: submods-match code-committed
repro:
docker build -t coldcard-build - < dockerfile.build
(cd ..; docker run $(DOCK_RUN_ARGS) sh src/stm32/repro-build.sh $(VERSION_STRING) $(MK_NUM))
(cd ..; docker run $(DOCK_RUN_ARGS) sh src/stm32/repro-build.sh $(VERSION_STRING) $(HW_MODEL) $(PARENT_MKFILE))
```
`$(HW_MODEL)` is the model string (e.g. `mk4`, `q1`) and `$(PARENT_MKFILE)` is the
top-level makefile being used (`MK-Makefile` or `Q1-Makefile`). The `submods-match`
and `code-committed` prerequisites ensure the submodules and working tree are clean
before a repro build.
Below are interesting sections from the docker logs that give an idea as to what is going on in build process:
```stdout
@ -61,19 +67,19 @@ Successfully installed signit-1.0
...
+ make -f MK4-Makefile setup
+ make -f MK-Makefile setup
...
+ make -f MK4-Makefile firmware-signed.bin firmware-signed.dfu production.bin dev.dfu firmware.lss firmware.elf
+ make -f MK-Makefile firmware-signed.bin firmware-signed.dfu production.bin dev.dfu firmware.lss firmware.elf
...
signit sign -b l-port/build-COLDCARD_MK4 -m 4 5.0.7 -o firmware-signed.bin
signit sign -b l-port/build-COLDCARD_MK4 -m mk4 5.0.7 -o firmware-signed.bin
...
signit sign -m 4 5.0.7 -r firmware-signed.bin -k 1 -o production.bin
signit sign -m mk4 5.0.7 -r firmware-signed.bin -k 1 -o production.bin
You don't have that key (1), so using key zero instead!
...
@ -96,7 +102,7 @@ production.bin
...
+ make -f MK4-Makefile 'PUBLISHED_BIN=/tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu' check-repro
+ make -f MK-Makefile 'PUBLISHED_BIN=/tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu' check-repro
...
@ -183,7 +189,7 @@ To summarize `check-repro`:
- `split` (cli/signit.py: Line 153-175) is run against the release `*.dfu` resulting in a `check-fw.bin` and `check-bootrom.bin`. "This splits the DFU file into the two parts it contains: the main firmware (COLDCARD application) and the boot loader code."
- `check` (cli/signit.py: Line 176-241) is run against each the release `check-fw.bin` and our built `firmware-signed.bin`.
- `check` (cli/signit.py: Line 176-243) is run against each the release `check-fw.bin` and our built `firmware-signed.bin`.
- a hexdump is taken of each the release `check-fw.bin` and our built `firmware-signed.bin` piped through $TRIM_SIG which removes 64 bytes of signature data and subsitutes it with a common string.

View File

@ -9,56 +9,104 @@ BIP-322 specification: <https://github.com/bitcoin/bips/blob/master/bip-0322.med
COLDCARD accepts a specially crafted PSBT file to sign as BIP-322 Proof of Reserves. The PSBT
must meet all these requirements:
* COLDCARD acts as a BIP-322 PSBT signer. It validates the BIP-322 `to_sign`
transaction, shows the message from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE`, and
adds signatures to the PSBT. Finalizing and encoding the final BIP-322
signature string is the responsibility of the finalizer.
* PSBT MUST include `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09`; the value is
the exact message shown to the user and signed by BIP-322.
* PSBT requires `PSBT_IN_BIP32_DERIVATION` for each input
* P2SH wrapped segwit addresses MUST have proper redeem script in PSBT: `PSBT_IN_REDEEM_SCRIPT`
* P2WSH segwit addresses MUST have proper witness script in PSBT: `PSBT_IN_WITNESS_SCRIPT`
* First (0th) input in `to_sign` transaction MUST have full (pre-segwit) UTXO (`PSBT_IN_NON_WITNESS_UTXO`) a.k.a `to_spend`.
* First (0th) input in `to_sign` `PSBT_IN_NON_WITNESS_UTXO` transaction (`to_spend`) is as defined
in [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full):
* PSBT (`to_sign`) MUST have at least one input.
* First (0th) input of `to_sign` MUST spend the BIP-322 `to_spend` output.
* Input 0 MUST include one of `PSBT_IN_NON_WITNESS_UTXO` or `PSBT_IN_WITNESS_UTXO`.
* When input 0 provides `PSBT_IN_WITNESS_UTXO`, COLDCARD reconstructs the
expected `to_spend` txid from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and the
witness UTXO scriptPubKey.
* When input 0 provides `PSBT_IN_NON_WITNESS_UTXO`, it MUST be the BIP-322
`to_spend` transaction as defined in
[BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full):
* 1 input, 1 output
* output nValue is 0
* input prevout hash is 0
* input prevout n is 0xffffffff
* input scriptSig is `OP_0 PUSH32 message_hash`
* PSBT (`to_sign`) MUST have at least one input & first input MUST be `to_spend` full txn
* PSBT (`to_sign`) MUST only have one output with null-data `OP_RETURN`
* `to_sign` transaction version MUST be 0 or 2.
* Optionally inputs can be added to `to_sign` for Proof of Reserve signing.
* PSBT MUST be version 0.
* PSBT MUST be version 0 or 2.
* Foreign inputs not allowed in POR PSBT.
The signatures created by the BIP-322 process will never be suitable
for a on-chain Bitcoin transaction that could move funds, because
of these restrictions imposed by BIP-322.
### Output
COLDCARD always returns a signed PSBT for BIP-322 message signing and Proof of
Reserves. It never returns an extracted/finalized transaction for these PSBTs.
This is true even when finalization is requested over USB, such as with
`ckcc unsigned.psbt --finalize`.
The signed PSBT is the handoff artifact for the external finalizer/verifier. It
keeps the PSBT metadata needed to verify or finalize the BIP-322 signature,
including public keys, scripts, partial signatures, and UTXO data. This matters
because the address being proven normally commits only to a hash of the public
key or script, not the public key or script itself.
### Proof of Reserves Signing Experience
After Coldcard recognizes BIP-322 PoR PSBT it asks the user to
import a human-readable message that was used to build `to_spend`
scriptSig. This message must hash exactly the `message_hash` from
the PSBT, otherwise signing is not offered.
After Coldcard recognizes a BIP-322 PSBT it reads the message from
`PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and shows it to the user for approval.
COLDCARD verifies that the message hash matches the input 0 `to_spend`
commitment before offering to sign.
When the PSBT contains only input 0, COLDCARD labels the request as
`BIP-322 Message`, because it is message signing and does not prove ownership
of any additional reserve UTXOs. In that case it does not show transaction
input/output counts. When the PSBT contains additional inputs, COLDCARD labels
the request as `Proof of Reserves` and shows the reserve amount.
If the message contains non-ASCII characters, COLDCARD warns that some
characters may not be readable on screen.
Legacy PoR PSBTs without `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` are rejected by
this flow.
Read more [here.](https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c)
Example screen text:
Example screen text for a one-input BIP-322 message signing PSBT:
```text
BIP-322 Message
Message:
This is the signed message
Challenge Address:
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
Press ENTER to approve and sign message. Press (2) to explore transaction.
CANCEL to abort.
```
Example screen text for a Proof of Reserves PSBT:
```text
Proof of Reserves
Message:
POR
Amount 0.20000000 BTC
Message Hash:
11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
Challenge Address:
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
Message Challenge:
00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
21 inputs
1 output
21 inputs
1 output
0.00000000 BTC
- OP_RETURN -
null-data
Press ENTER to approve and sign transaction. Press (2) to explore txn
outputs. CANCEL to abort.
Press ENTER to approve and sign proof of reserves. Press (2) to explore transaction.
CANCEL to abort.
```

View File

@ -92,8 +92,8 @@ increases flexibility and resistance to known plain text attacks.
| `pin stretch` | slot 2 | HMAC | SE1 | Key stretching for PIN entry and anti-phish words
| `firmware` | slot 14 | SHA256d | SE1 | Firmware checksum, controls green/red LEDs
| `nonce/chksum` | slot 10 | data | SE1 | AES nonce and GMAC tag, protected by PIN
| `SE2 easy key` | page 15 | AES via HMAC | SE2 | Another SE2 part of AES seed key
| `SE2 hard key` | page 14 | AES via ECC | SE2 | SE2's part of AES seed key; ECC used to unlock
| `SE2 easy key` | page 14 | AES via HMAC | SE2 | Another SE2 part of AES seed key
| `SE2 hard key` | page 15 | AES via ECC | SE2 | SE2's part of AES seed key; ECC used to unlock
| `tpin key` | `tpin_key` | HMAC(key) | MCU | Key for HMAC used to encrypt trick PINs
| `trick PIN slots` | pages 0-12 | HMAC | SE2 | Protect duress wallet seeds and pins (6 spots)
| `SE2 trash` | secret B | HMAC | SE2 | Used to destroy values (only SE2 knows the value)

View File

@ -1,4 +1,4 @@
# COLDCARD Mk4/Q Security Model
# COLDCARD Mk4/Mk5/Q Security Model
## Abstract
@ -96,9 +96,10 @@ user entered the True PIN. An attacker will only have access to the
duress wallet. They won't have access to steal the main stash.
The private key can be automatically derived using BIP-85 methods,
based on account numbers 1001, 1002, or 1003. Because this is BIP-85
based and uses a 24-word seed, it behaves exactly like a normal
wallet. Defining a passphrase for the wallet is also possible.
based on account numbers 1001, 1002, or 1003 for a 24-word duress wallet
(or 2001, 2002, 2003 for a 12-word one). Because this is BIP-85
based, it behaves exactly like a normal wallet. Defining a passphrase
for the wallet is also possible.
The Mk4 also supports older COLDCARD duress wallets and their UTXOs
on the blockchain. There is an option to create compatible wallets
@ -243,7 +244,7 @@ COLDCARD's case to do so, but the option is there if needed.
## SD Card Recovery Mode
Mk4/Q bootloader is smart enough to be able to read an SD card. You
Mk4/Mk5/Q bootloader is smart enough to be able to read an SD card. You
will only be able to trigger the SD card loading code, if the
COLDCARD was powered down during the upgrade process. At that point,
the intended firmware image has been lost because it it held in

View File

@ -12,7 +12,7 @@ are not discrete and you could be compelled to produce the passphrase.
Enter [_Seed XOR_](https://seedxor.com), a plausibly deniable means
of storing secrets in two or more parts that look and behave just
like the original secret. One 12 or 24-word seed phrase becomes two or more parts
like the original secret. One 12-, 18-, or 24-word seed phrase becomes two or more parts
that are also BIP-39 compatible seeds phrases. These should be backed up in your
preferred method, metal or otherwise. These parts can be individually loaded
with honeypot funds as each one has same word length, with the last being
@ -78,10 +78,12 @@ words right the next day.
When the parts are made deterministically, we take a double-SHA256 over
a fixed string (`Batshitoshi`), your master secret, and the text
`1 of 4 parts` which changes for each part.
`0 of 4 parts` which changes for each part (the index is 0-based).
In random mode, we simply pick 32 random bytes (and then double-SHA256
them) from the Coldcard's True Random Number Generator (TRNG)..
In random mode, we simply pick random bytes (and then double-SHA256
them) from the Coldcard's True Random Number Generator (TRNG). The number
of bytes matches your secret length: 16, 24, or 32 bytes for a 12-, 18-,
or 24-word seed respectively.
This is done to make all but the one part. The final part is the
value needed to get back to your secret, so it's the XOR of the

View File

@ -1,7 +1,7 @@
# Temporary Seeds
[_(new in v5.0.7, requires Mk4)_](upgrade.md)
[_(new in v5.0.7, requires Mk4, Mk5, or Q)_](upgrade.md)
Temporary seed (renamed in `5.2.0` from Ephemeral seed) is a temporary secret completely separate
@ -42,7 +42,7 @@ Read more about `Seed Vault` feature below.
- `24 words`
- `XPRV (BIP-32)`
- pick derivation `Index` in next prompt, or just press OK for index 0
- Press (2) in next prompt to activate derived secret as a temporary seed
- Press (0) in next prompt to activate derived secret as a temporary seed
* temporary seed can be activated from Duress Wallet
- go to `Settings -> Login Settings -> Trick Pins`
@ -66,7 +66,7 @@ Ability to generate and use **Temporary seed** is available on Coldcard when:
# Restore Master
[_(new in v5.2.0, requires Mk4)_](upgrade.md)
[_(new in v5.2.0, requires Mk4, Mk5, or Q)_](upgrade.md)
From version `5.2.0` users no longer need to reboot COLDCARD to return
to their "master seed" (one stored in SE2). Once COLDCARD has temporary
@ -84,7 +84,7 @@ Seed Vault entries can only be deleted in Seed Vault menu.
# Seed Vault
[_(new in v5.2.0, requires Mk4)_](upgrade.md)
[_(new in v5.2.0, requires Mk4, Mk5, or Q)_](upgrade.md)
Seed Vault adds the ability to store multiple temporary secrets into encrypted settings for simple
recall and later use (AES-256-CTR encrypted with your master seed's key).

View File

@ -1,7 +1,7 @@
# Firmware Upgrade and Recovery Process
_This document applies only to the Mk4. Earlier COLDCARDs did not use this approach._
_This document applies to the Mk4, Mk5, and Q. Earlier COLDCARDs did not use this approach._
On the COLDCARD, we have done away with the slow external SPI flash
(serial flash) chip entirely (used in Mk1-Mk3). In it's place we

View File

@ -30,8 +30,8 @@ the correct code.
- Usual 2fa base32 secret is picked by CC and stored in CC (so that server is stateless)
- CC creates URL encrypted to the pubkey of server, containing args:
- shared secret for TOTP (same value as held in user's phone)
- the response nonce (16 bytes, or 8 digits for Mk4) to be revealed to the user
on successful auth
- the response nonce (32 bytes, shown as 64 hex chars, on Q; or 8 digits on Mk4)
to be revealed to the user on successful auth
- flag if Q model, so can provide a QR to be scanned in that case (rather than digits)
- some text label for what's being approved, which is presented to user so they can pick
correct 2fa shared secret.
@ -82,12 +82,15 @@ the correct code.
## URL Format
https://coldcard.com/2fa?ss={shared_secret}&q={is_q}&g={nonce}&nm={label_text}
https://coldcard.com/2fa?g={nonce}&ss={shared_secret}&nm={label_text}&q={is_q}
(the query string is then encrypted to the server's pubkey, so the args above
are what is inside the encrypted payload.)
- `nonce`: text string that is either 8 digits on Mk4, or 64 hex chars on Q
- `shared_secret`: 16 chars of Base32-encoded pre-shared secret
- `is_q`: flag indicating use of QR to provide nonce back to user
- `nonce`: text string that is either 8 digits for Mk4, or hex digits for QR
- `nm`: human readable label for the transaction/purpose
- `is_q`: flag indicating use of QR to provide nonce back to user
Server will accept plaintext arguments as above, but normally everything
after the question mark is encrypted.

@ -1 +1 @@
Subproject commit 51de25089ef0154f6cc4b54a849e611e8c88a3fd
Subproject commit 3d1dfa858beb58b8dac37d8c66d7aed2909812f2

View File

@ -4,7 +4,48 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
- tbd
- Change: BIP-322 Proof of Reserves & message signing PSBT requires PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE
(read more [BIP-322 Proof of Reserves documentation](../docs/proof-of-reserves-bip-322.md) )
- Enhancement: WIF Store export watch-only descriptor
- Enhancement: WIF Store address detection without the need for PSBT_IN_BIP32_DERIVATION (Electrum support)
- Enhancement: Improve USB length validation
- Bugfix: Fixes legacy input amount spoofing by rejecting witness-utxo-only PSBT inputs when Coldcard is expected to sign a non-segwit input.
When both UTXO fields are present the full non_witness_utxo is now preferred for amount/script lookup. Thanks, @Damir
- Bugfix: Emit warning and do not calculate fee for legacy UTXOs with only witness utxo
- Bugfix: Disable Virtual Disk and NFC before activating HSM
- Bugfix: P2PK signing was broken. Now supports both compressed and uncompressed P2PK spend
- Bugfix: Custom address default menu position wrong
- Bugfix: Delta Mode Trick PIN was never restored from backup
- Bugfix: Proper error message for incorrect 7z headers
- Bugfix: Exiting nickname entry with nickname already saved deleted previous nickname
- Bugfix: "Send Password" menu item inside Notes & Passwords visibility reversed
- Bugfix: Yikes when using "Send Password" on entry with password None field
- Bugfix: Do not show "Saving..." UX after failed Notes & Passwords import
- Bugfix: Incorrect error message caused by error in Verify/Decrypt Backup
- Bugfix: NFC Verify Address raised incorrect error message
- Bugfix: Notes & Passwords bulk import JSON with BBQr encoded as text
- Bugfix: CCC key C challenge handled bad BIP-39 checksum by crashing the UX; now treated as a wrong attempt (counts toward 3-strike lockout)
- Bugfix: CCC magnitude reset from CANCEL on empty input
- Bugfix: OP_RETURN in CCC with whitelist enabled caused yikes
- Bugfix: TX Explorer crashed on foreign input with non-standard sighash
- Bugfix: Malformed JSON message-sign request crashed signing UX
- Bugfix: Reject UI-control bytes in JSON / QR text message-signing
- Bugfix: Non-standard OP_RETURN outputs shown as "null-data", hiding part of the script
- Bugfix: Over-limit CCC address-whitelist import was rejected but still modified the policy
- Bugfix: Deleting a file right after renaming it (List Files) blanked the old name, leaving the renamed file
- Bugfix: Reordered `multi(...)` multisig with same keys was misreported as name-only change. Now blocked as duplicate.
- Bugfix: Max WIF store capacity limit was ignored if saving via QR WIF visualization
- Bugfix: Force Seed XOR restore from Temporary Seed menu to remain temporary even when master seed is blank
- Bugfix: Q1 seed word entry cursor alignment for 12-word seeds and preserve visible words after failed QR scans
- Bugfix: Binary signed-transaction (.txn) failed in NFC/QR file share
- Bugfix: yikes in transaction explorer for goto index for tx with only one output
- Bugfix: Sending `signmessage` payload encoded as BBQr caused yikes
- Bugfix: CCC/SSSP NFC whitelist import caused Yikes
- Bugfix: Stricter address ownership validation rejects unrecognized payment addresses before wallet search
- Bugfix: Handle malformed NDEF records robustly. Thanks, @Damir
- Bugfix: Ignore `bkpw` if added to backup. Thanks [@dmonakhov](https://github.com/dmonakhov)
- Bugfix: Keep NFC export tag live for repeated probes
- Bugfix: Fix 1of1 multisig signing failure
# Mk Specific Changes
@ -17,6 +58,17 @@ This lists the new changes that have not yet been published in a normal release.
## 1.4.xQ - 2065-04-xx
- tbd
- New Feature: Secure Notes & Passwords UX groups
- New Feature: Apply Secure Note text, or Secure Note password as BIP-39 passphrase
- Bugfix: Teleporting a multisig PSBT file (without signing it first) sent stale data instead of the selected file
- Bugfix: Fix export UX message after teleport PSBT import & sign
- Bugfix: BIP-21 QR `amount` rendered with wrong decimal scaling on the Payment Address screen (e.g. `amount=1.1` was shown as `1.00000001 BTC`)
- Bugfix: QR scan import (Scan Any QR Code, master/temp seed via QR) now surfaces a clean error story on any parser or seed-loading failure (e.g. wordlist-valid but bad-checksum SeedQR) instead of yikesing the menu task
- Bugfix: Yikes when showing "QR too big" for a transaction output alone on an output-explorer page
- Bugfix: Yikes receiving a malformed full-backup via Key Teleport
- Bugfix: Keyboard debounce could leave a key stuck as "pressed" after release when another key was held
- Bugfix: Scanner robustness
- Avoid holding the QR scanner reset line low; reset is now only pulsed and then left deasserted.
- Recover scanner setup failures by retrying configuration and reinitializing on the next scan when needed.
- Prevent delayed scanner sleep commands from racing with a newly started scan.
- Improve scanner shutdown/recovery after scan cancel or command timeout.

View File

@ -459,21 +459,27 @@ async def pick_nickname(*a):
# Value is not stored with normal settings, it's part of "prelogin" settings
# which are encrypted with zero-key.
s = SettingsObject.prelogin()
nick = s.get('nick', '')
k = "nick"
nick = s.get(k, '')
if not nick:
ch = await ux_show_story('''\
You can give this Coldcard a nickname and it will be shown before login.''')
ch = await ux_show_story("You can give this Coldcard a nickname"
" and it will be shown before login.")
if ch != 'y': return
nn = await ux_input_text(nick, confirm_exit=False, prompt="Enter Nickname")
if nn is None or (nick == nn): return # user exit & same value - noop
from glob import dis
dis.fullscreen("Saving...")
dis.busy_bar(True)
nn = nn.strip() if nn else None
s.set('nick', nn)
if not nn:
s.remove_key(k)
else:
s.set(k, nn.strip())
s.save()
dis.busy_bar(False)
del s
@ -822,7 +828,7 @@ async def start_login_sequence():
# PIN again) and if it's a duress wallet, that's cool...
# Do we need to do countdown delay? (real or otherwise)
# - wiping has already occured if that was selected by trick details
# - wiping has already occurred if that was selected by trick details
# - delay is variable, stored in tc_arg
delay = tp.was_countdown_pin()
@ -932,12 +938,14 @@ async def start_login_sequence():
settings.master_set("seedvault", False)
except: pass
if version.has_nfc and settings.get('nfc', 0):
from glob import hsm_active
if version.has_nfc and settings.get('nfc', 0) and not hsm_active:
# Maybe allow NFC now
import nfc
nfc.NFCHandler.startup()
if settings.get('vidsk', 0):
if settings.get('vidsk', 0) and not hsm_active:
# Maybe start virtual disk
import vdisk
vdisk.VirtDisk()
@ -1095,8 +1103,10 @@ async def export_xpub(label, _2, item):
if ch == "2":
slip132 = not slip132
continue
if ch == '1':
acct = await ux_enter_bip32_index('Account Number:') or 0
acct = await ux_enter_bip32_index('Account Number:')
if acct is None: continue
pth_split = path.split("/")
pth_split[-1] = ("%dh" % acct)
path = "/".join(pth_split)
@ -1138,15 +1148,16 @@ async def electrum_skeleton(a, b, item):
ch = await ux_show_story(electrum_export_story(title), escape='1')
account_num = 0
acct = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:')
if (ch not in '1y') or acct is None:
return
rv = [
MenuItem(chains.addr_fmt_label(af), f=electrum_skeleton_step2,
arg=(af, account_num, title, fname_pat))
arg=(af, acct, title, fname_pat))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
@ -1167,13 +1178,14 @@ async def ss_descriptor_skeleton(_0, _1, item):
int_ext, allowed_af, ll, f_pattern, direct_way = item.arg
addition = " for " + ll
account_num = 0
acct = 0
if not direct_way:
ch = await ux_show_story(ss_descriptor_export_story(addition), escape='1')
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:', unlimited=True)
if (ch not in '1y') or acct is None:
return
if int_ext is None:
@ -1185,12 +1197,12 @@ async def ss_descriptor_skeleton(_0, _1, item):
int_ext = False if ch == "1" else True
if len(allowed_af) == 1:
await make_descriptor_wallet_export(allowed_af[0], account_num, int_ext=int_ext,
await make_descriptor_wallet_export(allowed_af[0], acct, int_ext=int_ext,
fname_pattern=f_pattern, direct_way=direct_way)
else:
rv = [
MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2,
arg=(af, account_num, int_ext, f_pattern, direct_way))
arg=(af, acct, int_ext, f_pattern, direct_way))
for af in allowed_af
]
the_ux.push(MenuSystem(rv))
@ -1204,12 +1216,13 @@ async def key_expression_skeleton_step2(_1, _2, item):
async def key_expression_skeleton(_0, _1, item):
# Export key expression -> [xfp/d/e/r]xpub
acct_num = 0
acct = 0
ch = await ux_show_story("This saves a extended key expression."
+ PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
if ch == '1':
acct_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:', unlimited=True)
if (ch not in '1y') or acct is None:
return
# element on 2nd index is address format for signed exports
@ -1229,7 +1242,7 @@ async def key_expression_skeleton(_0, _1, item):
ct = chains.current_chain().b44_cointype
rv = [ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct_num), af))
rv = [ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct), af))
for label, orig_der, af in todo ]
rv += [ MenuItem("Custom Path", menu=doit) ]
@ -1279,14 +1292,15 @@ You can then run the commands in Bitcoin Core's console window, \
without ever connecting this Coldcard to a computer.\
''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
account_num = 0
acct = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:')
if (ch not in '1y') or acct is None:
return
# no choices to be made, just do it.
await make_bitcoin_core_wallet(account_num)
await make_bitcoin_core_wallet(acct)
async def electrum_skeleton_step2(_1, _2, item):
@ -1300,13 +1314,14 @@ async def _generic_export(prompt, label, f_pattern):
# like the Multisig export, make a single JSON file with
# basically all useful XPUB's in it.
ch = await ux_show_story(prompt + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1")
account_num = 0
acct = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:')
if (ch not in '1y') or acct is None:
return
await export_contents(label, lambda: generate_generic_export(account_num),
await export_contents(label, lambda: generate_generic_export(acct),
f_pattern, is_json=True)
async def generic_skeleton(*A):
@ -1351,16 +1366,17 @@ async def unchained_capital_export(*a):
ch = await ux_show_story('''\
This saves multisig XPUB information required to setup on the Unchained platform. \
''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1")
account_num = 0
acct = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
acct = await ux_enter_bip32_index('Account Number:')
if (ch not in '1y') or acct is None:
return
xfp = xfp2str(settings.get('xfp', 0))
fname = 'unchained-%s.json' % xfp
await export_contents('Unchained', lambda: generate_unchained_export(account_num),
await export_contents('Unchained', lambda: generate_unchained_export(acct),
fname, is_json=True)
@ -1609,7 +1625,7 @@ async def qr_share_file(_1, _2, item):
# it's a txn, and we wrote as hex
data = data.decode()
else:
assert data[2:8] == bytes(6)
assert data[1:4] == bytes(3)
data = b2a_hex(data).decode()
elif data[0:5] == b'psbt\xff':
tc = "P"
@ -1764,6 +1780,7 @@ async def list_files(*A):
assert s not in new_basename, "illegal char"
uos.rename(path + "/" + basename, path + "/" + new_basename)
basename = new_basename
fn = path + "/" + basename # keep full path in sync (delete/sign use it)
except Exception as e:
await ux_show_story("Failed to rename the file. " + str(e),
title="Failure")
@ -2296,7 +2313,7 @@ async def wipe_address_cache(*a):
async def wipe_ovc(*a):
ok = await ux_confirm('''Clear history of segwit UTXO input values we have seen already. \
This data protects you against specific attacks. Use this only if certain a false-positive \
has occured in the detection logic.''')
has occurred in the detection logic.''')
if not ok: return
import history
@ -2430,7 +2447,11 @@ async def scan_any_qr(menu, label, item):
async def _scan_any_qr(expect_secret=False, tmp=False):
from ux_q1 import QRScannerInteraction
x = QRScannerInteraction()
await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
try:
await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
except Exception as e:
await ux_show_story(msg="Failed to import from QR.\n\n%s\n%s" % (e, problem_file_line(e)),
title="ERROR")
PUSHTX_SUPPLIERS = [

View File

@ -115,7 +115,7 @@ class KeypathMenu(MenuSystem):
val = item.arg or item.label
assert val.endswith('/⋯')
cpath = val[:-2]
nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True)
nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True, can_cancel=False)
return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn)
class PickAddrFmtMenu(MenuSystem):
@ -126,9 +126,10 @@ class PickAddrFmtMenu(MenuSystem):
for af in chains.SINGLESIG_AF
]
super().__init__(items)
if path.startswith("m/84h"):
# below is sensitive to order in chains.SINGLESIG_AF
if path.startswith("m/44h"):
self.goto_idx(1)
if path.startswith("m/49h"):
elif path.startswith("m/49h"):
self.goto_idx(2)
async def done(self, _1, _2, item):
@ -241,11 +242,15 @@ class AddressListMenu(MenuSystem):
self.goto_idx(axi)
async def change_account(self, *a):
self.account_num = await ux_enter_bip32_index('Account Number:') or 0
acct = await ux_enter_bip32_index('Account Number:')
if acct is None: return
self.account_num = acct
await self.render()
async def change_start_idx(self, *a):
self.start = await ux_enter_bip32_index("Start index:", unlimited=True)
idx = await ux_enter_bip32_index("Start index:", unlimited=True)
if idx is None: return
self.start = idx
await self.render()
async def pick_single(self, _1, _2, item):

View File

@ -131,7 +131,7 @@ Press %s to continue, otherwise %s to cancel.''' % (OK, X)
class ApproveMessageSign(UserAuthorizedAction):
def __init__(self, text, subpath, addr_fmt, approved_cb=None,
msg_sign_request=None, only_printable=True, privkey=None):
msg_sign_request=None, allow_tab_nl=False, privkey=None):
super().__init__()
is_json = False
@ -141,7 +141,7 @@ class ApproveMessageSign(UserAuthorizedAction):
text, subpath, addr_fmt, is_json = parse_msg_sign_request(msg_sign_request)
self.text = validate_text_for_signing(
text, only_printable=not is_json and only_printable
text, allow_tab_nl=is_json and allow_tab_nl
)
self.subpath = cleanup_deriv_path(subpath)
self.addr_fmt = chains.parse_addr_fmt_str(addr_fmt)
@ -203,7 +203,7 @@ def sign_msg(text, subpath, addr_fmt):
async def approve_msg_sign(text, subpath, addr_fmt, approved_cb=None,
msg_sign_request=None, kill_menu=False,
only_printable=True, privkey=None):
allow_tab_nl=False, privkey=None):
# Ask user if they want to sign some short text message.
UserAuthorizedAction.cleanup()
@ -213,7 +213,7 @@ async def approve_msg_sign(text, subpath, addr_fmt, approved_cb=None,
text, subpath, addr_fmt,
approved_cb=approved_cb,
msg_sign_request=msg_sign_request,
only_printable=only_printable,
allow_tab_nl=allow_tab_nl,
privkey=privkey
)
@ -271,8 +271,9 @@ async def try_push_tx(data, txid, txn_sha=None):
class ApproveTransaction(UserAuthorizedAction):
def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None,
output_encoder=None, filename=None):
output_encoder=None, filename=None, offset=TXN_INPUT_OFFSET):
super().__init__()
self.offset = offset
self.psbt_len = psbt_len
# do finalize is None if not USB, None = decide based on is_complete
@ -291,56 +292,6 @@ class ApproveTransaction(UserAuthorizedAction):
self.result = None # will be (len, sha256) of the resulting PSBT
self.chain = chains.current_chain()
async def por322_msg_verify(self):
# https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c
from glob import NFC
from ux import import_export_prompt
from actions import file_picker
ch = await import_export_prompt("message", is_import=True, force_prompt=True,
intro="Import msg that hashes to 'to_spend' msg hash.",
key0="to input message manually",
title="BIP-322 Messsage" if version.has_qwerty else 'BIP-322 MSG',
no_qr=not version.has_qwerty)
# single sha256 of b'BIP0322-signed-message'
bip322_tag_hash = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
if ch == KEY_CANCEL:
return
elif ch == "0":
msg = await ux_input_text("", confirm_exit=False)
elif ch == KEY_NFC:
msg = await NFC.read_bip322_msg()
elif ch == KEY_QR:
from ux_q1 import QRScannerInteraction
msg = await QRScannerInteraction().scan_text('Scan message from a QR code')
else:
choices = await file_picker(suffix='.txt', ux=False, **ch)
target = "%s.txt" % b2a_hex(self.psbt.por322_msg_hash).decode()
for fname, dir, _ in choices:
if target == fname:
fn = dir + "/" + fname
break
else:
fn = await file_picker(choices=choices, **ch)
if not fn: return
with CardSlot(readonly=True, **ch) as card:
with open(fn, 'rt') as fd:
msg = fd.read()
assert msg, "need msg"
msg_hash = ngu.hash.sha256t(bip322_tag_hash, msg, True)
assert msg_hash == self.psbt.por322_msg_hash, "hash verification failed"
ch = await ux_show_story(
msg+"\n\nPress %s to approve message, otherwise %s to exit." % (OK, X),
title="Message:"
)
return True if ch == "y" else False
def render_output(self, o):
# Pretty-print a transactions output.
# - expects CTxOut object
@ -400,7 +351,7 @@ class ApproveTransaction(UserAuthorizedAction):
# step 1: parse PSBT from PSRAM into in-memory objects.
try:
with SFFile(TXN_INPUT_OFFSET, length=self.psbt_len, message='Reading...') as fd:
with SFFile(self.offset, length=self.psbt_len, message='Reading...') as fd:
# NOTE: psbtObject captures the file descriptor and uses it later
self.psbt = psbtObject.read_psbt(fd)
except BaseException as exc:
@ -419,13 +370,14 @@ class ApproveTransaction(UserAuthorizedAction):
await self.psbt.validate() # might do UX: accept multisig import
dis.progress_sofar(10, 100)
# consider_keys only needs num_our_keys to be set
# it set during psbt.validate()
self.psbt.consider_keys()
if not self.psbt.wif_store:
self.psbt.consider_keys()
dis.progress_sofar(20, 100)
ccc_c_xfp = CCCFeature.get_xfp() # can be None
self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
if self.psbt.wif_store:
self.psbt.consider_keys()
dis.progress_sofar(50, 100)
self.psbt.consider_outputs()
@ -476,6 +428,7 @@ class ApproveTransaction(UserAuthorizedAction):
#
try:
msg = uio.StringIO()
is_por = self.psbt.por322 and (self.psbt.num_inputs > 1)
# mention warning at top
wl= len(self.psbt.warnings)
@ -485,20 +438,15 @@ class ApproveTransaction(UserAuthorizedAction):
msg.write('(%d warnings below)\n\n' % wl)
if self.psbt.por322:
msg.write("%s\n\n" % ("Proof of Reserves" if is_por else "BIP-322 Message"))
msg.write("Message:\n%s\n\n" % self.psbt.por322_msg)
if is_por:
msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in))
try:
if not await self.por322_msg_verify():
self.refused = True
await ux_dramatic_pause("Refused.", 1)
self.done()
return
except Exception as exc:
return await self.failure("Msg verification failed.", exc)
msg.write("Proof of Reserves\n\n")
msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in))
msg.write("Message Hash:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_hash).decode())
msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode())
addr = self.chain.render_address(self.psbt.por322_msg_challenge)
msg.write("Challenge Address:\n%s\n\n" % show_single_address(addr))
except ValueError:
msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode())
else:
if self.psbt.consolidation_tx:
# consolidating txn that doesn't change balance of account.
@ -512,15 +460,18 @@ class ApproveTransaction(UserAuthorizedAction):
if fee is not None:
msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee))
msg.write(" %d %s\n %d %s\n\n" % (
self.psbt.num_inputs,
"input" if self.psbt.num_inputs == 1 else "inputs",
self.psbt.num_outputs,
"output" if self.psbt.num_outputs == 1 else "outputs",
))
if not self.psbt.por322 or is_por:
msg.write(" %d %s\n %d %s\n\n" % (
self.psbt.num_inputs,
"input" if self.psbt.num_inputs == 1 else "inputs",
self.psbt.num_outputs,
"output" if self.psbt.num_outputs == 1 else "outputs",
))
if not self.psbt.por322:
# outputs + change story created here
self.output_summary_text(msg)
# outputs + change story created here
self.output_summary_text(msg)
gc.collect()
if self.psbt.ux_notes:
@ -548,8 +499,13 @@ class ApproveTransaction(UserAuthorizedAction):
if not hsm_active:
esc = "2"
msg.write("Press %s to approve and sign transaction."
" Press (2) to explore transaction." % OK)
noun = "transaction"
if self.psbt.por322:
noun = "proof of reserves" if is_por else "message"
msg.write("Press %s to approve and sign %s."
" Press (2) to explore transaction." % (OK, noun))
if (self.input_method == "sd") and CardSlot.both_inserted():
esc += "b"
msg.write(" (B) to write to lower SD slot.")
@ -621,7 +577,7 @@ class ApproveTransaction(UserAuthorizedAction):
CCCFeature.sign_psbt(self.psbt)
if SSSPFeature.is_enabled():
# capture new min-height for velocity limit
# update SSSP block_h even if SSSP blocks and overridden by CCC
SSSPFeature.update_last_signed(self.psbt)
except FraudulentChangeOutput as exc:
@ -695,7 +651,8 @@ class ApproveTransaction(UserAuthorizedAction):
has_change = True
total_change += tx_out.nValue
if len(largest_change) < MAX_VISIBLE_CHANGE:
largest_change.append((tx_out.nValue, self.chain.render_address(tx_out.scriptPubKey)))
_, addr = self.render_output(tx_out)
largest_change.append((tx_out.nValue, addr))
if len(largest_change) == MAX_VISIBLE_CHANGE:
largest_change = sorted(largest_change, key=lambda x: x[0], reverse=True)
continue
@ -720,12 +677,9 @@ class ApproveTransaction(UserAuthorizedAction):
continue # too small
largest.pop(-1)
if outp.is_change:
ret = (here, self.chain.render_address(tx_out.scriptPubKey))
else:
rendered, _ = self.render_output(tx_out)
ret = (here, rendered)
largest.insert(keep, ret)
rendered, dest = self.render_output(tx_out)
largest.insert(keep, (here, dest if outp.is_change else rendered))
# foreign outputs (soon to be other people's coins)
visible_out_sum = 0
@ -764,11 +718,12 @@ class ApproveTransaction(UserAuthorizedAction):
msg.write('%s %s\n\n' % self.chain.render_value(total_change - visible_change_sum))
def sign_transaction(psbt_len, flags=0x0, psbt_sha=None):
def sign_transaction(psbt_len, flags=0x0, psbt_sha=None, input_method="usb", offset=TXN_INPUT_OFFSET):
# transaction (binary) loaded into PSRAM already, checksum checked
UserAuthorizedAction.check_busy(ApproveTransaction)
UserAuthorizedAction.active_request = ApproveTransaction(
psbt_len, flags, psbt_sha=psbt_sha, input_method="usb",
psbt_len, flags, psbt_sha=psbt_sha, input_method=input_method,
offset=offset
)
# kill any menu stack, and put our thing at the top
@ -815,13 +770,19 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
# USB case - user can choose whether to attempt finalization
is_complete = finalize
if psbt.por322:
# network txn strips PSBT BIP-32 with paths with pubkey required for verification
# overrides --finalize from USB
# disable pushTX for BIP-322
is_complete = False
with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
if is_complete:
txid = psbt.finalize(psram)
noun = "Finalized TX ready for broadcast"
else:
psbt.serialize(psram)
noun = "Partly Signed PSBT"
noun = "Signed BIP-322 PSBT" if psbt.por322 else "Partly Signed PSBT"
txid = None
data_len = psram.tell()
@ -839,6 +800,10 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
msg = noun + " shared via USB."
title = "PSBT Signed"
elif input_method == "kt":
first_time = False
title = "PSBT Signed"
if txid and await try_push_tx(data_len, txid, data_sha2):
# go directly to reexport menu after pushTX
first_time = False
@ -857,8 +822,6 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
ch = KEY_QR
elif input_method == "nfc":
ch = KEY_NFC
elif input_method == "kt":
ch = 't'
else:
# SD/VDisk
ch = {"force_vdisk": input_method == "vdisk", "slot_b": slot_b}
@ -890,7 +853,7 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
elif ch == KEY_QR:
here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len)
msg = txid or 'Partly Signed PSBT'
msg = txid or noun
try:
if len(here) > 920:
# too big for simple QR - use BBQr instead
@ -916,8 +879,9 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
elif (ch == 't') and not is_complete:
# they might want to teleport it, but only if we have PSBT
# there is no need to teleport PSBT if txn is already complete & ready to be broadcast
# updated PSBT is at TXN_OUTPUT_OFFSET (at TXN_INPUT_OFFSET is PSBT that is NOT updated)
from teleport import kt_send_psbt
ok = await kt_send_psbt(psbt, data_len)
ok = await kt_send_psbt(psbt, data_len, psbt_offset=TXN_OUTPUT_OFFSET)
if ok is None:
title = "Failed to Teleport"
else:
@ -1065,7 +1029,7 @@ async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_
return msg
async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False):
# sign a PSBT file found on a MicroSD card
# - or from VirtualDisk (mk4)
@ -1595,6 +1559,9 @@ class TXExplorer:
self.qr_msgs = []
self.title = None
def can_goto_idx(self):
return self.max_items > 1
@classmethod
async def start(cls, user_auth_action):
rv = [
@ -1607,6 +1574,7 @@ class TXExplorer:
def make_ux_msg(self, offset, count):
from glob import dis
dis.fullscreen('Wait...')
esc = "4"+KEY_QR
rv = ""
qrs = []
change = []
@ -1615,18 +1583,29 @@ class TXExplorer:
rv += item
dis.progress_sofar(idx-offset+1, count)
rv += 'Press RIGHT to see next group'
hints = []
if end < self.max_items:
hints.append('RIGHT to see next group')
esc += KEY_RIGHT + "9"
if offset:
rv += ', LEFT to go back'
hints.append('LEFT to go back')
esc += KEY_LEFT + "7"
rv += ", (2) to go to index"
if self.can_goto_idx():
hints.append("(2) to go to index")
esc += "2"
if not version.has_qwerty:
# Q has hint key
rv += ", (4) to show QR code"
rv += ('. %s to quit.' % X)
hints.append("(4) to show QR code")
return rv, qrs, change, end
if hints:
rv += 'Press ' + ', '.join(hints)
rv += ('. %s to quit.' % X)
else:
rv += 'Press %s to quit.' % X
return rv, qrs, change, end, esc
async def explore(self, *a):
@ -1635,11 +1614,10 @@ class TXExplorer:
# - shows all inputs: utxo amount and address, txid & tx index.
start = 0
msg, addrs, change, end = self.make_ux_msg(start, self.n)
msg, addrs, change, end, esc = self.make_ux_msg(start, self.n)
while True:
ch = await ux_show_story(msg, title=self.title, escape='2479'+KEY_RIGHT+KEY_LEFT+KEY_QR,
hint_icons=KEY_QR)
ch = await ux_show_story(msg, title=self.title, hint_icons=KEY_QR, escape=esc)
if ch == 'x':
del msg
return
@ -1661,17 +1639,16 @@ class TXExplorer:
else:
# go forwards
start += self.n
elif ch == "2":
elif (ch == "2") and (self.max_items > 1):
max_v = self.max_items - 1
res = await ux_enter_number("Start Idx (0-%d):" % max_v, max_value=max_v,
can_cancel=True)
res = await ux_enter_number("Start Idx (0-%d):" % max_v, max_value=max_v)
if res is None: continue
start = res
else:
# nothing changed - do not recalc msg
continue
msg, addrs, change, end = self.make_ux_msg(start, self.n)
msg, addrs, change, end, esc = self.make_ux_msg(start, self.n)
class TXOutExplorer(TXExplorer):
@ -1722,9 +1699,11 @@ class TXInpExplorer(TXExplorer):
item += "=== UTXO ===\n\n%s %s\n\n%s\n\n" % (val, unit, spk)
if addr:
item += show_single_address(addr) + "\n\n"
item += "Address Format: %s\n\n" % chains.addr_fmt_str(inp.addr_fmt)
qr_items.append(addr)
if inp.addr_fmt is not None:
item += "Address Format: %s\n\n" % chains.addr_fmt_str(inp.addr_fmt)
if self.user_auth_action.psbt.txn_version >= 2:
has_rtl = inp.has_relative_timelock(txin)
if has_rtl:
@ -1742,13 +1721,19 @@ class TXInpExplorer(TXExplorer):
ws = self.user_auth_action.psbt.wif_store
our = [inp.required_key] if isinstance(inp.required_key, bytes) else inp.required_key
psbt_item += "Our key%s:\n\n" % ("s" if len(our) > 1 else "")
wif_note = "(WIF Store)"
for k in our:
pth = inp.subpaths[k]
ws_note = "\n(WIF Store)" if (ws and k in ws) else ""
psbt_item += "%s:\n%s%s\n\n" % (keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0])),
b2a_hex(k).decode(), ws_note)
pubkey = b2a_hex(k).decode()
pth = inp.subpaths.get(k)
note = ""
if pth:
label = keypath_to_str(pth, prefix="%s/" % xfp2str(pth[0]))
if ws and k in ws:
note = "\n" + wif_note
psbt_item += "%s:\n%s%s\n\n" % (label, pubkey, note)
else:
psbt_item += "%s\n%s\n\n" % (pubkey, wif_note)
M = None
if inp.is_multisig:
ks_coord = inp.witness_script or inp.redeem_script
if ks_coord:
@ -1768,7 +1753,7 @@ class TXInpExplorer(TXExplorer):
if pk in inp.part_sigs:
done.append(xfp2str(pth[0]))
if inp.fully_signed or (M and (len(done) >= M)):
if inp.fully_signed:
psbt_item += "Input fully signed.\n\n"
else:
psbt_item += "Already signed:\n"
@ -1783,7 +1768,7 @@ class TXInpExplorer(TXExplorer):
1 | 0x80: "ALL|ANYONECANPAY",
2 | 0x80: "NONE|ANYONECANPAY",
3 | 0x80: "SINGLE|ANYONECANPAY",
}[inp.sighash]
}.get(inp.sighash, "0x%02x (non-standard)" % inp.sighash)
if psbt_item:
psbt_item = "=== PSBT ===\n\n" + psbt_item

View File

@ -49,7 +49,7 @@ def render_backup_contents(bypass_tmp=False):
if sv.mode == 'words':
ADD('mnemonic', bip39.b2a_words(sv.raw))
if sv.mode == 'master':
elif sv.mode == 'master':
ADD('bip32_master_key', b2a_hex(sv.raw))
ADD('chain', chain.ctype)
@ -76,7 +76,12 @@ def render_backup_contents(bypass_tmp=False):
current_tmp = pa.tmp_value[:]
pa.tmp_value = None
# we also need correct settings from main seed
nv = stash.SecretStash.encode(seed_phrase=sv.raw)
if sv.mode == 'words':
nv = stash.SecretStash.encode(seed_phrase=sv.raw)
else:
assert sv.mode == "xprv"
nv = stash.SecretStash.encode(xprv=sv.node)
settings.set_key(nv)
settings.load()
stash.blank_object(nv)
@ -201,6 +206,13 @@ def restore_from_dict_ll(vals, raw):
k = key[8:]
if k == 'bkpw':
# never import a cached backup password from a backup file.
# write-side (render_backup_contents) strips bkpw, so a present
# value means a tampered/crafted file trying to fixate the
# password used for all FUTURE backups - drop it.
continue
if k == 'sd2fa':
# do NOT restore sd2fa as SD card can be lost or damaged
# new version of firmware 5.1.3+ will not back sd2fa
@ -577,7 +589,11 @@ async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
# give them a menu to pick from, and start picking
if usb:
# we're not originating from a menu
words = await seed.WordNestMenu.get_n_words(12)
words = await seed.WordNestMenu.get_n_words(num_pw_words)
if len(words) != num_pw_words:
seed.WordNestMenu.pop_all()
return
await done(words)
else:
m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)

View File

@ -138,12 +138,15 @@ async def batt_idle_logout():
# - even before login
import glob
from uasyncio import sleep_ms
from glob import settings, dis
from glob import settings, dis, SCAN
import utime
while True:
await sleep_ms(20000) # 20 seconds
if SCAN.busy_scanning:
continue
if get_batt_level() is None:
# on USB power
continue

View File

@ -54,7 +54,7 @@ def calc_num_qr(char_capacity, char_len, split_mod):
if char_len > actual:
need += 1
# Challenge: the final QR might have just a a few chars in it, if we redistribute
# Challenge: the final QR might have just a few chars in it, if we redistribute
# the data into the other parts, then each QR can have more forward error correction
# and be more robust. Must respect split_mod alignment tho.
level = ceil(char_len / need)
@ -439,5 +439,18 @@ class BBQrPsramStorage(BBQrStorage):
from glob import PSRAM
return PSRAM.read_at(0, self.final_size)
def finalize(self):
self._finalize()
if self.hdr.encoding == 'Z':
self.zlib_decompress()
# PSBT-typed BBQrs end up at PSRAM[0..size]
# skip a redundant PSRAM->heap->PSRAM round-trip
if self.hdr.file_type == 'P':
return self.hdr.file_type, self.final_size, 'PSRAM'
return self.hdr.file_type, self.final_size, self.get_buffer()
# EOF

9
shared/block_height.py Normal file
View File

@ -0,0 +1,9 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# AUTO-generated.
#
# Updated: 2026-06-19 14:13:23 UTC
BLOCK_HEIGHT = 932301
# EOF

View File

@ -10,6 +10,7 @@
# - "hobbled" refers to less-than full control over Coldcard, even though you have main PIN
#
import gc, chains, version, ngu, web2fa, bip39, re
from ubinascii import hexlify as b2a_hex
from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
from glob import settings, dis
@ -122,7 +123,10 @@ class SpendingPolicy(dict):
for idx, txo in psbt.output_iter():
out = psbt.outputs[idx]
if not out.is_change: # ignore change
addr = c.render_address(txo.scriptPubKey)
try:
addr = c.render_address(txo.scriptPubKey)
except ValueError:
addr = str(b2a_hex(txo.scriptPubKey), 'ascii')
if addr not in wl:
raise SpendPolicyViolation("whitelist: " + addr)
@ -232,7 +236,12 @@ class CCCFeature:
@classmethod
def words_check(cls, words):
# Test if words provided are right
enc = seed_words_to_encoded_secret(words)
try:
# a2b_words with checksum check
enc = seed_words_to_encoded_secret(words)
except:
return False
exp = cls.get_encoded_secret()
return enc == exp
@ -585,11 +594,12 @@ class SPAddrWhitelist(MenuSystem):
if choice == KEY_CANCEL:
return
elif choice == KEY_NFC:
addr = await NFC.read_address()
if not addr:
res = await NFC.read_address()
if not res:
# error already displayed in nfc.py
return
_, addr, _ = res
await self.add_addresses([addr])
return
@ -651,11 +661,12 @@ class SPAddrWhitelist(MenuSystem):
async def add_addresses(self, more_addrs):
# add new entries, if unique; preserve ordering
addrs = self.policy.get('addrs', [])
# - work on a copy and check the limit *before* committing: the list
# from get('addrs') is the live, settings-backed one
addrs = list(self.policy.get('addrs', []))
new = []
for a in more_addrs:
if a not in addrs:
addrs.append(a)
if a not in addrs and a not in new:
new.append(a)
if not new:
@ -663,10 +674,10 @@ class SPAddrWhitelist(MenuSystem):
'\n\n'.join(show_single_address(a) for a in more_addrs))
return
if len(addrs) > MAX_WHITELIST:
if len(addrs) + len(new) > MAX_WHITELIST:
return await self.maxed_out()
self.policy.update_policy_key(addrs=addrs)
self.policy.update_policy_key(addrs=addrs + new)
self.update_contents()
if len(new) > 1:
@ -746,15 +757,11 @@ class SpendingPolicyMenu(MenuSystem):
# Looks decent on both Q and Mk4...
was = self.policy.get('mag', 0)
val = await ux_enter_number('Transaction Max:', max_value=int(1e8),
can_cancel=True, value=(was or ''))
value=(was or ''))
if val is None: return
args = dict(mag=val)
if (val is None) or (val == was):
msg = "Did not change"
val = was
else:
msg = "You have set the"
unchanged = False
msg = "Did not change" if val == was else "You have set the"
if not val:
msg = "No check for maximum transaction size will be done. "
@ -1089,7 +1096,7 @@ is locked into a special mode that restricts seed access, backups, settings and
First step is to define a new PIN code that is used when you want to bypass or \
disable this feature.
''',
title="Spending Policy")
title="Spending Policy" if version.has_qwerty else "Spend Policy")
if ch != 'y':
# just a tourist

View File

@ -5,14 +5,15 @@
import ngu
from uhashlib import sha256
from ubinascii import hexlify as b2a_hex
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR
from public_constants import AF_BARE_PK, AF_CLASSIC, AF_P2WPKH, AF_P2TR
from public_constants import AF_P2SH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH
from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT
from block_height import BLOCK_HEIGHT
from serializations import hash160, ser_compact_size, disassemble
from ucollections import namedtuple
from opcodes import OP_RETURN, OP_1, OP_16
# DO NOT CHANGE ORDER! PickAddrFmtMenu.__init__ expects correct order
SINGLESIG_AF = (AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH)
# See SLIP 132 <https://github.com/satoshilabs/slips/blob/master/slip-0132.md>
@ -253,7 +254,7 @@ class ChainsBase:
return ngu.codecs.b58_encode(cls.b58_script + script[2:2+20])
# segwit v0 (P2WPKH, P2WSH)
if script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]:
if ll in (22, 34) and script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]:
return ngu.codecs.segwit_encode(cls.bech32_hrp, script[0], script[2:])
# segwit v1 (P2TR) and later segwit version
@ -264,56 +265,40 @@ class ChainsBase:
@classmethod
def op_return(cls, script):
# returns decoded string op return data if script is op return otherwise None
gen = disassemble(script)
script_type = next(gen)
if OP_RETURN not in script_type:
return
try:
gen = disassemble(script)
item, opcode = next(gen)
except (StopIteration, ValueError):
return None
if opcode != OP_RETURN:
return None
try:
data = next(gen)[0]
if data:
return data
except StopIteration:
pass
try:
data, opcode = next(gen)
except StopIteration:
return b"" # bare OP_RETURN
return b""
try:
next(gen)
return None # extra ops/pushes -> raw script display
except StopIteration: pass
@classmethod
def possible_address_fmt(cls, addr):
# Given a text (serialized) address, return what
# address format applies to the address, but
# for AF_P2SH case, could be: AF_P2SH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH. .. we don't know
hrp = cls.bech32_hrp + "1"
if addr.startswith(hrp):
if addr.startswith(hrp+'p'):
# segwit v1 (any ver=1 script or address, but for now just taproot...)
return AF_P2TR
elif addr.startswith(hrp+'q'):
# segwit v0
return AF_P2WPKH if len(addr) < 55 else AF_P2WSH
return 0
try:
raw = ngu.codecs.b58_decode(addr)
except ValueError:
# not base58, not an error
return 0
if raw[0] == cls.b58_addr[0]:
return AF_CLASSIC
if raw[0] == cls.b58_script[0]:
return AF_P2SH
return 0
except ValueError:
return None
if isinstance(data, bytes):
return data
if data is None and opcode == 0:
return b"" # OP_RETURN OP_0
return None
class BitcoinMain(ChainsBase):
# see <https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp#L140>
ctype = 'BTC'
name = 'Bitcoin Mainnet'
ccc_min_block = 939464 # Mar 5/2026
ccc_min_block = BLOCK_HEIGHT
slip132 = {
AF_CLASSIC: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
@ -465,6 +450,7 @@ def addr_fmt_label(addr_fmt):
def addr_fmt_str(addr_fmt):
# Short string codes used for address format (industry standard)
return {AF_CLASSIC: "p2pkh",
AF_BARE_PK: "p2pk",
AF_P2SH: "p2sh",
AF_P2TR: "p2tr",
AF_P2WPKH: "p2wpkh",

View File

@ -51,9 +51,7 @@ def decode_utf_16_le(s):
'''
def read_var64(f):
'''
Decode their silly 64-bit encoding.
'''
# Decode their silly 64-bit encoding.
first = ord(f.read(1))
if first < 128:
return first
@ -100,7 +98,7 @@ def check_file_headers(f):
# assume f is seekable
fh = FileHeader.read(f)
if not fh.has_good_magic:
if not fh.has_good_magic():
raise ValueError("Bad magic bytes")
# read only first header
@ -113,22 +111,21 @@ def check_file_headers(f):
if sh.size > 10000:
raise ValueError("Second header too big")
# capture this spot
# TODO 'data_start' unused
data_start = f.tell() # expect 0x20
# FileHeader.read() always reads exactly calcsize('<6sBBL') = 12 bytes
# SectionHeader.read() always reads exactly calcsize('<QQL') = 20 bytes
# after those two calls, f.tell() is always start_pos + 32
# assert f.tell() == 0x20 # expect 0x20
try:
f.seek(sh.offset, 1)
th = f.read(sh.size)
if len(th) != sh.size:
raise IndexError("Truncated file?")
assert len(th) == sh.size, "Truncated file?"
# Look for properties about compression. this could be
# faked-out but good enough for now
if b'\x24\x06\xf1\x07\x01' not in th:
raise RuntimeError("Not marked as AES+SHA encrypted?")
assert b'\x24\x06\xf1\x07\x01' in th, "Not marked as AES+SHA encrypted?"
except Exception as e:
raise ValueError("Confused file? %s" % e.message)
raise ValueError("Confused file? %s" % e)
if masked_crc(th) != sh.crc:
raise ValueError("Trailing header has wrong CRC")
@ -174,7 +171,6 @@ class FileHeader(object):
def actual_crc(self):
return masked_crc(self.bits)
class SectionHeader(namedtuple('SectionHeader', ['offset', 'size', 'crc' ])):
@ -213,6 +209,7 @@ class SectionHeader(namedtuple('SectionHeader', ['offset', 'size', 'crc' ])):
def actual_crc(self):
return masked_crc(self.bits)
class Builder(object):
def __init__(self, password=None, salt_len=16, iv_len=16, rounds_pow=13, progress_fcn=None):
self.rounds_pow = rounds_pow # standard is 19, 16 and 17 work fine

View File

@ -11,6 +11,15 @@ from bbqr import TYPE_LABELS
from utils import decode_bip21_text
def decode_qr_text(got):
if isinstance(got, str):
return got
try:
return got.decode()
except UnicodeError:
raise QRDecodeExplained('UTF-8 decode failed')
def decode_seed_qr(data):
# SeedQR: 4 digit groups of index into word list
parts = [data[pos:pos + 4] for pos in range(0, len(data), 4)]
@ -39,6 +48,8 @@ def decode_secret(got):
# - xprv / tprv
# - words (either full or prefixes, case insensitive)
# - SeedQR (github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/README.md)
# - word lists are NOT BIP-39-checksum-validated here. Callers that
# require a valid seed must run bip39.a2b_words(...)
if len(got) > 300:
raise ValueError("Too big.")
@ -51,7 +62,7 @@ def decode_secret(got):
# xprv or tprv: private key import for sure
# - verify checksum is right
try:
raw = ngu.codecs.b58_decode(got)
ngu.codecs.b58_decode(got)
except:
raise ValueError('corrupt xprv?')
@ -63,7 +74,7 @@ def decode_secret(got):
kp, testnet, compressed = decode_wif(got)
return 'wif', (got, kp, compressed, testnet)
except: pass
taste = got.strip().lower()
if taste.isdigit():
@ -108,11 +119,8 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
return got.decode()
if ty == 'P':
# may already be in PSRAM, avoid a copy here
from glob import PSRAM
if PSRAM.is_at(got, 0):
got = 'PSRAM' # see qr_psbt_sign()
# `got` is the literal 'PSRAM' from BBQrPsramStorage when data already there
# otherwise it's real bytes
return 'psbt', (None, final_size, got)
elif ty == 'T':
@ -120,9 +128,10 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
elif ty == 'U':
# continue thru code below for TEXT
pass
got = decode_qr_text(got)
elif ty == 'J':
got = decode_qr_text(got)
what = "json"
if "msg" in got:
what = "smsg"
@ -187,12 +196,7 @@ def decode_short_text(got):
# - if bad checksum on bitcoin addr, we treat as text... since might be
# return: what-it-is, (tuple)
if not isinstance(got, str):
# decode utf-8
try:
got = got.decode()
except UnicodeError:
raise QRDecodeExplained('UTF-8 decode failed')
got = decode_qr_text(got)
# might be a PSBT?
if len(got) > 100:
@ -227,10 +231,11 @@ def decode_short_text(got):
cc_ms_pat = r"[0-9a-fA-F]+\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]+"
rgx = ure.compile(cc_ms_pat)
# go line by line and match above, once 2 matches observed - considered multisig
# important to not use ure.search for big strings (can run out of stack)
# important to not use ure.search for big strings (can run out of stack);
# a real line here is a "<8-hex xfp>: <xpub>" key (~121 chars)
c = 0 # match count
for l in got.split("\n"):
if rgx.search(l):
if len(l) <= 150 and rgx.search(l):
c += 1
if c > 1:
return 'multi', (got,)

View File

@ -124,8 +124,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
msg = "Password Index?" if picked == 7 else "Index Number?"
index = await ux_enter_bip32_index(msg, unlimited=settings.get("b85max", False))
if index is None:
return
if index is None: return
dis.fullscreen("Working...")
new_secret, width, s_mode, path = bip85_derive(picked, index)
@ -292,7 +291,7 @@ async def password_entry(*args, **kwargs):
while True:
the_ux.pop()
index = await ux_enter_bip32_index("Password Index?", can_cancel=True)
index = await ux_enter_bip32_index("Password Index?")
if index is None:
break

View File

@ -20,7 +20,7 @@ from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu
from wif import WIFStore
from wif import WIFStoreMenu
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
@ -425,7 +425,7 @@ AdvancedNormalMenu = [
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem("Spending Policy", menu=SpendingPolicySubMenu,shortcut='s',predicate=has_real_secret),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('WIF Store', menu=WIFStore.make_menu),
MenuItem('WIF Store', menu=WIFStoreMenu.make),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
]
@ -488,7 +488,7 @@ NormalSystem = [
MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
predicate=lambda: version.has_qwerty and settings.get("secnap", False)),
MenuItem('Type Passwords', f=password_entry, shortcut='t',
MenuItem('Type Passwords', f=password_entry, shortcut='e',
predicate=lambda: settings.get("emu", False) and has_secrets()),
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',
predicate=lambda: settings.master_get('seedvault') and has_secrets()),
@ -552,7 +552,7 @@ HobbledAdvancedMenu = [
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
MenuItem('WIF Store', menu=WIFStore.make_menu, predicate=sssp_related_keys),
MenuItem('WIF Store', menu=WIFStoreMenu.make, predicate=sssp_related_keys),
MenuItem('Show %s Version' % ("Firmware" if version.has_qwerty else "FW"), f=show_version),
MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
]

View File

@ -5,7 +5,7 @@
# Unattended signing of transactions and messages, subject to a set of rules.
#
import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path, keypath_to_str
from utils import cleanup_payment_address
from pincodes import AE_LONG_SECRET_LEN
from stash import blank_object
@ -656,6 +656,15 @@ class HSMPolicy:
assert not glob.hsm_active
glob.hsm_active = self
# HSM is the locked-down operating mode: shut down peripherals
# that enlarge the USB-stack interaction surface.
# - VDisk: MSC bulk OUT and HID OUT share the STM32 OTG_FS RX FIFO;
# under load this can wedge the HID OUT endpoint permanently
if glob.VD is not None:
glob.VD.shutdown()
if glob.NFC is not None:
glob.NFC.shutdown()
self.start_time = utime.ticks_ms()
if new_file:
@ -873,9 +882,6 @@ class HSMPolicy:
# do this super early so always cleared even if other issues
local_ok = self.consume_local_code(psbt_sha)
if not self.rules:
raise ValueError("no txn signing allowed")
# reject anything with warning, probably
if psbt.warnings:
if self.warnings_ok:
@ -883,6 +889,32 @@ class HSMPolicy:
else:
raise ValueError("has %d warning(s)" % len(psbt.warnings))
if psbt.por322:
if not self.msg_paths:
raise ValueError("Message signing not permitted")
for inp in psbt.inputs:
if not inp.required_key:
continue
if inp.is_multisig:
paths = [
keypath_to_str(inp.subpaths[pk])
for pk in inp.required_key
if pk in inp.subpaths
]
else:
paths = [keypath_to_str(inp.subpaths[inp.required_key])]
if not any(match_deriv_path(self.msg_paths, p) for p in paths):
raise ValueError("Message signing not enabled for that path")
self.approve(log, "BIP-322 message signing allowed")
return 'y'
if not self.rules:
raise ValueError("no txn signing allowed")
# See who has entered creditials already (all must be valid).
users = []
for u, (token, counter) in auth.items():

View File

@ -59,7 +59,7 @@ class ApproveHSMPolicy(UserAuthorizedAction):
msg = '''Last chance. You are defining a new policy which \
allows the Coldcard to sign specific transactions without any further user approval.\n\n\
Policy hash:\n%s\n\n
Press %s to save policy and enable HSM mode.''' % (self.policy.hash(), confirm_char)
Press (%s) to save policy and enable HSM mode.''' % (self.policy.hash(), confirm_char)
ch = await ux_show_story(msg, title=self.title,
escape='x'+confirm_char, strict_escape=True)

View File

@ -134,7 +134,7 @@ class FullKeyboard(NumpadBase):
if self._history[kn] == NUM_SAMPLES:
self.is_pressed[kn] = 1
new_presses.add(kn)
elif self._history[i] == 0:
elif self._history[kn] == 0:
self.is_pressed[kn] = 0
self._history[kn] = 0

View File

@ -734,7 +734,9 @@ class Display:
lines = self.handle_qr_msg(msg, max_lines=True)
self.draw_qr_lines(lines, False)
self.draw_qr_idx_hint(idx_hint)
if idx_hint:
self.draw_qr_idx_hint(idx_hint)
self.show()
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,

View File

@ -6,6 +6,7 @@ freeze_as_mpy('', [
'address_explorer.py',
'auth.py',
'backups.py',
'block_height.py',
'callgate.py',
'ccc.py',
'chains.py',
@ -13,8 +14,6 @@ freeze_as_mpy('', [
'compat7z.py',
'countdowns.py',
'descriptor.py',
'dev_helper.py',
'display.py',
'drv_entro.py',
'exceptions.py',
'export.py',
@ -48,7 +47,6 @@ freeze_as_mpy('', [
'selftest.py',
'serializations.py',
'sffile.py',
'ssd1306.py',
'stash.py',
'tapsigner.py',
'trick_pins.py',

View File

@ -1,5 +1,6 @@
# Mk4 only files; would not be needed on Mk3 or earlier.
freeze_as_mpy('', [
'display.py',
'hsm.py',
'hsm_ux.py',
'mempad.py',

View File

@ -290,7 +290,7 @@ class MenuSystem:
dis.clear()
cursor_y = None
for n in range(self.ypos+PER_M+1):
for n in range(PER_M+1):
real_idx = n+self.ypos
if real_idx >= self.count: break

View File

@ -179,14 +179,16 @@ async def msg_sign_ux_get_subpath(addr_fmt):
purpose = chains.af_to_bip44_purpose(addr_fmt)
chain_n = chains.current_chain().b44_cointype
acct = await ux_enter_bip32_index('Account Number:') or 0
acct = await ux_enter_bip32_index('Account Number:')
if acct is None: return
ch = await ux_show_story(title="Change?",
msg="Press (0) to use internal/change address,"
" %s to use external/receive address." % OK, escape="0")
change = 1 if ch == '0' else 0
idx = await ux_enter_bip32_index('Index Number:') or 0
idx = await ux_enter_bip32_index('Index Number:')
if idx is None: return
return "m/%dh/%dh/%dh/%d/%d" % (purpose, chain_n, acct, change, idx)
@ -260,13 +262,13 @@ def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_
return sig_nice
def validate_text_for_signing(text, only_printable=True):
def validate_text_for_signing(text, allow_tab_nl=False):
# Check for some UX/UI traps in the message itself.
# - messages must be short and ascii only. Our charset is limited
# - too many spaces, leading/trailing can be an issue
# MSG_MAX_SPACES = 4 # impt. compared to -=- positioning
result = to_ascii_printable(text, only_printable=only_printable)
text = str(text, "ascii") # handle memoryview coming from USB
result = to_ascii_printable(text, allow_tab_nl=allow_tab_nl)
length = len(result)
assert length >= 2, "msg too short (min. 2)"
@ -313,6 +315,7 @@ def parse_msg_sign_request(data):
if text is None:
raise AssertionError("MSG required")
subpath = data_dict.get("subpath", subpath)
assert isinstance(subpath, str), "subpath"
addr_fmt = data_dict.get("addr_fmt", addr_fmt)
is_json = True
except ValueError:
@ -331,11 +334,13 @@ def parse_msg_sign_request(data):
addr_fmt = addr_fmt_from_subpath(subpath)
if not subpath:
subpath = chains.STD_DERIVATIONS[addr_fmt]
subpath = subpath.format(
coin_type=chains.current_chain().b44_cointype,
account=0, change=0, idx=0
)
try:
subpath = chains.STD_DERIVATIONS[addr_fmt]
subpath = subpath.format(
coin_type=chains.current_chain().b44_cointype,
account=0, change=0, idx=0
)
except: pass
return text, subpath, addr_fmt, is_json
@ -408,9 +413,10 @@ async def ux_sign_msg(txt, approved_cb=None, kill_menu=True):
text, af = item.arg
subpath = await msg_sign_ux_get_subpath(af)
if subpath is None: return
await approve_msg_sign(text, subpath, af, approved_cb=approved_cb,
kill_menu=kill_menu, only_printable=False)
kill_menu=kill_menu, allow_tab_nl=True)
# pick address format
rv = [

View File

@ -248,7 +248,7 @@ class MultisigWallet(WalletABC):
def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmts=None, name=None):
# yield MS wallets we know about, that match at least right M,N if known.
# - this is only place we should be searching this list, please!!
# addr_fmts: list of address formats we're intersted in
# addr_fmts: list of address formats we're interested in
# name: string ms wallet name
lst = settings.get('multisig', [])
@ -424,6 +424,11 @@ class MultisigWallet(WalletABC):
# do not allow to import multi if sortedmulti with the same set of keys
# already imported and vice-versa
return None, ["BIP-67 clash"], 1
elif not self.bip67 and self.xpubs != c.xpubs:
# multi(2,A,B) and multi(2,B,A) are consensus-different scripts;
# treat as duplicates -- don't allow either if a same-keys variant
# in a different order is already enrolled
return None, ["key order"], 1
elif self.name == c.name:
return None, [], 1
else:
@ -583,7 +588,7 @@ class MultisigWallet(WalletABC):
found_pk = node.pubkey()
# Document path(s) used. Not sure this is useful info to user tho.
# - Do not show what we can't verify: we don't really know the hardeneded
# - Do not show what we can't verify: we don't really know the hardened
# part of the path from fingerprint to here.
here = '[%s]' % xfp2str(xfp)
if dp != len(path):
@ -1082,11 +1087,11 @@ class MultisigWallet(WalletABC):
story = 'Update NAME only of existing multisig wallet?'
elif num_dups and isinstance(diff_items, list):
# failures only
story = "Duplicate wallet."
story = "Duplicate wallet. "
if diff_items:
story += diff_items[0]
else:
story += ' All details are the same as existing!'
story += 'All details are the same as existing!'
is_dup = True
elif diff_items:
# Concern here is overwrite when similar, but we don't overwrite anymore, so
@ -1566,7 +1571,8 @@ P2WSH:
if ch != "y":
return
acct = await ux_enter_bip32_index('Account Number:') or 0
acct = await ux_enter_bip32_index('Account Number:')
if acct is None: return
def render(acct_num):
sign_der = None
@ -1779,7 +1785,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
secret, ccc_ms_count = for_ccc
# Always include 2 keys from CCC: own master (key A) and key C
# - force them to same derivation.
acct = await ux_enter_bip32_index('CCC Account Number:') or 0
acct = await ux_enter_bip32_index('CCC Account Number:')
if acct is None: return
dis.fullscreen("Wait...")
a = add_own_xpub(chain, acct, addr_fmt) # master: key A
@ -1804,7 +1811,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
ch = await ux_show_story("Add current Coldcard with above XFP ?",
title="[%s]" % xfp2str(my_xfp))
if ch == "y":
acct = await ux_enter_bip32_index('Account Number:') or 0
acct = await ux_enter_bip32_index('Account Number:')
if acct is None: return
dis.fullscreen("Wait...")
xpubs.append(add_own_xpub(chain, acct, addr_fmt))
num_mine += 1
@ -1819,10 +1827,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False,
M = 2
else:
# pick useful M value to start
M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
if not M:
await ux_dramatic_pause('Aborted.', 2)
return # user cancel
M = await ux_enter_number("How many need to sign?(M)", N)
if M is None: return
dis.fullscreen("Wait...")

View File

@ -2,7 +2,7 @@
#
# ndef.py -- NDEF records: making them and parsing them.
#
# - see ../docs/nfc-on-coldcard.md for background.
# - see ../docs/nfc-coldcard.md for background.
# - cross platform file
#
from struct import pack, unpack

View File

@ -226,10 +226,13 @@ class NFCHandler:
self.set_rf_disable(1)
async def share_loop(self, n, **kws):
# Keep one fully-written tag image live until the user exits. Some
# phones perform multiple probes while deciding if a tag is NDEF.
await self.big_write(n.bytes())
while 1:
done = await self.share_start(n, **kws)
if done:
# do not wipe if we are not done
aborted = await self.ux_animation(exit_after_activity=False, **kws)
if aborted:
await self.wipe(kws.get("is_secret", False))
break
@ -401,8 +404,9 @@ class NFCHandler:
self.write_dyn(GPO_CTRL_Dyn, 0x01) # GPO_EN
self.read_dyn(IT_STS_Dyn) # clear interrupt
async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=None,
is_secret=False):
async def ux_animation(self, allow_enter=True, prompt=None, line2=None,
is_secret=False, exit_after_activity=True,
min_delay=1000):
# Run the pretty animation, and detect both when we are written, and/or key to exit/abort.
# - similar when "read" and then removed from field
# - return T if aborted by user
@ -428,7 +432,6 @@ class NFCHandler:
# (ms) How long to wait after RF field comes and goes
# - user can press OK during this period if they know they are done
min_delay = (3000 if write_mode else 1000)
while 1:
if dis.has_lcd:
@ -467,7 +470,7 @@ class NFCHandler:
aborted = False
break
if last_activity:
if exit_after_activity and last_activity:
dt = utime.ticks_diff(utime.ticks_ms(), last_activity)
if dt >= min_delay:
# They acheived some RF activity and then nothing for some time, so
@ -484,14 +487,14 @@ class NFCHandler:
# - assumpting is people know what they are scanning
# - x key to abort early, but also self-clears
await self.big_write(ndef_obj.bytes())
return await self.ux_animation(False, **kws)
return await self.ux_animation(**kws)
async def start_nfc_rx(self, **kws):
# Pretend to be a big warm empty tag ready to be stuffed with data
await self.big_write(ndef.CC_WR_FILE)
# wait until something is written
aborted = await self.ux_animation(True, **kws)
aborted = await self.ux_animation(min_delay=3000, **kws)
if aborted: return
# read CCFILE area (header)
@ -618,7 +621,7 @@ class NFCHandler:
# it's a txn, and we wrote as hex
data = a2b_hex(data)
else:
assert data[2:8] == bytes(6)
assert data[1:4] == bytes(3)
sha = ngu.hash.sha256s(data)
await self.share_signed_txn(txid, data, len(data), sha)
elif ext == 'psbt':
@ -747,10 +750,11 @@ class NFCHandler:
async def verify_address_nfc(self):
# Get an address or complete bip-21 url even and search it... slow.
_, addr, args = await self.read_address()
if addr:
from ownership import OWNERSHIP
await OWNERSHIP.search_ux(addr, args)
res = await self.read_address()
if not res: return
_, addr, args = res
from ownership import OWNERSHIP
await OWNERSHIP.search_ux(addr, args)
async def read_extended_private_key(self):
f = lambda x: x.decode().strip() if b"prv" in x else None
@ -774,15 +778,17 @@ class NFCHandler:
if not data: return
winner = None
for urn, msg, meta in ndef.record_parser(data):
msg = bytes(msg)
try:
r = func(msg)
if r is not None:
winner = r
break
except:
pass
try:
for urn, msg, meta in ndef.record_parser(data):
msg = bytes(msg)
try:
r = func(msg)
if r is not None:
winner = r
break
except:
pass
except Exception: pass # dont crash when given garbage
if not winner:
await ux_show_story(fail_msg)

View File

@ -10,10 +10,11 @@ from ux_q1 import QRScannerInteraction
from actions import goto_top_menu
from glob import settings, dis
from files import CardMissingError, needs_microsd, CardSlot
from public_constants import MSG_SIGNING_MAX_LENGTH
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
from utils import problem_file_line, url_unquote, wipe_if_deltamode
from utils import problem_file_line, url_unquote, wipe_if_deltamode, is_printable
# title, username and such are limited that they fit on the one line both in
# text entry (W-2) and also in menu display (W-3)
@ -130,9 +131,7 @@ class NotesMenu(MenuSystem):
else:
wipe_if_deltamode()
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), menu=note.make_menu))
rv = cls.construct_note_items(readonly=False)
rv.extend(news)
@ -150,16 +149,34 @@ class NotesMenu(MenuSystem):
# When only allowed to view, no export/add new/delete.
wipe_if_deltamode()
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title),
menu=note.make_menu, arg=True)) # readonly=True
rv = cls.construct_note_items(readonly=True)
if not rv:
rv.append(MenuItem('(none saved yet)'))
return rv
@classmethod
def construct_note_items(cls, readonly=False):
rv = []
by_group = {}
for note in NoteContent.get_all():
item = MenuItem('%d: %s' % (note.idx+1, note.title),
menu=note.make_menu, arg=readonly)
group = note.group
if group:
if group not in by_group:
by_group[group] = []
by_group[group].append(item)
else:
rv.append(item)
for group in sorted(by_group):
rv.append(MenuItem('' + group, menu=NoteGroupMenu(group, readonly)))
return rv
@classmethod
async def export_all(cls, *a):
await start_export(NoteContent.get_all())
@ -211,7 +228,7 @@ class NotesMenu(MenuSystem):
@classmethod
async def disable_notes(cls, *a):
# they don't want feature anymore; already checked no notes in effect
# - no need for confirm, they aren't loosing anything
# - no need for confirm, they aren't losing anything
settings.remove_key('secnap')
settings.remove_key('notes')
settings.save()
@ -230,10 +247,28 @@ class NotesMenu(MenuSystem):
@classmethod
async def drill_to(cls, menu, item):
# make it so looks like we drilled down into the new note
menu.goto_idx(item.idx)
label = '%d: %s' % (item.idx+1, item.title)
group = item.group
if group:
cls.goto_exact_label(menu, '' + group)
gm = NoteGroupMenu(group)
cls.goto_exact_label(gm, label)
the_ux.push(gm)
else:
cls.goto_exact_label(menu, label)
m = await item._make_menu()
the_ux.push(MenuSystem(m))
@staticmethod
def goto_exact_label(menu, label):
for i, mi in enumerate(menu.items):
if mi.label == label:
menu.goto_idx(i)
return True
return False
class NoteContentBase:
def __init__(self, json={}, idx=-1):
@ -249,9 +284,15 @@ class NoteContentBase:
return PasswordContent(j, idx) if 'user' in j else NoteContent(j, idx)
def serialize(self):
return {fld:getattr(self, fld, '') for fld in self.flds}
res = {}
for fld in self.flds:
val = getattr(self, fld, '')
# user field is necessary for proper password identification in constructor
if not val and (fld != "user"):
continue
res[fld] = val
to_json = serialize
return res
@classmethod
def get_all(cls):
@ -277,6 +318,15 @@ class NoteContentBase:
settings.put('notes', [n.serialize() for n in notes])
settings.save()
@classmethod
def get_groups(cls):
groups = set()
for note in cls.get_all():
if note.group:
groups.add(note.group)
return sorted(groups)
async def delete(self, *a):
# Remove note
ok = await ux_confirm("Everything about this note/password will be lost.")
@ -297,6 +347,11 @@ class NoteContentBase:
the_ux.pop()
m = the_ux.top_of_stack()
m.update_contents()
parent = the_ux.parent_of(m)
if parent:
parent.update_contents()
if isinstance(m, NoteGroupMenu) and not m.has_notes():
the_ux.pop()
await ux_dramatic_pause('Deleted.', 3)
@ -334,6 +389,11 @@ class NoteContentBase:
# update parent
parent = the_ux.parent_of(menu)
parent.update_contents()
grandparent = the_ux.parent_of(parent)
if grandparent:
grandparent.update_contents()
if isinstance(parent, NoteGroupMenu) and not parent.has_notes():
the_ux.stack.remove(parent)
else:
menu.update_contents()
@ -363,12 +423,94 @@ class NoteContentBase:
await ux_sign_msg(txt, approved_cb=msg_signing_done, kill_menu=False)
def sign_misc_menu_item(self):
return MenuItem("Sign Note Text", f=self.sign_txt_msg, arg=self.misc)
return MenuItem("Sign Note Text", f=self.sign_txt_msg, arg=self.misc,
predicate=2 <= len(self.misc) <= MSG_SIGNING_MAX_LENGTH)
@staticmethod
def is_b39pass_applicable(data, read_only):
from seed import MAX_PASS_LEN
from ccc import sssp_spending_policy
if read_only and not sssp_spending_policy('okeys'):
return False
return (len(data) <= MAX_PASS_LEN) and is_printable(data) and settings.get("words", True)
async def apply_as_b39_pass(self, a, b, item):
data, readonly = item.arg
# rstrip just trailing whitespaces/tabs/newlines
data = data.rstrip()
# do not allow any more tabs/newlines
assert self.is_b39pass_applicable(data, readonly)
from seed import apply_pass_value
await apply_pass_value(data)
class NoteGroupMenu(MenuSystem):
def __init__(self, group, readonly=False):
self.group = group
self.readonly = readonly
super().__init__(self.construct())
def construct(self):
items = []
for note in NoteContent.get_all():
if note.group == self.group:
items.append(MenuItem('%d: %s' % (note.idx+1, note.title),
menu=note.make_menu, arg=self.readonly))
return items or [MenuItem('(none)')]
def has_notes(self):
return any(note.group == self.group for note in NoteContent.get_all())
def update_contents(self):
self.replace_items(self.construct())
class GroupPickerMenu(MenuSystem):
def __init__(self, current=''):
self.result = None
self.current = current
groups = NoteContentBase.get_groups()
chosen = 0
items = [MenuItem('(none)', f=self.picked, arg='')]
for group in groups:
if group == self.current:
chosen = len(items)
items.append(MenuItem(group, f=self.picked, arg=group))
items.append(MenuItem('New Group', f=self.new_group))
super().__init__(items, chosen=chosen)
async def picked(self, menu, idx, mi):
assert menu == self
self.result = mi.arg
the_ux.pop()
async def new_group(self, menu, idx, mi):
group = await ux_input_text('', max_len=ONE_LINE, confirm_exit=False,
prompt='Group', placeholder='(optional)')
if group is None:
self.result = None
else:
self.result = group
the_ux.pop()
@classmethod
async def pick(cls, current=''):
m = cls(current)
the_ux.push(m)
await m.interact()
return current if m.result is None else m.result
class PasswordContent(NoteContentBase):
# "Passwords" have a few more fields and are more structured
flds = ['title', 'user', 'password', 'site', 'misc' ]
flds = ['title', 'user', 'password', 'site', 'misc', 'group']
type_label = 'password'
async def _make_menu(self, readonly=False):
@ -380,7 +522,7 @@ class PasswordContent(NoteContentBase):
# if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
rv += [
MenuItem('View Password', f=self.view_pw),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: not settings.get('du', 0)),
]
if not readonly:
rv += [
@ -395,6 +537,12 @@ class PasswordContent(NoteContentBase):
ShortcutItem(KEY_NFC, f=self.share_nfc, arg=self.type_label),
]
# if password is less than MAX_PASS_LEN and only consist of printable ASCII characters
# and current seed (master or tmp) is word based - offer to apply pwd text as BIP-39 passphrase
if self.is_b39pass_applicable(self.password, readonly):
rv += [MenuItem('Apply as BIP-39 Passphrase',
f=self.apply_as_b39_pass, arg=(self.password, readonly))]
return rv
async def make_menu(self, a, b, item):
@ -468,7 +616,8 @@ class PasswordContent(NoteContentBase):
if self.idx == -1:
# prompt for password only on new records.
self.password = await get_a_password(self.password)
# can be None if CANCEL is pressed - handle, Send Password requires string
self.password = await get_a_password(self.password) or ""
site = await ux_input_text(self.site, max_len=ONE_LINE, scan_ok=True, confirm_exit=False,
prompt='Website', placeholder='(optional)')
@ -480,6 +629,8 @@ class PasswordContent(NoteContentBase):
if misc is None:
misc = self.misc
group = await GroupPickerMenu.pick(self.group)
if self.idx != -1:
# confirm changes, don't for new records
chgs = []
@ -491,6 +642,8 @@ class PasswordContent(NoteContentBase):
chgs.append('Username')
if self.misc != misc:
chgs.append('Other Notes')
if self.group != group:
chgs.append('Group')
if not chgs:
await ux_dramatic_pause('No changes.', 3)
@ -504,6 +657,7 @@ class PasswordContent(NoteContentBase):
self.user = user
self.site = site
self.misc = misc
self.group = group
await self._save_ux(menu)
return self
@ -511,11 +665,12 @@ class PasswordContent(NoteContentBase):
class NoteContent(NoteContentBase):
# Pure "notes" have just a title and free-form text
flds = ['title', 'misc']
flds = ['title', 'misc', 'group']
type_label = 'note'
async def _make_menu(self, readonly=False):
# Details and actions for this Note
rv = [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Note', f=self.view),
@ -526,11 +681,19 @@ class NoteContent(NoteContentBase):
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
rv += [
self.sign_misc_menu_item(),
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
]
# if misc is less than MAX_PASS_LEN and only consist of printable ASCII characters
# and current seed (master or tmp) is word based - offer to apply note text as BIP-39 passphrase
if self.is_b39pass_applicable(self.misc, readonly):
rv += [MenuItem('Apply as BIP-39 Passphrase',
f=self.apply_as_b39_pass, arg=(self.misc, readonly))]
return rv
async def make_menu(self, a, b, item):
@ -557,6 +720,8 @@ class NoteContent(NoteContentBase):
if misc is None:
misc = self.misc
group = await GroupPickerMenu.pick(self.group)
if self.idx != -1:
# confirm changes, don't for new records
chgs = []
@ -564,6 +729,8 @@ class NoteContent(NoteContentBase):
chgs.append('Title')
if self.misc != misc:
chgs.append('Note Text')
if self.group != group:
chgs.append('Group')
if not chgs:
await ux_dramatic_pause('No changes.', 3)
@ -576,6 +743,7 @@ class NoteContent(NoteContentBase):
self.title = title
self.misc = misc
self.group = group
await self._save_ux(menu)
@ -664,8 +832,9 @@ async def import_from_other(menu, *a):
records = json.load(open(fn, 'rt'))
# We have some JSON, parsed now.
await import_from_json(records)
ok = await import_from_json(records)
if not ok: return
await ux_dramatic_pause('Saved.', 3)
menu.update_contents()
@ -683,6 +852,7 @@ async def import_from_json(records):
settings.set('notes', was)
settings.set('secnap', True)
settings.save()
return True
except Exception as e:
await ux_show_story(title="Failure", msg=str(e) + '\n\n' + problem_file_line(e))

View File

@ -8,7 +8,7 @@ from ucollections import namedtuple
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from exceptions import UnknownAddressExplained
from utils import problem_file_line, show_single_address
from utils import problem_file_line, show_single_address, validate_own_address
from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH
# Track many addresses, but in compressed form
@ -304,11 +304,10 @@ class OwnershipCache:
dis.fullscreen("Wait...")
ch = chains.current_chain()
addr_fmt = ch.possible_address_fmt(addr)
if not addr_fmt:
# might be valid address over on testnet vs mainnet
raise UnknownAddressExplained('That address is not valid on ' + ch.name)
try:
addr, addr_fmt = validate_own_address(addr)
except Exception as e:
raise UnknownAddressExplained('That address is not valid on ' + e.args[0])
matches = OWNERSHIP.filter(addr_fmt, args)
@ -343,7 +342,7 @@ class OwnershipCache:
dis.fullscreen("WIF Store...")
from wif import iter_wif_store_addresses
target_af = AF_P2WPKH_P2SH if addr_fmt == AF_P2SH else addr_fmt
for i, store_addr in iter_wif_store_addresses(ch, target_af):
for i, store_addr in iter_wif_store_addresses(target_af):
if store_addr == addr:
return False, ("wif", target_af), i+1

View File

@ -14,13 +14,13 @@ from sffile import SizerFile
from multisig import MultisigWallet, disassemble_multisig_mn
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
from serializations import ser_compact_size, deser_compact_size, hash160
from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import CTransaction, CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import ser_sig_der, uint256_from_str, ser_push_data
from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY
from serializations import ALL_SIGHASH_FLAGS
from opcodes import OP_CHECKMULTISIG, OP_RETURN
from glob import settings
from wif import init_wif_store
from wif import WIFStore
from public_constants import (
PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
@ -31,12 +31,23 @@ from public_constants import (
PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT,
PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID,
PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME,
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS,
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, MAX_PATH_DEPTH, MAX_SIGNERS,
AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH, AF_P2SH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, AF_BARE_PK
)
# transaction version error
TX_VER_ERR = "bad txn version"
NO_KEY_ERR = "None of the keys involved in this transaction belong to this Coldcard"
# single sha256 of b'BIP0322-signed-message'
BIP322_TAG_HASH = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
def build_bip322_to_spend(msg_hash, message_challenge):
to_spend = CTransaction()
to_spend.nVersion = 0
to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff), scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, message_challenge)]
return to_spend
# PSBT proprietary keytype
PSBT_PROPRIETARY = const(0xFC)
@ -282,8 +293,6 @@ class psbtProxy:
vl = self.subpaths[pk][1]
# force them to use a derived key, never the master
assert vl >= 8, 'too short key path'
assert (vl % 4) == 0, 'corrupt key path'
assert (vl//4) <= MAX_PATH_DEPTH, 'too deep'
@ -431,7 +440,7 @@ class psbtOutputProxy(psbtProxy):
if af == AF_BARE_PK:
# output is public key (not a hash, much less common)
assert len(addr_or_pubkey) == 33
assert len(addr_or_pubkey) in (33, 65) # compressed or uncompressed
if addr_or_pubkey != expect_pubkey:
raise FraudulentChangeOutput(out_idx, "P2PK change output is fraudulent")
@ -478,7 +487,7 @@ class psbtOutputProxy(psbtProxy):
# - we get all of that by re-constructing the script from our wallet details
if not redeem_script and not witness_script:
# Perhaps an omission, so let's not call fraud on it
# But definately required, else we don't know what script we're sending to.
# But definitely required, else we don't know what script we're sending to.
raise FatalPSBTIssue(
"Missing redeem/witness script for multisig output #%d" % out_idx
)
@ -578,6 +587,7 @@ class psbtInputProxy(psbtProxy):
'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys',
'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid',
'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'addr_fmt',
'wif_redeem_script',
)
def __init__(self, fd, idx):
@ -665,7 +675,7 @@ class psbtInputProxy(psbtProxy):
# - assuming PSBT creator doesn't give us extra data not required
# - seems harmless if they fool us into thinking already signed; we do nothing
# - could also look at pubkey needed vs. sig provided
# - could consider structure of MofN in p2sh cases
# - structure of MofN is considered in determine_my_signing_key where fully_signed is updated
self.fully_signed = (len(self.part_sigs) >= len(self.subpaths))
else:
# No signatures at all yet for this input (typical non multisig)
@ -710,49 +720,58 @@ class psbtInputProxy(psbtProxy):
fd = self.fd
old_pos = fd.tell()
if self.witness_utxo:
# Going forward? Just what we will witness; no other junk
# - prefer this format, altho does that imply segwit txn must be generated?
# - I don't know why we wouldn't always use this
# - once we use this partial utxo data, we must create witness data out
if self.utxo:
# skip over all the parts of the txn we don't care about, without
# fully parsing it... pull out a single TXO
fd.seek(self.utxo[0])
_, marker, flags = unpack("<iBB", fd.read(6))
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
# rewind back over marker+flags
fd.seek(-2, 1)
# How many ins? We accept zero here because utxo's inputs might have been
# trimmed to save space, and we have test cases like that.
num_in = deser_compact_size(fd)
_skip_n_objs(fd, num_in, 'CTxIn')
num_out = deser_compact_size(fd)
assert idx < num_out, "not enuf outs"
_skip_n_objs(fd, idx, 'CTxOut')
fd.seek(self.witness_utxo[0])
utxo = CTxOut()
utxo.deserialize(fd)
# ... followed by more outs, and maybe witness data, but we don't care ...
fd.seek(old_pos)
return utxo
assert self.utxo, 'no utxo'
# skip over all the parts of the txn we don't care about, without
# fully parsing it... pull out a single TXO
fd.seek(self.utxo[0])
_, marker, flags = unpack("<iBB", fd.read(6))
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
# rewind back over marker+flags
fd.seek(-2, 1)
# How many ins? We accept zero here because utxo's inputs might have been
# trimmed to save space, and we have test cases like that.
num_in = deser_compact_size(fd)
_skip_n_objs(fd, num_in, 'CTxIn')
num_out = deser_compact_size(fd)
assert idx < num_out, "not enuf outs"
_skip_n_objs(fd, idx, 'CTxOut')
assert self.witness_utxo, 'no utxo'
fd.seek(self.witness_utxo[0])
utxo = CTxOut()
utxo.deserialize(fd)
# ... followed by more outs, and maybe witness data, but we don't care ...
fd.seek(old_pos)
return utxo
def witness_utxo_is_provably_segwit(self, utxo):
af, addr_or_pubkey, addr_is_segwit = utxo.get_address()
if addr_is_segwit:
return True
if af != AF_P2SH or not self.redeem_script:
return False
redeem_script = self.get(self.redeem_script)
return redeem_script[0] == 0 and \
((len(redeem_script) == 22 and redeem_script[1] == 20) or
(len(redeem_script) == 34 and redeem_script[1] == 32)) and \
hash160(redeem_script) == addr_or_pubkey
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt, cosign_xfp=None):
# See what it takes to sign this particular input
# - type of script
@ -763,7 +782,23 @@ class psbtInputProxy(psbtProxy):
self.amount = utxo.nValue
self.addr_fmt, addr_or_pubkey, addr_is_segwit = utxo.get_address()
if not self.subpaths or self.fully_signed or (not self.num_our_keys):
subpaths = dict(self.subpaths or {}) # shallow copy
if not subpaths and psbt.wif_store:
res = psbt.wif_store.match_address_hash(self.addr_fmt, addr_or_pubkey)
if res:
# we have private key in WIF Store
# add pubkey with bogus zero fingerprint to current scope "subpaths" copy (will not be serialized)
# we want this function to finish properly, to set scriptSig or scriptCode, address format, etc...
idx, pk = res
subpaths[pk] = bytes(4)
self.num_our_keys = 1
if self.addr_fmt == AF_P2SH:
self.wif_redeem_script = b'\x00\x14' + psbt.wif_store._pkh[idx]
if self.redeem_script:
assert self.get(self.redeem_script) == self.wif_redeem_script
if not subpaths or self.fully_signed or (not self.num_our_keys):
# without xfp+path we will not be able to sign this input
# - okay if fully signed
# - okay if payjoin or other multi-signer (not multisig) txn
@ -792,25 +827,32 @@ class psbtInputProxy(psbtProxy):
self.is_p2sh = True
# we must have the redeem script already (else fail)
ks = self.witness_script or self.redeem_script
if not ks:
raise FatalPSBTIssue("Missing redeem/witness script for input #%d" % my_idx)
if self.wif_redeem_script:
redeem_script = self.wif_redeem_script
else:
ks = self.witness_script or self.redeem_script
if not ks:
raise FatalPSBTIssue("Missing redeem/witness script for input #%d" % my_idx)
redeem_script = self.get(ks)
redeem_script = self.get(ks)
self.scriptSig = redeem_script
# new cheat: psbt creator probably telling us exactly what key
# to use, by providing exactly one. This is ideal for p2sh wrapped p2pkh
if len(self.subpaths) == 1:
which_key, = self.subpaths.keys()
else:
# Assume we'll be signing with any key we know
# - limitation: we cannot be two legs of a multisig (only if CCC feature used)
# - but if partial sig already in place, ignore that one
if not which_key:
which_key = set()
if not addr_is_segwit and len(redeem_script) == 22 and \
redeem_script[0] == 0 and redeem_script[1] == 20:
# segwit p2pkh wrapped in p2sh: exactly one key, not multisig.
# psbt creator tells us the key by providing exactly one subpath.
self.addr_fmt = AF_P2WPKH_P2SH
addr = redeem_script[2:22]
self.is_segwit = True
for pubkey, path in self.subpaths.items():
assert len(subpaths) == 1, "p2sh-p2wpkh needs one key"
which_key, = subpaths.keys()
else:
self.is_multisig = True
which_key = set()
for pubkey, path in subpaths.items():
if self.part_sigs and (pubkey in self.part_sigs):
# pubkey has already signed, so ignore
continue
@ -820,20 +862,8 @@ class psbtInputProxy(psbtProxy):
which_key.add(pubkey)
elif pubkey in psbt.wif_store:
# maybe sset some input value
which_key.add(pubkey)
if not addr_is_segwit and \
len(redeem_script) == 22 and \
redeem_script[0] == 0 and redeem_script[1] == 20:
# it's actually segwit p2pkh inside p2sh
self.addr_fmt = AF_P2WPKH_P2SH
addr = redeem_script[2:22]
self.is_segwit = True
else:
# multiple keys involved
self.is_multisig = True
if self.witness_script and not self.is_segwit and self.is_multisig:
# bugfix
self.addr_fmt = AF_P2WSH_P2SH
@ -844,7 +874,7 @@ class psbtInputProxy(psbtProxy):
self.scriptSig = utxo.scriptPubKey
addr = addr_or_pubkey
for pubkey in self.subpaths:
for pubkey in subpaths:
if hash160(pubkey) == addr:
which_key = pubkey
break
@ -855,9 +885,9 @@ class psbtInputProxy(psbtProxy):
elif self.addr_fmt == AF_BARE_PK:
# input is single public key (less common)
self.scriptSig = utxo.scriptPubKey
assert len(addr_or_pubkey) == 33
assert len(addr_or_pubkey) in (33, 65) # compressed or uncompressed
if addr_or_pubkey in self.subpaths:
if addr_or_pubkey in subpaths:
which_key = addr_or_pubkey
else:
# pubkey provided is just wrong vs. UTXO
@ -875,7 +905,12 @@ class psbtInputProxy(psbtProxy):
#print("redeem: %s" % b2a_hex(redeem_script))
M, N = disassemble_multisig_mn(redeem_script)
xfp_paths = list(self.subpaths.values())
if len(self.part_sigs) >= M:
self.fully_signed = True
return
xfp_paths = list(subpaths.values())
xfp_paths.sort()
# only search wallets with correct script type (aka address format)
@ -892,7 +927,7 @@ class psbtInputProxy(psbtProxy):
# validate redeem script, by disassembling it and checking all pubkeys
try:
psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths)
psbt.active_multisig.validate_script(redeem_script, subpaths=subpaths)
target_spk, _ = chains.current_chain().script_pubkey(self.addr_fmt,
script=redeem_script)
assert target_spk == utxo.scriptPubKey, "spk mismatch"
@ -989,8 +1024,9 @@ class psbtInputProxy(psbtProxy):
for k in self.subpaths:
wr(PSBT_IN_BIP32_DERIVATION, self.subpaths[k], k)
if self.redeem_script:
wr(PSBT_IN_REDEEM_SCRIPT, self.redeem_script)
redeem_script = self.redeem_script or self.wif_redeem_script
if redeem_script:
wr(PSBT_IN_REDEEM_SCRIPT, redeem_script)
if self.witness_script:
wr(PSBT_IN_WITNESS_SCRIPT, self.witness_script)
@ -1029,7 +1065,7 @@ class psbtObject(psbtProxy):
self.xpubs = [] # tuples(xfp_path, xpub)
self.my_xfp = settings.get('xfp', 0)
self.wif_store = init_wif_store()
self.wif_store = WIFStore()
# details that we discover as we go
self.inputs = None
@ -1076,6 +1112,7 @@ class psbtObject(psbtProxy):
# Proof of Reserves
self.por322 = False
self.por322_msg = None
self.por322_msg_hash = None
self.por322_msg_challenge = None
@ -1109,12 +1146,35 @@ class psbtObject(psbtProxy):
# bytes of length 1 (tx modifiable in short_values)
assert len(val) == 1
self.txn_modifiable = val[0]
elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
assert len(key) == 1
self.por322_msg = self.get(val).decode()
else:
self.unknown = self.unknown or {}
if key in self.unknown:
raise FatalPSBTIssue("Duplicate key. Key for unknown value already provided in global namespace.")
self.unknown[key] = val
def validate_bip322_input0(self, inp, txi, utxo):
msg_hash = ngu.hash.sha256t(BIP322_TAG_HASH, self.por322_msg.encode(), True)
message_challenge = utxo.scriptPubKey
to_spend = build_bip322_to_spend(msg_hash, message_challenge)
to_spend_hash = uint256_from_str(ngu.hash.sha256d(to_spend.serialize_without_witness()))
assert txi.prevout.hash == to_spend_hash, "to_spend hash"
assert txi.prevout.n == 0, "prevout n"
assert utxo.nValue == 0, "input0 value"
if inp.utxo:
old_pos = self.fd.tell()
raw_utxo = self.get(inp.utxo)
self.fd.seek(old_pos)
assert raw_utxo == to_spend.serialize_without_witness(), "utxo"
self.por322_msg_hash = msg_hash
assert message_challenge, "empty message_challenge"
self.por322_msg_challenge = message_challenge
def output_iter(self, start=0, stop=None):
# yield the txn's outputs: index, (CTxOut object) for each
if stop is None:
@ -1457,24 +1517,34 @@ class psbtObject(psbtProxy):
out = self.outputs[idx]
if self.is_v2:
# v2 requires inclusion
assert out.amount
assert out.amount is not None
assert out.script
if out.amount == 0 and out.script == b'\x6a':
null_data_op_return = True
else:
# v0 requires exclusion
assert out.amount is None
assert out.script is None
if txo.nValue == 0 and txo.scriptPubKey == b'\x6a':
null_data_op_return = True
if txo.nValue == 0 and txo.scriptPubKey == b'\x6a':
null_data_op_return = True
if null_data_op_return and (num_outs == 1):
self.por322 = True
assert self.por322_msg, "msg"
self.por322 = bool(self.por322_msg)
if self.por322:
if len(self.por322_msg) != len(self.por322_msg.encode()):
self.warnings.append((
"Message",
"Message contains non-ASCII characters that may not be readable on this screen."
))
if self.txn_version == 0:
# only allow txn version 0 for Proof of Reserves txn (BIP-322)
assert self.por322, TX_VER_ERR
if self.por322:
assert self.txn_version in {0, 2}, TX_VER_ERR
# time based relative locks
tb_rel_locks = []
# block height based relative locks
@ -1665,6 +1735,9 @@ class psbtObject(psbtProxy):
if input.sighash not in ALL_SIGHASH_FLAGS:
raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % input.sighash)
if self.por322 and input.sighash != SIGHASH_ALL:
raise FatalPSBTIssue("POR not SIGHASH_ALL")
if input.sighash != SIGHASH_ALL:
sh_unusual = True
@ -1767,6 +1840,7 @@ class psbtObject(psbtProxy):
# hashes match, and what values are we getting?
# Important: parse incoming UTXO to build total input value
foreign = []
unverified_witness_utxo = []
total_in = 0
from_wif_store = []
prevouts = set()
@ -1781,8 +1855,6 @@ class psbtObject(psbtProxy):
prevouts.add(k)
inp = self.inputs[i]
if inp.fully_signed:
self.presigned_inputs.add(i)
if not inp.has_utxo():
if inp.num_our_keys and not inp.fully_signed:
@ -1798,12 +1870,23 @@ class psbtObject(psbtProxy):
assert utxo.nValue >= 0, "negative input value: i%d" % i
total_in += utxo.nValue
if not inp.utxo and not inp.witness_utxo_is_provably_segwit(utxo):
unverified_witness_utxo.append(i)
# Look at what kind of input this will be, and therefore what
# type of signing will be required, and which key we need.
# - also validates redeem_script when present
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
if inp.required_key and not inp.is_segwit and not inp.utxo:
raise FatalPSBTIssue('Legacy input #%d requires non-witness UTXO' % i)
# determine_my_signing_key is updating fully_signed for multisig inputs
# based on redeem/witness script
if inp.fully_signed:
self.presigned_inputs.add(i)
if inp.required_key and self.wif_store:
is_in = False
for pk in inp.required_key if isinstance(inp.required_key, set) else [inp.required_key]:
@ -1822,42 +1905,8 @@ class psbtObject(psbtProxy):
if self.por322 and (i == 0):
# Proof of Reserves 'to_spend' validation
try:
assert inp.utxo, "utxo"
fd = self.fd
old_pos = fd.tell()
fd.seek(inp.utxo[0])
txn_version, marker, flags = unpack("<iBB", fd.read(6))
assert txn_version == 0, TX_VER_ERR
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
fd.seek(-2, 1)
num_in = deser_compact_size(fd)
assert num_in == 1, "num ins"
tx_inp = CTxIn()
tx_inp.deserialize(fd)
try:
assert len(tx_inp.scriptSig) == 34
assert tx_inp.scriptSig[0] == 0
assert tx_inp.scriptSig[1] == 32
except:
assert False, "scriptSig"
self.por322_msg_hash = tx_inp.scriptSig[2:]
try:
assert tx_inp.prevout.hash == 0
assert tx_inp.prevout.n == 0xffffffff
except:
assert False, "prevout"
num_out = deser_compact_size(fd)
assert num_out == 1, "num outs"
tx_out = CTxOut()
tx_out.deserialize(fd)
self.por322_msg_challenge = tx_out.scriptPubKey
assert tx_out.nValue == 0, "nVal"
fd.seek(old_pos)
assert inp.required_key, "not our key"
self.validate_bip322_input0(inp, txi, utxo)
except Exception as e:
raise FatalPSBTIssue("i0: invalid BIP-322 'to_spend': %s" % e)
@ -1865,7 +1914,7 @@ class psbtObject(psbtProxy):
# XXX scan witness data provided, and consider those ins signed if not multisig?
if not foreign:
if not foreign and not unverified_witness_utxo:
# no foreign inputs, we can calculate the total input value
self.total_value_in = total_in
assert total_in > 0 or self.por322, "zero value txn"
@ -1874,9 +1923,15 @@ class psbtObject(psbtProxy):
# OK for multi-party transactions (coinjoin etc.)
assert not self.por322 # cannot have foreign inputs in POR txn
self.total_value_in = None
self.warnings.append(
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
)
if foreign:
self.warnings.append(
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
)
if unverified_witness_utxo:
self.warnings.append(
("Unable to calculate fee", "Some input(s) provided unverified witness UTXO(s): " +
seq_to_str(unverified_witness_utxo))
)
if len(self.presigned_inputs) == self.num_inputs:
# Maybe wrong for multisig cases? Maybe they want to add their
@ -1891,6 +1946,9 @@ class psbtObject(psbtProxy):
for n,inp in enumerate(self.inputs)
if (inp.required_key is None) and (not inp.fully_signed)
)
if len(no_keys) >= self.num_inputs:
raise FatalPSBTIssue(NO_KEY_ERR)
if no_keys:
# This is seen when you re-sign same signed file by accident (multisig)
# - case of len(no_keys)==num_inputs is handled by consider_keys
@ -1937,9 +1995,7 @@ class psbtObject(psbtProxy):
others.discard(self.my_xfp)
msg = ', '.join(xfp2str(i) for i in others)
raise FatalPSBTIssue('None of the keys involved in this transaction '
'belong to this Coldcard (need %s, found %s).'
% (xfp2str(self.my_xfp), msg))
raise FatalPSBTIssue(NO_KEY_ERR + " (need %s, found %s)" % (xfp2str(self.my_xfp), msg))
@classmethod
def read_psbt(cls, fd):
@ -2003,6 +2059,9 @@ class psbtObject(psbtProxy):
for v, k in self.xpubs:
wr(PSBT_GLOBAL_XPUB, v, k)
if self.por322_msg:
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.por322_msg.encode())
if self.unknown:
for k, v in self.unknown.items():
wr(k[0], v, k[1:])
@ -2025,7 +2084,13 @@ class psbtObject(psbtProxy):
node = sv.derive_path(skp)
# check the pubkey of this BIP-32 node
if target_pk == node.pubkey():
pu = node.pubkey() # always 33-byte compressed
if len(target_pk) == 65:
# P2PK with uncompressed pubkey: re-serialize node's pubkey in
# uncompressed form for direct comparison.
pu = ngu.secp256k1.pubkey(pu).to_bytes(True)
if target_pk == pu:
return node
return None
@ -2152,21 +2217,16 @@ class psbtObject(psbtProxy):
which_key = inp.required_key
assert not inp.added_sigs, "already done??"
assert which_key in inp.subpaths, 'unk key'
if which_key in self.wif_store:
node = node_from_privkey(self.wif_store[which_key])
else:
assert which_key in inp.subpaths, 'unk key'
# get node required
skp = keypath_to_str(inp.subpaths[which_key])
node = sv.derive_path(skp, register=False)
# expensive test, but works... and important
pu = node.pubkey()
assert pu == which_key, \
"Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx)
node = self.check_pubkey_at_path(sv, inp.subpaths[which_key], which_key)
assert node, "Path (%s) led to wrong pubkey for input#%d" % (
keypath_to_str(inp.subpaths[which_key]), in_idx)
# track wallet usage
@ -2529,7 +2589,12 @@ class psbtObject(psbtProxy):
else:
pubkey, der_sig = ssig
txi.scriptSig = ser_push_data(der_sig) + ser_push_data(pubkey)
if inp.addr_fmt == AF_BARE_PK:
# P2PK: pubkey is already in scriptPubKey, scriptSig is just <sig>
txi.scriptSig = ser_push_data(der_sig)
else:
# P2PKH: scriptSig is <sig> <pubkey>
txi.scriptSig = ser_push_data(der_sig) + ser_push_data(pubkey)
fd.write(txi.serialize())

View File

@ -25,10 +25,6 @@ class PSRAMWrapper:
return memoryview(self._wr)[offset:offset+ln]
def is_at(self, ptr, offset):
# is bytes() object really one we created at read_at
return uctypes.addressof(ptr) == self.base+offset
# Be compatible with SPIFlash class...
def read(self, address, buf, cmd=None):

View File

@ -57,9 +57,8 @@ SLOW_BAUD = const(9600)
FAST_BAUD = const(57600)
RX_BUF_SIZE = const(4350) # big enough for full v40 decoded
# TODO: constructor should leave it in reset for simple lower-power usage; then after
# login we can do full setup (2+ seconds) and then sleep again until needed.
# TODO: constructor should avoid full setup until after login; after setup,
# command sleep is the known low-power state.
class QRScanner:
def __init__(self):
@ -68,6 +67,8 @@ class QRScanner:
self.scan_light = False # is light on during scanning?
self.version = None
self.setup_done = False
self.needs_reinit = False
self.sleep_seq = 0
# hodl this lock when communicating w/ QR scanner
self.lock = asyncio.Lock()
@ -84,16 +85,21 @@ class QRScanner:
# setup hardware, reset scanner and return time to delay until ready
from machine import UART, Pin
self.serial = UART(2, SLOW_BAUD, rxbuf=RX_BUF_SIZE)
self.reset = Pin('QR_RESET', Pin.OUT_OD, value=0)
self.reset = Pin('QR_RESET', Pin.OUT_OD, value=1)
self.trigger = Pin('QR_TRIG', Pin.OUT_OD, value=1) # wasn't needed
# NOTE: reset is active low (open drain)
self.pulse_reset()
# needs full 2 seconds of recovery time after reset
return 2
def pulse_reset(self):
# RESET is active low (open drain). Keep it as a pulse; module docs
# describe low on this pin as wake-up, so don't use it as parking state.
self.reset(0)
utime.sleep_ms(10)
self.reset(1)
# needs full 2 seconds of recovery time
return 2
self.needs_reinit = False
def set_baud(self, br):
# change serial port baud rate
@ -118,56 +124,104 @@ class QRScanner:
async def setup_task(self, start_delay):
# Task to setup device, and then die.
await asyncio.sleep(start_delay)
async with self.lock:
for attempt in range(3):
await asyncio.sleep(start_delay)
async with self.lock:
try:
await self._configure()
except Exception:
# a step failed or timed out (would have left scanner dead
# until next boot); reset module and start over
await self.blind_shutdown()
if attempt == 2:
break
start_delay = self.reset_stream()
continue
# might need to repeat a few time to get into right state
self.setup_done = True
await self.goto_sleep()
return
self.mark_needs_reinit()
def reset_stream(self):
self.sleep_seq += 1
start_delay = self.hardware_setup()
self.stream = asyncio.StreamReader(self.serial, {})
return start_delay
def mark_needs_reinit(self):
self.setup_done = False
self.version = None
self.needs_reinit = True
if hasattr(self, 'reset'):
self.reset(1)
async def blind_shutdown(self):
for baud in (SLOW_BAUD, FAST_BAUD):
self.set_baud(baud)
await self.tx('S_CMD_020D') # return to "Command mode"
await asyncio.sleep_ms(20)
await self.tx('S_CMD_03L0') # turn off bright light
await asyncio.sleep_ms(20)
await self.tx('SRDF0050') # sleep scanner
await asyncio.sleep_ms(150)
await self.tx('SRDF0050')
await asyncio.sleep_ms(20)
async def _configure(self):
# full config sequence; any step may raise on timeout/framing error
# might need to repeat a few time to get into right state
for retry in range(5):
baud = await self.probe_baud()
if baud: break
else:
#print("QR Scanner: missing")
raise RuntimeError('no contact')
try:
await self.txrx('S_CMD_FFFF') # factory reset of settings
except RuntimeError:
await asyncio.sleep_ms(1000)
for retry in range(5):
baud = await self.probe_baud()
if baud: break
else:
#print("QR Scanner: missing")
return
raise RuntimeError('no contact after S_CMD_FFFF')
await self.txrx('S_CMD_FFFF') # factory reset of settings
# go to high speed!
if baud != FAST_BAUD:
await self.txrx('S_CMD_H3BR%d' % FAST_BAUD)
self.set_baud(FAST_BAUD)
# go to high speed!
if baud != FAST_BAUD:
await self.txrx('S_CMD_H3BR%d' % FAST_BAUD)
self.set_baud(FAST_BAUD)
# configure it like we want it
await self.txrx('S_CMD_MTRS5000') # 5s to read before fail (unused)
await self.txrx('S_CMD_MT11') # trigger is edge-based (not level)
await self.txrx('S_CMD_MT30') # Same code reading without delay
await self.txrx('S_CMD_MT20') # Enable automatic sleep when idle
await self.txrx('S_CMD_MTRF500') # Idle time: 500ms
await self.txrx('S_CMD_059A') # add CR LF after QR data (important)
await self.txrx('S_CMD_03L0') # light off all the time by default
await self.txrx('S_CMD_0407') # turn on signal for our yellow led
# configure it like we want it
await self.txrx('S_CMD_MTRS5000') # 5s to read before fail (unused)
await self.txrx('S_CMD_MT11') # trigger is edge-based (not level)
await self.txrx('S_CMD_MT30') # Same code reading without delay
await self.txrx('S_CMD_MT20') # Enable automatic sleep when idle
await self.txrx('S_CMD_MTRF500') # Idle time: 500ms
await self.txrx('S_CMD_059A') # add CR LF after QR data (important)
await self.txrx('S_CMD_03L0') # light off all the time by default
await self.txrx('S_CMD_0407') # turn on signal for our yellow led
# settings under continuous scan mode
await self.txrx('S_CMD_MARS0000') # "Modify the duration of single code reading" (ms)
await self.txrx('S_CMD_MARR000') # "Modify the time of the reading interval 0ms"
await self.txrx('S_CMD_MA31') # Enable "Same code reading delay"
await self.txrx('S_CMD_MARI0050') # "Modify the same code reading delay 50ms"
# settings under continuous scan mode
await self.txrx('S_CMD_MARS0000') # "Modify the duration of single code reading" (ms)
await self.txrx('S_CMD_MARR000') # "Modify the time of the reading interval 0ms"
await self.txrx('S_CMD_MA31') # Enable "Same code reading delay"
await self.txrx('S_CMD_MARI0050') # "Modify the same code reading delay 50ms"
# these aren't useful (yet?) and just make things harder to decode.
#await self.txrx('S_CMD_05F1') # add all information on
#await self.txrx('S_CMD_05L1') # output decoding length info on
#await self.txrx('S_CMD_05S1') # STX start char
#await self.txrx('S_CMD_05C1') # CodeID+prefix
#await self.txrx('S_CMD_0501') # prefix on
#await self.txrx('S_CMD_0506') # suffix
#await self.txrx('S_CMD_05D0') # tx total data
# these aren't useful (yet?) and just make things harder to decode.
#await self.txrx('S_CMD_05F1') # add all information on
#await self.txrx('S_CMD_05L1') # output decoding length info on
#await self.txrx('S_CMD_05S1') # STX start char
#await self.txrx('S_CMD_05C1') # CodeID+prefix
#await self.txrx('S_CMD_0501') # prefix on
#await self.txrx('S_CMD_0506') # suffix
#await self.txrx('S_CMD_05D0') # tx total data
# prevent scanning magic QR to affect settings
await self.txrx('S_CMD_0000') # close setting codes
self.setup_done = True
await self.goto_sleep()
# prevent scanning magic QR to affect settings
await self.txrx('S_CMD_0000') # close setting codes
async def scan_once(self):
# Blocks until something is scanned. Returns it as string
@ -176,6 +230,16 @@ class QRScanner:
# - returns a BBQr object at that point
self.scan_light = False
if self.needs_reinit:
try:
await self.setup_task(self.reset_stream())
if self.setup_done:
await asyncio.sleep_ms(200)
except asyncio.CancelledError:
await self.blind_shutdown()
self.mark_needs_reinit()
return None
# wait for reset process to complete (can be an issue right after boot)
# - few seconds of boot time needed
for retry in range(10):
@ -211,19 +275,22 @@ class QRScanner:
finally:
# Problem: another valid scan can come in just as we are trying
# to get out of scanner mode
for retry in range(10):
for retry in range(3):
try:
await self.txrx('S_CMD_020D') # return to "Command mode"
await self.txrx('S_CMD_03L0') # turn off bright light
await self.txrx('S_CMD_020D', timeout=1000) # return to "Command mode"
await self.txrx('S_CMD_03L0', timeout=1000) # turn off bright light
#print('rest after %d retries' % retry)
break
except: pass
await asyncio.sleep_ms(25)
except Exception:
pass
await asyncio.sleep_ms(50)
else:
pass
#print('reset failed')
await self.blind_shutdown()
self.mark_needs_reinit()
await self.goto_sleep()
if self.setup_done:
await self.goto_sleep()
self.busy_scanning = False
# return BBQr object or string if simple QR
@ -254,13 +321,14 @@ class QRScanner:
# send specific command until it responds
# - it will wake on any command, but not instant
# - first one seems to fail 100%
self.sleep_seq += 1
await self.tx('SRDF0051') # blindly at first
for retry in range(5):
try:
await self.txrx('SRDF0051', timeout=50) # 50 ok, 20 too short
return
except:
except Exception:
# first try usually fails, that's okay... its asleep and groggy
pass
@ -270,9 +338,13 @@ class QRScanner:
# - need blind retries here
# - might be two layers of sleep, and we need this second command after the first
# - helps to turn off the yellow LED, and save power as well
self.sleep_seq += 1
sleep_seq = self.sleep_seq
await self.tx('SRDF0050')
async def later():
await asyncio.sleep_ms(150)
if sleep_seq != self.sleep_seq or self.busy_scanning:
return
await self.tx('SRDF0050')
asyncio.create_task(later())
@ -290,6 +362,22 @@ class QRScanner:
#print('tx >> ' + msg)
self.serial.write(msg)
async def readexactly_timeout(self, num, timeout, msg=None):
# Avoid asyncio.wait_for_ms here: it can leave the scanner setup task
# stuck after a CancelledError. Convert scanner silence into a normal
# retryable command failure instead.
if timeout is None:
return await self.stream.readexactly(num)
start = utime.ticks_ms()
while self.stream.s.any() < num:
if utime.ticks_diff(utime.ticks_ms(), start) >= timeout:
#print("no rx after %s" % msg)
raise RuntimeError
await asyncio.sleep_ms(5)
return await self.stream.readexactly(num)
async def txrx(self, msg, timeout=250):
# Send a command, get the corresponding response.
# - has a long timeout, collects rx based on framing
@ -310,13 +398,8 @@ class QRScanner:
expect = LEN_OKAY
rx = b''
while 1:
try:
rx += await asyncio.wait_for_ms(self.stream.readexactly(expect), timeout)
except asyncio.TimeoutError:
if timeout is None:
continue
#print("no rx after %s" % msg)
raise RuntimeError
rx += await self.readexactly_timeout(expect, timeout, msg)
#print('txrx << ' + B2A(rx))

View File

@ -33,6 +33,9 @@ from ucollections import namedtuple
# seed words lengths we support: 24=>256 bits, and recommended
VALID_LENGTHS = (24, 18, 12)
# maximum length for BIP-39 passphrase
MAX_PASS_LEN = 100
# bit flag that means "also include bare prefix as a valid word"
_PREFIX_MARKER = const(1<<26)
@ -1166,7 +1169,7 @@ class EphemeralSeedMenu(MenuSystem):
from actions import nfc_recv_ephemeral, import_xprv
from actions import restore_backup, scan_any_qr
from tapsigner import import_tapsigner_backup_file
from xor_seed import xor_restore_start
from xor_seed import xor_restore_temporary
from charcodes import KEY_QR
import_ephemeral_menu = [
@ -1190,7 +1193,7 @@ class EphemeralSeedMenu(MenuSystem):
MenuItem("Import XPRV", f=import_xprv, arg=True), # ephemeral=True
MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True
MenuItem("Coldcard Backup", f=restore_backup, arg=True), # tmp=True
MenuItem("Restore Seed XOR", f=xor_restore_start),
MenuItem("Restore Seed XOR", f=xor_restore_temporary),
]
return rv
@ -1235,10 +1238,10 @@ the passphrase as well, it's okay to put them together.) There is no way for \
the Coldcard to know if your entry is correct, and if you have it wrong, \
you will be looking at an empty wallet.
Limitations: 100 characters max length, ASCII characters 32-126 (0x20-0x7e) only.
Limitations: %d characters max length, ASCII characters 32-126 (0x20-0x7e) only.
%s to continue or press (2) to hide this message forever.
''' % (howto if not version.has_qwerty else '', OK)
''' % (howto if not version.has_qwerty else '', MAX_PASS_LEN, OK)
ch = await ux_show_story(msg, escape='2')
if ch == '2':
@ -1248,8 +1251,8 @@ Limitations: 100 characters max length, ASCII characters 32-126 (0x20-0x7e) only
if version.has_qwerty and not PassphraseSaver.has_file():
# no need for any menus if Q and no card present
pp = await ux_input_text('', prompt="Your BIP-39 Passphrase",
b39_complete=True, scan_ok=True, max_len=100)
pp = await ux_input_text('', prompt="Your BIP-39 Passphrase", b39_complete=True,
scan_ok=True, max_len=MAX_PASS_LEN)
if not pp: return
await apply_pass_value(pp)
@ -1259,7 +1262,7 @@ Limitations: 100 characters max length, ASCII characters 32-126 (0x20-0x7e) only
class PassphraseMenu(MenuSystem):
# Collect up to 100 chars as a BIP-39 passphrase
# Collect up to MAX_PASS_LEN chars as a BIP-39 passphrase
# singleton (cls level) vars
done_cb = None
@ -1348,7 +1351,7 @@ class PassphraseMenu(MenuSystem):
async def view_edit_phrase(cls, *a):
# let them control each character
pw = await ux_input_text(cls.pp_sofar, prompt="Your BIP-39 Passphrase",
b39_complete=True, scan_ok=True, max_len=100)
b39_complete=True, scan_ok=True, max_len=MAX_PASS_LEN)
if pw is not None:
cls.pp_sofar = pw
cls.check_length()
@ -1359,8 +1362,8 @@ class PassphraseMenu(MenuSystem):
@classmethod
def check_length(cls):
# enforce a limit of 100 chars
cls.pp_sofar = cls.pp_sofar[0:100]
# enforce a limit of MAX_PASS_LEN chars
cls.pp_sofar = cls.pp_sofar[0:MAX_PASS_LEN]
@classmethod
async def add_text(cls, _1, _2, item):

View File

@ -195,41 +195,43 @@ def disassemble(script):
try:
offset = 0
slen = len(script)
while 1:
if offset >= len(script):
if offset >= slen:
#print('dis %d done' % offset)
return
c = script[offset]
offset += 1
if 1 <= c <= 75:
#print('dis %d: bytes=%s' % (offset, b2a_hex(script[offset:offset+c])))
yield (script[offset:offset+c], None)
offset += c
cnt = c
elif OP_1 <= c <= OP_16:
# OP_1 thru OP_16
#print('dis %d: number=%d' % (offset, (c - OP_1 + 1)))
yield (c - OP_1 + 1, None)
continue
elif c == OP_PUSHDATA1:
cnt = script[offset]
offset += 1
yield (script[offset:offset+cnt], None)
offset += cnt
elif c == OP_PUSHDATA2:
# up to 65535 bytes
cnt, = struct.unpack_from("H", script, offset)
offset += 2
yield (script[offset:offset+cnt], None)
offset += cnt
elif c == OP_PUSHDATA4:
# no where to put so much data
raise NotImplementedError
elif c == OP_1NEGATE:
yield (-1, None)
continue
else:
# OP_0 included here
#print('dis %d: opcode=%d' % (offset, c))
yield (None, c)
continue
# a data push of `cnt` bytes - reject if it runs off the end
if offset + cnt > slen:
raise ValueError
yield (script[offset:offset+cnt], None)
offset += cnt
except Exception as e:
# import sys;sys.print_exception(e)
raise ValueError("bad script")
@ -374,10 +376,12 @@ class CTxOut(object):
return AF_P2SH, self.scriptPubKey[2:2+20], False
if self.is_p2pk():
# rare, pay to full pubkey
return AF_BARE_PK, self.scriptPubKey[2:2+33], False
# rare, pay to full pubkey: <push_op> <pubkey> OP_CHECKSIG
# push_op is 0x21 (33) for compressed, 0x41 (65) for uncompressed
pk_len = self.scriptPubKey[0]
return AF_BARE_PK, self.scriptPubKey[1:1+pk_len], False
if self.scriptPubKey[0] == OP_RETURN:
if self.is_op_return():
return OP_RETURN, self.scriptPubKey, False
return None, self.scriptPubKey, None
@ -405,8 +409,11 @@ class CTxOut(object):
def is_p2pk(self):
return (len(self.scriptPubKey) == 35 or len(self.scriptPubKey) == 67) \
and (self.scriptPubKey[0] == 0x21 or self.scriptPubKey[0] == 0x41) \
and self.scriptPubKey[-1] == 0xac
and self.scriptPubKey[0] == len(self.scriptPubKey) - 2 \
and self.scriptPubKey[-1] == OP_CHECKSIG
def is_op_return(self):
return self.scriptPubKey and (self.scriptPubKey[0] == OP_RETURN)
#def __repr__(self):
# return "CTxOut(nValue=%d scriptPubKey=%s)" \

View File

@ -2,7 +2,7 @@
#
# sffile.py - file-like objects stored in PSRAM (Mk4+) (used to be SPI Flash)
#
# - implements stream IO protoccol
# - implements stream IO protocol
# - random read, sequential write
# - only a few of these are possible
# - the offset is the file name

View File

@ -343,17 +343,20 @@ async def kt_accept_values(dtype, raw):
# This will take over UX w/ the signing process
# flags=None --> whether to finalize is decided based on psbt.is_complete
sign_transaction(psbt_len, flags=None)
sign_transaction(psbt_len, flags=None, input_method="kt")
return
elif dtype == 'b':
# full system backup, including master: text lines
from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict, extract_raw_secret
vals = text_bk_parser(raw)
assert vals # empty?
raw_sec, _ = extract_raw_secret(vals)
try:
vals = text_bk_parser(raw)
assert vals # empty?
raw_sec, _ = extract_raw_secret(vals)
except Exception as e:
await ux_show_story("Invalid backup\n\n" + str(e), title='FAILED')
return
from flow import has_secrets
@ -638,7 +641,7 @@ class SecretPickerMenu(MenuSystem):
await kt_do_send(self.rx_pubkey, 's', raw=raw)
async def kt_send_psbt(psbt, psbt_len):
async def kt_send_psbt(psbt, psbt_len, psbt_offset):
# We just finished adding our signature to an incomplete PSBT.
# User wants to send to one or more other senders for them to complete signing.
@ -653,10 +656,8 @@ async def kt_send_psbt(psbt, psbt_len):
await ux_show_story("No more signers?")
return
# move out of PSRAM
from auth import TXN_OUTPUT_OFFSET
with SFFile(TXN_OUTPUT_OFFSET, psbt_len) as fd:
# (TXN_OUTPUT_OFFSET after signing, TXN_INPUT_OFFSET for the file-teleport path)
with SFFile(psbt_offset, psbt_len) as fd:
bin_psbt = fd.read(psbt_len)
my_xfp = settings.get('xfp')
@ -684,12 +685,12 @@ async def kt_send_psbt(psbt, psbt_len):
f = None
if x in need:
# we haven't signed ourselves yet, so allow that
from auth import sign_transaction, TXN_INPUT_OFFSET
from auth import sign_transaction
async def sign_now(*a):
# this will reset the UX stack:
# flags=None --> whether to finalize is decided based on psbt.is_complete
sign_transaction(psbt_len, flags=None)
sign_transaction(psbt_len, flags=None, input_method="kt", offset=psbt_offset)
f = sign_now
@ -781,6 +782,6 @@ async def kt_send_file_psbt(*a):
await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT")
return
await kt_send_psbt(psbt, psbt_len=psbt_len)
await kt_send_psbt(psbt, psbt_len=psbt_len, psbt_offset=TXN_INPUT_OFFSET)
# EOF

View File

@ -211,14 +211,14 @@ class TrickPinMgmt:
def update_slot(self, pin, new=False, new_pin=None, tc_flags=None, tc_arg=None, secret=None):
# create or update a trick pin
# - doesn't support wallet to no-wallet transitions
'''
>>> from pincodes import pa; pa.setup(b'12-12'); pa.login(); from trick_pins import *
'''
#
# from pincodes import pa; pa.setup(b'12-12'); pa.login(); from trick_pins import *
#
assert isinstance(pin, bytes)
b, slot = self.get_by_pin(pin)
if not slot:
if not new: raise KeyError("wrong pin")
assert new, "wrong pin"
# Making a new entry
b, slot = make_slot()
@ -398,17 +398,17 @@ class TrickPinMgmt:
continue
if flags & TC_DELTA_MODE:
prob = validate_delta_pin(true_pin, pin)
prob, arg = validate_delta_pin(true_pin, pin)
if prob:
# just forget it, no UI here to report issue
continue
continue
try:
# might need to construct a BIP-85 or XPRV secret to match
path, new_secret = construct_duress_secret(flags, arg)
b, slot = tp.update_slot(pin.encode(), new=True,
tc_flags=flags, tc_arg=arg, secret=new_secret)
tp.update_slot(pin.encode(), new=True, secret=new_secret,
tc_flags=flags, tc_arg=arg)
except: pass
@staticmethod

View File

@ -75,14 +75,7 @@ HOBBLED_CMDS = frozenset({
'enrl', # no new multisigs during policy enforcement
'back', # no backups
'bagi', 'dfu_', # just in case
"user", # same as HSM_DISABLE_CMDS
"rmur",
"nwur",
"gslr",
"hsts",
"hsms",
})
}) | HSM_DISABLE_CMDS
# singleton instance of USBHandler()
handler = None
@ -423,10 +416,12 @@ class USBHandler:
if cmd == 'dwld':
offset, length, fileno = unpack_from('<III', args)
assert len(args) == 12, 'badlen'
return await self.handle_download(offset, length, fileno)
if cmd == 'ncry':
version, his_pubkey = unpack_from('<I64s', args)
assert len(args) == 68, 'badlen'
return self.handle_crypto_setup(version, his_pubkey)
@ -456,9 +451,9 @@ class USBHandler:
if cmd == 'smsg':
# sign message
addr_fmt, len_subpath, len_msg = unpack_from('<III', args)
assert len(args) == (12 + len_subpath + len_msg), 'badlen'
subpath = args[12:12+len_subpath]
msg = args[12+len_subpath:]
assert len(msg) == len_msg, "badlen"
from auth import sign_msg
sign_msg(msg, subpath, addr_fmt)
@ -487,6 +482,7 @@ class USBHandler:
xfp_paths = []
for i in range(N):
assert offset < len(args), 'badlen'
ln = args[offset]
assert 1 <= ln <= 16, 'badlen'
xfp_paths.append(unpack_from('<%dI' % ln, args, offset+1))
@ -502,6 +498,7 @@ class USBHandler:
from auth import usb_show_address
addr_fmt, = unpack_from('<I', args)
assert len(args) >= 4, 'badlen'
# regression patch of AFC_BECH32M flag
# fixed here https://github.com/Coldcard/ckcc-protocol/commit/a6d901f9fca50755835eca895586ca74d0ca81ed
if addr_fmt == 0x17: # old P2TR
@ -513,6 +510,7 @@ class USBHandler:
# - text config file must already be uploaded
file_len, file_sha = unpack_from('<I32s', args)
assert len(args) == 36, 'badlen'
if file_sha != self.file_checksum.digest():
return b'err_Checksum'
assert 100 < file_len <= (20*200), "badlen"
@ -527,19 +525,20 @@ class USBHandler:
# Quick check to test if we have a wallet already installed.
from multisig import MultisigWallet
M, N, xfp_xor = unpack_from('<3I', args)
assert len(args) == 12, 'badlen'
return int(MultisigWallet.quick_check(M, N, xfp_xor))
if cmd == 'stxn':
# sign transaction
txn_len, flags, txn_sha = unpack_from('<II32s', args)
assert len(args) == 40, 'badlen'
if txn_sha != self.file_checksum.digest():
return b'err_Checksum'
assert 50 < txn_len <= MAX_TXN_LEN, "badlen"
from auth import sign_transaction
sign_transaction(txn_len, (flags & STXN_FLAGS_MASK), txn_sha)
sign_transaction(txn_len, (flags & STXN_FLAGS_MASK), txn_sha, input_method="usb")
return None
if cmd == 'stok' or cmd == 'bkok' or cmd == 'smok' or cmd == 'pwok':
@ -602,6 +601,8 @@ class USBHandler:
if cmd == 'rest':
# restore backup from what is already uploaded in PSRAM
file_len, file_sha, bf = unpack_from('<I32sB', args)
assert len(args) == 37, 'badlen'
assert 0 < file_len <= MAX_TXN_LEN, "badlen"
if file_sha != self.file_checksum.digest():
return b'err_Checksum'
@ -624,6 +625,7 @@ class USBHandler:
# HSM mode "start" -- requires user approval
if args:
file_len, file_sha = unpack_from('<I32s', args)
assert len(args) == 36, 'badlen'
if file_sha != self.file_checksum.digest():
return b'err_Checksum'
assert 2 <= file_len <= (200*1000), "badlen"
@ -651,6 +653,8 @@ class USBHandler:
if cmd == 'nwur': # new user
from users import Users
auth_mode, ul, sl = unpack_from('<BBB', args)
assert len(args) == (3 + ul + sl), 'badlen'
assert ul and sl, "badlen"
username = bytes(args[3:3+ul]).decode('ascii')
secret = bytes(args[3+ul:3+ul+sl])
@ -659,6 +663,8 @@ class USBHandler:
if cmd == 'rmur': # delete user
from users import Users
ul, = unpack_from('<B', args)
assert len(args) == (1 + ul), 'badlen'
assert ul, "badlen"
username = bytes(args[1:1+ul]).decode('ascii')
return Users.delete(username)
@ -666,6 +672,8 @@ class USBHandler:
if cmd == 'user': # auth user (HSM mode)
from users import Users
totp_time, ul, tl = unpack_from('<IBB', args)
assert len(args) == (6 + ul + tl), 'badlen'
assert ul and tl, "badlen"
username = bytes(args[6:6+ul]).decode('ascii')
token = bytes(args[6+ul:6+ul+tl])
@ -754,7 +762,8 @@ class USBHandler:
length = min(length, MAX_BLK_LEN)
assert 0 <= file_number < 2, 'bad fnum'
assert 0 <= offset <= MAX_TXN_LEN, "bad offset"
assert 0 <= offset < MAX_TXN_LEN, "bad offset"
assert offset + length <= MAX_TXN_LEN, "bad offset"
assert 1 <= length, 'len'
# maintain a running SHA256 over what's sent
@ -789,7 +798,8 @@ class USBHandler:
dis.progress_sofar(offset, total_size)
assert offset % 256 == 0, 'alignment'
assert offset+len(data) <= total_size <= MAX_UPLOAD_LEN, 'long'
assert 1 <= total_size <= MAX_UPLOAD_LEN, 'long'
assert offset + len(data) <= total_size, 'long'
if hsm_active or pa.hobbled_mode:
# additional restriction in HSM mode or hobbled: must be PSBT

View File

@ -78,7 +78,7 @@ KEY = 'usr'
UserInfo = namedtuple('UserInfo', 'auth_mode secret last_counter')
class Users:
'''Track users and thier TOTP secrets or hashed passwords'''
'''Track users and their TOTP secrets or hashed passwords'''
@classmethod
def get(cls):

View File

@ -8,7 +8,7 @@ from ubinascii import hexlify as b2a_hex
from ubinascii import a2b_base64, b2a_base64
from charcodes import OUT_CTRL_ADDRESS, OUT_CTRL_NOWRAP
from uhashlib import sha256
from public_constants import MAX_PATH_DEPTH, AF_CLASSIC
from public_constants import MAX_PATH_DEPTH, AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2TR
B2A = lambda x: str(b2a_hex(x), 'ascii')
@ -193,34 +193,31 @@ def str2xfp(txt):
# Inverse of xfp2str
return ustruct.unpack('<I', a2b_hex(txt))[0]
def is_ascii(s):
if len(s) == len(s.encode()):
return True
return False
def is_printable(s):
PRINTABLE = range(32, 127)
for ch in s:
if ord(ch) not in PRINTABLE:
o = ord(ch)
if o < 32 or o > 126:
return False
return True
def to_ascii_printable(s, strip=False, only_printable=True):
def to_ascii_printable(s, allow_tab_nl=False):
try:
s = str(s, 'ascii')
if strip:
s = s.strip()
assert is_ascii(s)
if only_printable:
# s must be a string!
assert len(s) == len(s.encode())
if not allow_tab_nl:
assert is_printable(s)
else:
for ch in s:
o = ord(ch)
assert 32 <= o <= 126 or o == 9 or o == 10
return s
except:
raise AssertionError("must be ascii" + (" printable" if only_printable else ""))
err = "must be ascii printable" + (", tab, or newline" if allow_tab_nl else "")
raise AssertionError(err)
def problem_file_line(exc):
# return a string of just the filename.py and line number where
# an exception occured. Best used on AssertionError.
# an exception occurred. Best used on AssertionError.
tmp = uio.StringIO()
sys.print_exception(exc, tmp)
@ -252,7 +249,7 @@ def cleanup_deriv_path(bin_path, allow_star=False):
# - do not assume /// is m/0/0/0
# - if allow_star, then final position can be * or *h (wildcard)
s = to_ascii_printable(bin_path, strip=True).lower()
s = to_ascii_printable(str(bin_path, "ascii").strip()).lower()
# empty string is valid
if s == '': return 'm'
@ -691,6 +688,35 @@ def decode_bip21_text(got):
raise ValueError('not bip-21')
def validate_own_address(addr):
ch = chains.current_chain()
addr_l = addr.lower()
if addr_l[:3] in ("bc1", "tb1") or addr_l[:5] == 'bcrt1':
try:
hrp, witver, data = ngu.codecs.segwit_decode(addr)
assert hrp == ch.bech32_hrp
assert witver == 0
if len(data) == 20:
return addr_l, AF_P2WPKH
if len(data) == 32:
return addr_l, AF_P2WSH
except: pass
# Bitcoin main/test/reg base58 address prefixes.
elif addr and addr[0] in '123mn':
try:
raw = ngu.codecs.b58_decode(addr)
assert len(raw) == 21
if raw[0] == ch.b58_addr[0]:
return addr, AF_CLASSIC
if raw[0] == ch.b58_script[0]:
return addr, AF_P2SH
except: pass
assert False, ch.name
def encode_seed_qr(words):
return ''.join('%04d' % bip39.get_word_index(w) for w in words)

View File

@ -349,7 +349,7 @@ async def show_qr_code(data, is_alnum=False, msg=None, **kw):
o = QRDisplaySingle([data], is_alnum, msg=msg, **kw)
await o.interact_bare()
async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False):
async def ux_enter_bip32_index(prompt, can_cancel=True, unlimited=False):
if unlimited:
max_value = (2 ** 31) - 1 # we handle hardened
else:

View File

@ -60,7 +60,7 @@ class PressRelease:
return ch
async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max

View File

@ -76,7 +76,7 @@ class PressRelease:
self.last_key = ch
return ch
async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
@ -121,7 +121,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
dis.text(0, 4, ' '*CHARS_W)
elif ch == KEY_CANCEL:
if can_cancel:
# quit if they press X on empty screen
# quit if they press CANCEL on any screen
return None
elif '0' <= ch <= '9':
if len(value) == max_w:
@ -578,7 +578,7 @@ def ux_draw_words(y, num_words, words):
if num_words == 12:
# luxious space after colon
msg = ('%2d: ' % n) + word
x_off = 3
x_off = 4
else:
if n <= n_per_c:
# no space in front of 1: thru N: in leftmost column of 3
@ -667,7 +667,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None, li
what, vals = decode_qr_result(got, expect_secret=True)
except QRDecodeExplained as e:
err_msg = str(e)
redraw_words()
redraw_words(words)
continue
if what != "words":
@ -825,7 +825,6 @@ class QRScannerInteraction:
while 1:
if task.done():
data = await task
#print("Scanned: %r" % data)
break
dis.image(None, 40, 'scan_%d' % frames[ph])
@ -838,7 +837,12 @@ class QRScannerInteraction:
data = None
break
task.cancel()
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# clear screen right away so user knows we got it
dis.clear()
@ -881,7 +885,7 @@ class QRScannerInteraction:
file_type, _, data = decode_qr_result(got, expect_bbqr=True)
if file_type == 'U':
data = data.strip()
if data[0] == '{' and data[-1] == '}':
if data[:1] == b'{' and data[-1:] == b'}':
file_type = 'J'
if file_type != 'J':
raise QRDecodeExplained('Expected JSON data')
@ -1057,7 +1061,7 @@ async def qr_psbt_sign(decoder, psbt_len, raw):
psbt_len = total
else:
with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
with SFFile(TXN_INPUT_OFFSET, length=psbt_len) as out:
taste = out.read(10)
_, output_encoder, _ = psbt_encoding_taster(taste, psbt_len)
@ -1109,20 +1113,22 @@ async def ux_visualize_bip21(proto, addr, args):
# - imho, a bare address is a valid BIP-21 URL so we come here too
# - validate address ownership on request
from ux import ux_show_story
from chains import current_chain
msg = show_single_address(addr) + '\n\n'
args = args or {}
if 'amount' in args:
msg += 'Amount: '
try:
amt = args.pop('amount')
whole, frac = amt.split('.', 1)
frac = int(frac) if frac else 0
whole = int(whole) if whole else 0
msg += '%d.%08d BTC\n' % (whole, frac)
whole, _, frac = amt.partition('.')
assert whole.isdigit()
assert len(whole) <= 8
assert len(frac) <= 8
sats = int((whole or '0') + (frac + '00000000')[:8])
msg += 'Amount: %s %s\n' % current_chain().render_value(sats)
except:
msg += '(corrupt)\n'
msg += 'Amount: (corrupt)\n'
for fn in ['label', 'message', 'lightning']:
if fn in args:
@ -1199,7 +1205,6 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
from ux import ux_wait_keydown
import uqr
assert not PSRAM.is_at(data, 0) # input data would be overwritten with our work
assert type_code in TYPE_LABELS
dis.fullscreen('Generating BBQr...', .1)
@ -1210,6 +1215,11 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
else:
# default to Base32, because always best option
encoding = '2'
if isinstance(data, str):
# 'U'/'J' payloads are UTF-8 text; b32encode consumes the UTF-8
# bytes, so convert now to keep length/slicing consistent (else
# multi-byte chars overflow target_vers -> assert below trips)
data = data.encode()
data_len = len(data)
# try a few select resolutions (sizes) in order such that we use either single QR

View File

@ -38,7 +38,7 @@ class MasterSingleSigWallet(WalletABC):
def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None):
# Construct a wallet based on current master secret, and chain.
# - path is optional, and then we use standard path for addr_fmt
# - path can be overriden when we come here via address explorer
# - path can be overridden when we come here via address explorer
n = chains.addr_fmt_label(addr_fmt)
if not version.has_qwerty:

View File

@ -9,9 +9,10 @@ from utils import problem_file_line, show_single_address, node_from_pubkey
from files import CardSlot, CardMissingError, needs_microsd
from glob import settings
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from public_constants import AF_P2WPKH
from public_constants import AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2SH
from msgsign import msg_signing_done
MAX_ITEMS = 30
def decode_wif(wif):
# Decode base58 encoded WIF string, return keypair and metadata
@ -33,7 +34,7 @@ def decode_wif(wif):
return kp, testnet, compressed
def iter_wif_store_addresses(chain, addr_fmt):
def iter_wif_store_addresses(addr_fmt):
# nothing found among singlesig & registered multisig wallets
# check WIF store
wifs = settings.get("wifs", [])
@ -41,7 +42,37 @@ def iter_wif_store_addresses(chain, addr_fmt):
for i, (pk, sk) in enumerate(wifs):
node = node_from_pubkey(a2b_hex(pk))
yield i, chain.address(node, addr_fmt)
yield i, chains.current_chain().address(node, addr_fmt)
def save_wif_store_items(new_wifs):
saved = settings.get("wifs", [])
len_saved = len(saved)
unique = []
dups = 0
for item in new_wifs:
if item in unique:
continue
if item not in saved:
unique.append(item)
else:
dups += 1
err = ("No valid WIF key found." + (" Contains duplicate WIF(s)" if dups else ""))
assert unique, err
err = ("Max %d items allowed in WIF Store.\n\nAttempted to import %d keys,"
" while remaining WIF store capacity is only %d. Please, make room"
" first." % (MAX_ITEMS, len(unique), MAX_ITEMS - len_saved))
assert (len_saved + len(unique)) <= MAX_ITEMS, err
saved.extend(unique)
settings.set('wifs', saved)
settings.save()
return len(unique)
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
@ -58,27 +89,24 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet):
ch = await ux_show_story(msg, title="WIF Key", escape=esc)
if ch == "1":
saved = settings.get("wifs", [])
if (pk, sk) in saved:
await ux_show_story("Already saved in WIF Store.", title="Failure")
return
title = "Success"
try:
save_wif_store_items([(pk, sk)])
msg = "Saved to WIF Store."
except Exception as e:
title = "Failure"
msg = str(e)
saved.append((pk, sk))
settings.set('wifs', saved)
settings.save()
await ux_show_story("Saved to WIF Store.", title="Success")
await ux_show_story(msg, title=title)
class WIFStore(MenuSystem):
MAX_ITEMS = 30
class WIFStoreMenu(MenuSystem):
def __init__(self):
items = self.construct()
super().__init__(items)
@classmethod
async def make_menu(cls, *a):
async def make(cls, *a):
if not settings.get("wifs", None):
intro = ("Individual private keys, encoded as WIF (Wallet Import Format) keys"
" can be imported and used for signing. Any PSBT that uses a WIF stored here"
@ -104,7 +132,7 @@ class WIFStore(MenuSystem):
items = []
if len(wifs) < self.MAX_ITEMS:
if len(wifs) < MAX_ITEMS:
items.append(MenuItem('Import WIF', f=self.import_wif, predicate=not_hobbled_mode))
a_items = []
@ -115,6 +143,7 @@ class WIFStore(MenuSystem):
submenu = [
MenuItem("Detail", f=self.detail, arg=(wif,pk,sk)),
MenuItem("Descriptors", f=self.show_desc_step1, arg=pk),
MenuItem("Addresses", f=self.show_addr_step1, arg=pk),
MenuItem("Sign MSG", f=self.sign_msg_step1, arg=sk),
MenuItem('Delete', f=self.delete, arg=(i, pk), predicate=not_hobbled_mode),
@ -144,40 +173,51 @@ class WIFStore(MenuSystem):
await export_contents(title, wif, "wif.txt", None, None,
force_prompt=True, intro=msg, ux_title=title)
async def show_addr_step1(self, a, b, item):
pubkey = a2b_hex(item.arg)
async def show_desc_step1(self, a, b, item):
rv = [
MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(pubkey, af))
MenuItem(chains.addr_fmt_label(af), f=self.show_desc_step2, arg=(item.arg, af))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
async def show_desc_step2(self, a, b, item):
# allow to export pubkey, instead of main detail where WIF is exported
pk, af = item.arg
title = "Descriptor"
if af == AF_P2WPKH:
desc = "wpkh(%s)"
elif af == AF_CLASSIC:
desc = "pkh(%s)"
else:
assert af == AF_P2WPKH_P2SH
desc = "sh(wpkh(%s))"
from descriptor import append_checksum
desc = append_checksum(desc % pk)
from export import export_contents
await export_contents(title, desc, "wif_desc_%d.txt" % af, None, None,
force_prompt=True, intro=desc, ux_title=title)
async def show_addr_step1(self, a, b, item):
rv = [
MenuItem(chains.addr_fmt_label(af), f=self.show_addr_step2, arg=(item.arg, af))
for af in chains.SINGLESIG_AF
]
the_ux.push(MenuSystem(rv))
async def show_addr_step2(self, a, b, item):
from glob import NFC
pubkey, af = item.arg
node = node_from_pubkey(pubkey)
node = node_from_pubkey(a2b_hex(pubkey))
addr = chains.current_chain().address(node, af)
msg = show_single_address(addr) + "\n\n"
msg = show_single_address(addr)
escape = ""
# Q only hint keys
if not version.has_qwerty:
msg += "Press (1) to show address QR code."
escape += "1"
if NFC:
msg += "(3) to share via NFC."
escape += "3"
ux_title = chains.addr_fmt_label(af) if version.has_qwerty else None
title = chains.addr_fmt_label(af) if version.has_qwerty else None
while True:
ch = await ux_show_story(msg, title=title, escape=escape,
hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
if ch == "x": return
if ch in "1"+KEY_QR:
await show_qr_code(addr, is_alnum=af == AF_P2WPKH)
elif NFC and (ch in "3"+KEY_NFC):
await NFC.share_text(addr)
from export import export_contents
await export_contents("Address", addr, "wif_addr.txt", None, None,
force_prompt=True, intro=msg, ux_title=ux_title)
async def sign_msg_step1(self, a, b, item):
privkey = a2b_hex(item.arg)
@ -224,16 +264,17 @@ class WIFStore(MenuSystem):
return
idx, pubkey = item.arg
wifs = settings.get('wifs', {})
wifs = settings.get('wifs', [])
if not wifs: return
try:
item = wifs[idx]
assert item[0] == pubkey
entry = wifs[idx]
assert entry[0] == pubkey
del wifs[idx]
settings.set('wifs', wifs)
settings.save()
except IndexError: pass
except IndexError:
return
the_ux.pop() # pop submenu
self.update_contents()
@ -298,12 +339,8 @@ class WIFStore(MenuSystem):
# allow commas, spaces, and newlines as separators
got = got.replace(',', ' ').split()
saved = settings.get("wifs", [])
len_saved = len(saved)
try:
new_wifs = []
dups = 0
for here in got:
here = here.strip()
@ -322,28 +359,10 @@ class WIFStore(MenuSystem):
sk = b2a_hex(kp.privkey()).decode()
pk = b2a_hex(kp.pubkey().to_bytes()).decode()
item = (pk, sk)
if item in new_wifs:
# duplicate in import content
continue
new_wifs.append((pk, sk))
if item in saved: # ignore dups
dups += 1
else:
new_wifs.append(item)
save_wif_store_items(new_wifs)
assert new_wifs, 'no valid WIF found' if not dups else 'duplicate WIF(s)'
if (len_saved + len(new_wifs)) > self.MAX_ITEMS:
await ux_show_story("Max %d items allowed in WIF Store.\n\nAttempted to import %d keys,"
" while remaining WIF store capacity is only %d. Please, make room"
" first." % (self.MAX_ITEMS, len(new_wifs), self.MAX_ITEMS - len_saved),
title="Failure")
return
saved.extend(new_wifs)
settings.set('wifs', saved)
settings.save()
self.update_contents()
except Exception as e:
@ -351,13 +370,54 @@ class WIFStore(MenuSystem):
title="Failure")
def init_wif_store():
# stored as hex strings, need load to bytes
wifs = settings.get('wifs', [])
if not wifs: return {}
res = {}
for pk, sk in wifs:
res[a2b_hex(pk)] = a2b_hex(sk)
return res
class WIFStore:
def __init__(self):
wifs = settings.get('wifs', [])
self.wifs = [] # max 30 items, each (pubkey, privkey)
for pk, sk in wifs:
self.wifs.append((a2b_hex(pk), a2b_hex(sk)))
# built lazily, on first match_address_hash() call
self._pkh = [] # hash160(pubkey) — P2PKH / P2WPKH
self._sh = [] # hash160(0014 || _pkh) — P2SH-P2WPKH
def __bool__(self):
return len(self.wifs) > 0
def __contains__(self, pubkey):
return self._privkey_for(pubkey) is not None
def __getitem__(self, pubkey):
sk = self._privkey_for(pubkey)
if sk is None: raise KeyError
return sk
def _privkey_for(self, pubkey):
for pk, sk in self.wifs:
if pk == pubkey:
return sk
def match_address_hash(self, addr_fmt, hash20):
if not self.wifs:
return None
if not self._pkh:
self._pkh = [ngu.hash.hash160(pk) for pk, _ in self.wifs]
if addr_fmt in (AF_P2WPKH, AF_CLASSIC):
table = self._pkh
elif addr_fmt == AF_P2SH:
if not self._sh:
self._sh = [ngu.hash.hash160(b'\x00\x14' + h) for h in self._pkh]
table = self._sh
else:
return None # AF_P2WSH / AF_P2TR / AF_BARE_PK / unknown — not us
try:
idx = table.index(hash20)
return idx, self.wifs[idx][0]
except ValueError:
return None
# EOF

View File

@ -124,7 +124,7 @@ You have confirmed the details of the new split.''')
# - stores encoded secret bytes (not word lists)
import_xor_parts = []
async def xor_all_done(data):
async def xor_all_done(data, force_tmp, done_cb):
# So we have another part, might be done or not.
global import_xor_parts
@ -178,9 +178,9 @@ async def xor_all_done(data):
if version.has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry("Part %s Words" % chr(65+len(import_xor_parts)),
target_words, done_cb=xor_all_done)
target_words, done_cb=done_cb)
else:
nxt = XORWordNestMenu(num_words=target_words, done_cb=xor_all_done)
nxt = XORWordNestMenu(num_words=target_words, done_cb=done_cb)
the_ux.push(nxt)
elif ch == '2':
@ -190,7 +190,7 @@ async def xor_all_done(data):
enc = SecretStash.encode(seed_phrase=seed)
if pa.is_secret_blank():
if pa.is_secret_blank() and not force_tmp:
# save it since they have no other secret
set_seed_value(encoded=enc)
# update menu contents now that wallet defined
@ -239,7 +239,7 @@ async def show_n_parts(parts, chk_word):
return await ux_show_story(msg, title="Record these:", sensitive=True, escape="4",
hint_icons=KEY_QR)
async def xor_restore_start(*a):
async def xor_restore_start(*a, force_tmp=False):
# shown on import menu when no seed of any kind yet
# - or operational system
ch = await ux_show_story('''\
@ -261,6 +261,9 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
global import_xor_parts
import_xor_parts.clear()
async def done_cb(data):
return await xor_all_done(data, force_tmp=force_tmp, done_cb=done_cb)
from pincodes import pa
from glob import dis
@ -317,14 +320,17 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
if selected:
import_xor_parts += [opt[i][-1] for i in range(len(opt)) if i in selected]
return await xor_all_done(None)
return await done_cb(None)
if version.has_qwerty:
from ux_q1 import seed_word_entry
# if current loaded seed is added to xor - it is always A
await seed_word_entry("Part %s Words" % (chr(65+len(import_xor_parts))),
desired_num_words, done_cb=xor_all_done)
desired_num_words, done_cb=done_cb)
else:
return XORWordNestMenu(num_words=desired_num_words, done_cb=xor_all_done)
return XORWordNestMenu(num_words=desired_num_words, done_cb=done_cb)
async def xor_restore_temporary(*a):
return await xor_restore_start(*a, force_tmp=True)
# EOF

View File

@ -0,0 +1,81 @@
#!/usr/bin/env python3
#
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Capture current (mainnet) block height for SSSP/CCC features
#
import sys, time, datetime
import urllib.request
FILE_NAME = "../shared/block_height.py"
def _get_block_height(url):
with urllib.request.urlopen(url) as response:
height_data = response.read().decode().strip()
return int(height_data)
def get_block_height(url):
try:
return _get_block_height(url)
except:
time.sleep(2)
return _get_block_height(url)
def parse_block_height_file():
with open(FILE_NAME, "r") as f:
for l in f.readlines():
if l.startswith("BLOCK_HEIGHT ="):
return int(l.split("=")[-1].strip())
return None
def write_block_height_file(block_height):
now = datetime.datetime.now(datetime.timezone.utc)
with open(FILE_NAME, "wt") as f:
f.write('''\
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# AUTO-generated.
#
# As of %s UTC
BLOCK_HEIGHT = %d
# EOF
''' % (now.strftime("%Y-%m-%d %H:%M:%S"), block_height))
def main():
current_height = None
for _ in range(2):
bh_a = get_block_height("https://mempool.space/api/blocks/tip/height")
bh_b = get_block_height("https://blockstream.info/api/blocks/tip/height")
if bh_a == bh_b:
current_height = bh_a
break
time.sleep(5)
if current_height is None:
raise RuntimeError("Could not get current block height")
file_block_height = parse_block_height_file()
if file_block_height is None:
raise RuntimeError("Could not parse block height from file")
if current_height > file_block_height:
write_block_height_file(current_height)
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
# EOF

View File

@ -5,14 +5,14 @@
# Capture build time and version number into a number used as the timestamp on
# all created files for that Coldcard version.
#
import os, sys, time, datetime
import os, sys, datetime
out_fname, version = sys.argv[1:]
assert out_fname.endswith('.c'), out_fname
if os.path.exists(out_fname):
# to help deterministic builds, don't replace the file from git if verison # is right
# to help deterministic builds, don't replace the file from git if version # is right
with open(out_fname, 'rt') as fd:
if ('// version: %s\n' % version) in fd.read():
print("==> %s already version %s; not changing it" % (out_fname, version))
@ -22,7 +22,7 @@ if os.path.exists(out_fname):
today = datetime.date.today()
value = ((today.year - 1980) << 25) | (today.month << 21) | (today.day << 16)
# only 2second resolution for times, so can only support minor verion up to x.x.5 and hard to see
# only 2second resolution for times, so can only support minor version up to x.x.5 and hard to see
# anyway, let's omit ... worst case, use the date instead
ver = ''.join(v for v in version if v in '0123456789.') # strip letter codes from end
h, m, _ = [int(x) for x in ver.split('b')[0].split('.')]

View File

@ -82,6 +82,18 @@ $(BOARD)/file_time.c: make_filetime.py *-Makefile shared.mk
./make_filetime.py $(BOARD)/file_time.c $(VERSION_STRING)
cp $(BOARD)/file_time.c .
.PHONY: block_height
block_height:
@python3 make_block_height.py; \
if [ $$? -eq 0 ]; then \
echo "Block Height file already up-to-date."; \
else \
echo "Block Height file updated."; \
git commit -m "update block height" ../shared/block_height.py; \
fi
# Make a factory release: using key #1
# - when executed in a repro w/o the required key, it defaults to key zero
# - and that's what happens inside the Docker build
@ -91,7 +103,7 @@ production.bin: firmware-signed.bin Makefile
SUBMAKE = $(MAKE) -f $(PARENT_MKFILE)
.PHONY: release
release: submods-match code-committed
release: submods-match code-committed block_height
$(SUBMAKE) clean
$(SUBMAKE) repro
test -f built/production.bin
@ -118,7 +130,7 @@ rc1:
rc2: RC2_TIMESTAMP = $(shell date "+%F_%H%M")
rc2: RC2_FNAME = ./RC2-$(RC2_TIMESTAMP)-$(HW_MODEL)-coldcard.dfu
rc2: RC2_FNAME_FACT = ./RC2-$(RC2_TIMESTAMP)-$(HW_MODEL)-factory.dfu
rc2: submods-match code-committed
rc2: submods-match code-committed block_height
$(SUBMAKE) clean
$(SUBMAKE) repro
test -f built/production.bin

View File

@ -33,6 +33,7 @@ class Bitcoind:
self.userpass = None
self.supply_wallet = None
self.has_bdb = True
self.version = None
def start(self):
@ -51,7 +52,8 @@ class Bitcoind:
[
self.bitcoind_path,
# needed for newest master
# TODO legacy wallet will be deprecated in 29
# legacy wallet was deprecated in v29
# and removed completely in v30
"-deprecatedrpc=create_bdb",
"-regtest",
f"-datadir={self.datadir}",
@ -59,9 +61,9 @@ class Bitcoind:
"-fallbackfee=0.0002",
"-server=1",
"-keypool=1",
"-listen=0"
"-listen=0",
f"-port={self.p2p_port}",
f"-rpcport={self.rpc_port}"
f"-rpcport={self.rpc_port}",
]
)
signal.signal(signal.SIGTERM, self.cleanup)
@ -91,12 +93,14 @@ class Bitcoind:
pass
assert self.rpc.getblockchaininfo()['chain'] == 'regtest'
assert self.rpc.getnetworkinfo()['version'] >= 220000, "we require >= 22.0 of Core"
self.version = self.rpc.getnetworkinfo()['version']
assert self.version >= 220000, "we require >= 22.0 of Core"
# not descriptors so that we can do dumpwallet
try:
self.supply_wallet = self.create_wallet(wallet_name="supply", descriptors=False)
except JSONRPCException as e:
assert "BDB wallet creation is deprecated" in str(e)
assert "BDB wallet creation is deprecated" in str(e) \
or "no longer possible to create a legacy wallet" in str(e) # before v30.0 vs v30.0+
self.has_bdb = False
self.supply_wallet = self.create_wallet(wallet_name="supply", descriptors=True)
@ -172,8 +176,9 @@ def match_key(bitcoind, set_master_key, reset_seed_words):
os.unlink(fn)
except JSONRPCException as e:
print(str(e))
assert "Only legacy wallets are supported by this command" in str(e)
assert "Only legacy wallets are supported by this command" in str(e) \
or "Method not found" in str(e) # v30.0
prv_descs = bitcoind.supply_wallet.listdescriptors(True) # True --> show private
prv = prv_descs["descriptors"][0]["desc"].replace("pkh(", "").split("/")[0]

View File

@ -2,14 +2,18 @@
#
# construct Proof of Reserves transaction according to BIP-322
#
import pytest, struct, hashlib
import struct, hashlib
from ckcc_protocol.protocol import MAX_TXN_LEN
from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput
from io import BytesIO
from helpers import hash160, taptweak, str_to_path
from helpers import hash160, str_to_path, taptweak
from bip32 import BIP32Node, PublicKey
from constants import simulator_fixed_tprv, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
from ctransaction import CTransaction, COutPoint, CTxIn, CTxOut, uint256_from_str
from sighash import legacy_sighash, segwit_v0_sighash, taproot_sighash, SIGHASH_DEFAULT, SIGHASH_ALL
from pysecp256k1 import ec_pubkey_parse, ecdsa_signature_parse_der, ecdsa_verify
from pysecp256k1.extrakeys import xonly_pubkey_parse
from pysecp256k1.schnorrsig import schnorrsig_verify
def bip322_msg_hash(msg):
@ -17,248 +21,402 @@ def bip322_msg_hash(msg):
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
@pytest.fixture
def create_msg_file(sim_root_dir, garbage_collector):
def doit(msg, msg_hash):
# carelessly overwrites
fpath = f"{sim_root_dir}/MicroSD/{msg_hash.hex()}.txt"
with open(fpath, "w") as f:
f.write(msg.decode())
garbage_collector.append(fpath)
return doit
def ecdsa_verify_sig(pubkey, sig, digest):
if not sig or sig[-1] != SIGHASH_ALL:
return False
try:
parsed = ecdsa_signature_parse_der(sig[:-1])
return bool(ecdsa_verify(parsed, ec_pubkey_parse(pubkey), digest))
except Exception:
return False
@pytest.fixture
def bip322_txn(dev, pytestconfig, create_msg_file):
def bip322_verify(psbt_bytes):
"""Verify BIP-322 PSBT signatures without a full script interpreter.
def doit(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0,
sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0):
Enforces the BIP-322 transaction shape, SIGHASH_ALL for ECDSA,
SIGHASH_DEFAULT/SIGHASH_ALL for taproot, and direct signature checks for
p2pkh, p2wpkh, p2sh-p2wpkh, sh, wsh, and p2tr key-path.
It intentionally omits consensus-level script evaluation rules such as
CLEANSTACK, MINIMALIF, NULLFAIL beyond empty CHECKMULTISIG dummy,
CODESEPARATOR/FindAndDelete handling, and NOP-upgrade checks; unsupported
scripts raise AssertionError.
"""
psbt = BasicPSBT().parse(psbt_bytes)
assert psbt.bip322_msg is not None
msg = psbt.bip322_msg
tx = CTransaction()
if psbt.txn:
tx.deserialize(BytesIO(psbt.txn))
else:
tx.nVersion = psbt.txn_version
tx.nLockTime = psbt.fallback_locktime or 0
for inp in psbt.inputs:
tx.vin.append(CTxIn(COutPoint(uint256_from_str(inp.previous_txid), inp.prevout_idx),
nSequence=inp.sequence if inp.sequence is not None else 0xffffffff))
for out in psbt.outputs:
tx.vout.append(CTxOut(out.amount, out.script))
msg_challenge = None
inp0 = psbt.inputs[0]
to_spend = None
if inp0.utxo:
to_spend = CTransaction()
to_spend.deserialize(BytesIO(inp0.utxo))
assert len(to_spend.vout) == 1
assert to_spend.vout[0].nValue == 0
script_pubkey = to_spend.vout[0].scriptPubKey
else:
assert inp0.witness_utxo
witness_utxo = CTxOut()
witness_utxo.deserialize(BytesIO(inp0.witness_utxo))
assert witness_utxo.nValue == 0
script_pubkey = witness_utxo.scriptPubKey
num_ins = len(inputs)
expected_to_spend = CTransaction()
expected_to_spend.nVersion = 0
expected_to_spend.nLockTime = 0
expected_to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff),
scriptSig=b'\x00\x20' + bip322_msg_hash(msg),
nSequence=0)]
expected_to_spend.vout = [CTxOut(0, script_pubkey)]
expected_to_spend.calc_sha256()
if to_spend:
assert to_spend.serialize_without_witness() == expected_to_spend.serialize_without_witness()
to_spend = expected_to_spend
psbt = BasicPSBT()
assert tx.nVersion in (0, 2)
assert len(tx.vin) >= 1
assert tx.vin[0].prevout.hash == to_spend.sha256
assert tx.vin[0].prevout.n == 0
assert not (len(tx.vin) == 1 and (tx.vin[0].nSequence != 0 or tx.nLockTime != 0))
assert len(tx.vout) == 1
assert tx.vout[0].nValue == 0
assert tx.vout[0].scriptPubKey == b'\x6a'
to_sign = CTransaction()
to_sign.nLockTime = to_sign_lock_time
# must be set to 2 if BIP-68 is used (relative tx level lock)
to_sign.nVersion = to_sign_nVersion
master_xpub = dev.master_xpub or simulator_fixed_tprv
# we have a key; use it to provide "plausible" value inputs
mk = BIP32Node.from_wallet_key(master_xpub)
mfp = mk.fingerprint()
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
psbt.outputs = []
for i, inp in enumerate(inputs):
sp = f"0/{i}"
af = addr_fmt
ia = input_amount
pubkey = None # public key
try:
if inp[0] is not None:
af = inp[0]
if inp[1] is not None:
sp = inp[1]
if inp[2] is not None:
ia = inp[2]
if inp[3] is not None:
pubkey = inp[3]
except:
pass
if pubkey:
int_path = [0]
sec = pubkey
prevouts = []
for idx, txin in enumerate(tx.vin):
if idx == 0:
prevouts.append((0, script_pubkey))
else:
assert idx < len(psbt.inputs)
if psbt.inputs[idx].witness_utxo:
prev = CTxOut()
prev.deserialize(BytesIO(psbt.inputs[idx].witness_utxo))
else:
int_path = str_to_path(sp)
sec = mk.subkey_for_path(sp).sec()
prev_tx = CTransaction()
prev_tx.deserialize(BytesIO(psbt.inputs[idx].utxo))
prev = prev_tx.vout[txin.prevout.n]
prevouts.append((prev.nValue, prev.scriptPubKey))
subkey = PublicKey.parse(sec)
for idx, txin in enumerate(tx.vin):
amount, spk = prevouts[idx]
assert len(sec) == 33, "expect compressed"
inp = psbt.inputs[idx]
if len(spk) == 25 and spk[:3] == b'\x76\xa9\x14' and spk[-2:] == b'\x88\xac':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == spk[3:23]
assert ecdsa_verify_sig(pub, sig, legacy_sighash(tx, idx, spk))
continue
if af == "p2tr":
tweaked_xonly = taptweak(sec[1:])
psbt.inputs[i].taproot_bip32_paths[sec[1:]] = b"\x00" + mfp + struct.pack(f'<{"I" * len(int_path)}',
*int_path)
scr = bytes([81, 32]) + tweaked_xonly
if len(spk) == 22 and spk[:2] == b'\x00\x14':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == spk[2:22]
script_code = b'\x76\xa9\x14' + spk[2:22] + b'\x88\xac'
assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount))
continue
elif af in ("p2wpkh", "p2sh-p2wpkh", "p2wpkh-p2sh"):
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path)
scr = bytes([0x00, 0x14]) + subkey.h160()
if af != "p2wpkh":
# use classic p2wpkh (from above) as redeem script
psbt.inputs[i].redeem_script = scr
scr = bytes([0xa9, 0x14]) + hash160(scr) + bytes([0x87])
elif af == "p2pkh":
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path)
scr = bytes([0x76, 0xa9, 0x14]) + subkey.h160() + bytes([0x88, 0xac])
if len(spk) == 34 and spk[:2] == b'\x00\x20':
assert inp.witness_script
assert hashlib.sha256(inp.witness_script).digest() == spk[2:34]
assert inp.part_sigs
sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
if len(spk) == 34 and spk[:2] == b'\x51\x20':
assert inp.taproot_key_sig
if len(inp.taproot_key_sig) == 64:
sighash = SIGHASH_DEFAULT
sig = inp.taproot_key_sig
else:
raise ValueError("unknown addr_fmt %s" % af)
assert len(inp.taproot_key_sig) == 65
sighash = inp.taproot_key_sig[-1]
sig = inp.taproot_key_sig[:-1]
digest = taproot_sighash(tx, idx, prevouts, sighash)
assert schnorrsig_verify(sig, digest, xonly_pubkey_parse(spk[2:34]))
continue
if i == 0:
# first input always spends to_spend
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
create_msg_file(msg, msg_hash)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, scr)] # always zero val
msg_challenge = scr
else:
# other outputs that we want to prove ownership
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)),
73
)
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
to_spend.vout.append(CTxOut(int(ia), scr))
if len(spk) == 23 and spk[:2] == b'\xa9\x14' and spk[-1:] == b'\x87':
assert inp.redeem_script
assert hash160(inp.redeem_script) == spk[2:22]
if len(inp.redeem_script) == 22 and inp.redeem_script[:2] == b'\x00\x14':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == inp.redeem_script[2:22]
script_code = b'\x76\xa9\x14' + inp.redeem_script[2:22] + b'\x88\xac'
assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount))
continue
if len(inp.redeem_script) == 34 and inp.redeem_script[:2] == b'\x00\x20':
assert inp.witness_script
assert inp.redeem_script == b'\x00\x20' + hashlib.sha256(inp.witness_script).digest()
assert inp.part_sigs
sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
assert inp.part_sigs
sighash = legacy_sighash(tx, idx, inp.redeem_script)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
assert False, "unsupported script"
if sighash is not None:
psbt.inputs[i].sighash = sighash
def bip322_txn(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0,
sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0,
psbt_v2=False, master_xpub=None):
to_spend.calc_sha256()
msg_challenge = None
if i in witness_utxo:
psbt.inputs[i].witness_utxo = to_spend.vout[-1].serialize()
else:
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
num_ins = len(inputs)
psbt = BasicPSBT()
psbt.bip322_msg = msg
to_sign = CTransaction()
to_sign.nLockTime = to_sign_lock_time
# must be set to 2 if BIP-68 is used (relative tx level lock)
to_sign.nVersion = to_sign_nVersion
master_xpub = master_xpub or simulator_fixed_tprv
# we have a key; use it to provide "plausible" value inputs
mk = BIP32Node.from_wallet_key(master_xpub)
mfp = mk.fingerprint()
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
psbt.outputs = []
for i, inp in enumerate(inputs):
sp = f"0/{i}"
af = addr_fmt
ia = input_amount
pubkey = None # public key
try:
if inp[0] is not None:
af = inp[0]
if inp[1] is not None:
sp = inp[1]
if inp[2] is not None:
ia = inp[2]
if inp[3] is not None:
pubkey = inp[3]
except:
pass
if pubkey:
int_path = [0]
sec = pubkey
else:
int_path = str_to_path(sp)
sec = mk.subkey_for_path(sp).sec()
subkey = PublicKey.parse(sec)
assert len(sec) == 33, "expect compressed"
if af == "p2tr":
tweaked_xonly = taptweak(sec[1:])
psbt.inputs[i].taproot_bip32_paths[sec[1:]] = b"\x00" + mfp + struct.pack(f'<{"I" * len(int_path)}',
*int_path)
scr = bytes([81, 32]) + tweaked_xonly
elif af in ("p2wpkh", "p2sh-p2wpkh", "p2wpkh-p2sh"):
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path)
scr = bytes([0x00, 0x14]) + subkey.h160()
if af != "p2wpkh":
# use classic p2wpkh (from above) as redeem script
psbt.inputs[i].redeem_script = scr
scr = bytes([0xa9, 0x14]) + hash160(scr) + bytes([0x87])
elif af == "p2pkh":
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path)
scr = bytes([0x76, 0xa9, 0x14]) + subkey.h160() + bytes([0x88, 0xac])
else:
raise ValueError("unknown addr_fmt %s" % af)
if i == 0:
# first input always spends to_spend
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, scr)] # always zero val
msg_challenge = scr
else:
# other outputs that we want to prove ownership
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)),
73
)
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
to_spend.vout.append(CTxOut(int(ia), scr))
if sighash is not None:
psbt.inputs[i].sighash = sighash
to_spend.calc_sha256()
if i in witness_utxo:
psbt.inputs[i].witness_utxo = to_spend.vout[-1].serialize()
else:
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
if len(inputs) == 1:
# basic msg sign
seq = 0
else:
if to_sign_lock_time and not i:
seq = 0xfffffffd
else:
seq = 0xffffffff
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
to_sign.vin.append(spendable)
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
to_sign.vin.append(spendable)
# just one zero amount output with script null data OP_RETURN
op_ret_o = BasicPSBTOutput(idx=0)
op_return_out = CTxOut(0, b'\x6a')
to_sign.vout.append(op_return_out)
# just one zero amount output with script null data OP_RETURN
op_ret_o = BasicPSBTOutput(idx=0)
op_return_out = CTxOut(0, b'\x6a')
to_sign.vout.append(op_return_out)
psbt.outputs.append(op_ret_o)
psbt.outputs.append(op_ret_o)
psbt.txn = to_sign.serialize_with_witness()
psbt.txn = to_sign.serialize_with_witness()
# last minute chance to mod PSBT object
if psbt_hacker:
psbt_hacker(psbt)
# last minute chance to mod PSBT object
if psbt_hacker:
psbt_hacker(psbt)
rv = BytesIO()
psbt.serialize(rv)
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
if psbt_v2:
psbt.parsed_txn = CTransaction()
psbt.parsed_txn.deserialize(BytesIO(psbt.txn))
psbt.to_v2()
return rv.getvalue(), msg_challenge
rv = BytesIO()
psbt.serialize(rv)
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
return doit
return rv.getvalue(), msg_challenge
@pytest.fixture
def bip322_ms_txn(pytestconfig, create_msg_file):
def bip322_ms_txn(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None,
lock_time=0, with_sigs=False, sighash=None, hack_psbt=None, to_sign_nVersion=0,
psbt_v2=False):
from test_multisig import make_ms_address
def doit(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None,
lock_time=0, with_sigs=False, sighash=None, hack_psbt=None, to_sign_nVersion=0):
msg_challenge = None
msg_challenge = None
psbt = BasicPSBT()
psbt.bip322_msg = msg
psbt = BasicPSBT()
txn = CTransaction()
txn.nVersion = to_sign_nVersion
txn.nLockTime = lock_time
txn = CTransaction()
txn.nVersion = to_sign_nVersion
txn.nLockTime = lock_time
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
psbt.outputs = []
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
psbt.outputs = []
for i in range(num_ins):
# make a fake txn to supply each of the inputs
# - each input is 1BTC
for i in range(num_ins):
# make a fake txn to supply each of the inputs
# - each input is 1BTC
# addr where the fake money will be stored.
addr, scriptPubKey, script, details = make_ms_address(
M, keys, idx=i, addr_fmt=inp_af, path_mapper=path_mapper
)
# addr where the fake money will be stored.
addr, scriptPubKey, script, details = make_ms_address(
M, keys, idx=i, addr_fmt=inp_af, path_mapper=path_mapper
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + hashlib.sha256(script).digest()
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack('<I', j) for j in xfp_path)
if with_sigs and (xfp_path[0] != keys[-1][0]) and len(psbt.inputs[i].part_sigs) < (M-1): # only cosigner signatures are added
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
if i == 0:
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout.append(CTxOut(0, scriptPubKey))
msg_challenge = scriptPubKey
else:
# other outputs that we want to prove ownership
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)),
73
)
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
to_spend.vout.append(CTxOut(int(input_amount), scriptPubKey))
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + hashlib.sha256(script).digest()
# always add whole txn as utxo
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
if sighash is not None and (i != 0):
psbt.inputs[i].sighash = sighash
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack('<I', j) for j in xfp_path)
if with_sigs and (xfp_path[0] != keys[-1][0]): # only cosigner signatures are added
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
if i == 0:
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
create_msg_file(msg, msg_hash)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout.append(CTxOut(0, scriptPubKey))
msg_challenge = scriptPubKey
else:
# other outputs that we want to prove ownership
to_spend = CTransaction()
to_spend.nVersion = 0
out_point = COutPoint(
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, i)),
73
)
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
to_spend.vout.append(CTxOut(int(input_amount), scriptPubKey))
# always add whole txn as utxo
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
if sighash is not None and (i != 0):
psbt.inputs[i].sighash = sighash
to_spend.calc_sha256()
to_spend.calc_sha256()
if num_ins == 1:
# basic msg sign
seq = 0
else:
if lock_time and not i:
seq = 0xfffffffd
else:
seq = 0xffffffff
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
txn.vin.append(spendable)
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
txn.vin.append(spendable)
# just one zero amount output with script null data OP_RETURN
op_ret_o = BasicPSBTOutput(idx=0)
op_return_out = CTxOut(0, b'\x6a')
txn.vout.append(op_return_out)
# just one zero amount output with script null data OP_RETURN
op_ret_o = BasicPSBTOutput(idx=0)
op_return_out = CTxOut(0, b'\x6a')
txn.vout.append(op_return_out)
psbt.outputs.append(op_ret_o)
psbt.outputs.append(op_ret_o)
if hack_psbt:
hack_psbt(psbt)
if hack_psbt:
hack_psbt(psbt)
psbt.txn = txn.serialize_with_witness()
psbt.txn = txn.serialize_with_witness()
if psbt_v2:
psbt.parsed_txn = CTransaction()
psbt.parsed_txn.deserialize(BytesIO(psbt.txn))
psbt.to_v2()
rv = BytesIO()
psbt.serialize(rv)
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
rv = BytesIO()
psbt.serialize(rv)
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
return rv.getvalue(), msg_challenge
return doit
return rv.getvalue(), msg_challenge

View File

@ -2,10 +2,12 @@
#
import pytest, time, pdb, itertools
from charcodes import KEY_ENTER
from core_fixtures import _pick_menu_item, _cap_story, _press_select
from core_fixtures import _need_keypress, _cap_menu, _sim_exec
from core_fixtures import _pick_menu_item, _cap_story, _press_select, _word_menu_entry
from core_fixtures import _need_keypress, _cap_menu, _sim_exec, _pass_word_quiz
from run_sim_tests import ColdcardSimulator, clean_sim_data
from ckcc_protocol.cli import wait_and_download
from ckcc_protocol.client import ColdcardDevice
from ckcc_protocol.protocol import CCProtocolPacker
def _clone(source, target):
@ -123,4 +125,99 @@ def test_clone(source, target):
_clone(source, target)
time.sleep(1)
def test_backup_restore_delta_pin():
# SOURCE
# clone with multisig wallet
clean_sim_data() # remove all from previous
sim_source = ColdcardSimulator(args=["--ms", "--p2wsh", "--set", "nfc=1", "--set", "vidsk=1"],
segregate=True) # in /tmp/cc-simulators
sim_source.start(start_wait=6)
device_source = ColdcardDevice(is_simulator=True, sn=sim_source.socket)
_pick_menu_item(device_source, False, "Settings")
time.sleep(.1)
_pick_menu_item(device_source, False, "Login Settings")
time.sleep(.1)
_pick_menu_item(device_source, False, "Trick PINs")
time.sleep(.1)
_pick_menu_item(device_source, False, "Add New Trick")
time.sleep(.1)
# twice, first select, then verify
for _ in range(2):
pin = "11-11"
pre, suff = pin.split("-")
for ch in pre:
_need_keypress(device_source, ch)
time.sleep(.1)
_press_select(device_source, False)
time.sleep(.2)
for ch in suff:
_need_keypress(device_source, ch)
time.sleep(.1)
_press_select(device_source, False)
time.sleep(.2)
_pick_menu_item(device_source, False, "Delta Mode")
time.sleep(.1)
title, story = _cap_story(device_source)
assert "trick PIN must be same length as true PIN and differ only in final 4 positions" in story
_press_select(device_source, False)
time.sleep(.1)
_press_select(device_source, False)
time.sleep(.1)
m = _cap_menu(device_source)
assert "11-11" in m[1]
ok = device_source.send_recv(CCProtocolPacker.start_backup())
assert ok is None
time.sleep(1)
title, story = _cap_story(device_source)
assert "backup file password" in story
word_list = [item.split()[-1] for item in story.split("\n")[1:-4]]
assert len(word_list) == 12
_pass_word_quiz(device_source, False, word_list)
_press_select(device_source, False) # bkpw
result, chk = wait_and_download(device_source, CCProtocolPacker.get_backup_file(), 0)
sim_source.stop()
# TARGET Q (empty)
sim_target = ColdcardSimulator(args=["--q1", "-l"])
sim_target.start(start_wait=6)
device_target = ColdcardDevice(is_simulator=True)
name = "backup-delta.7z"
path = f"../unix/work/MicroSD/{name}"
with open(path, "wb") as f:
f.write(result)
_pick_menu_item(device_target, True, "Import Existing")
_pick_menu_item(device_target, True, "Restore Backup")
_pick_menu_item(device_target, True, name)
time.sleep(.1)
_word_menu_entry(device_target, True, word_list, has_checksum=False)
_press_select(device_target, True) # allow backup restore
time.sleep(.1)
_press_select(device_target, True) # best security practices config
time.sleep(.1)
_press_select(device_target, True) # success
sim_target.stop()
time.sleep(1)
sim_target = ColdcardSimulator(args=["--q1"])
sim_target.start(start_wait=6)
device_target = ColdcardDevice(is_simulator=True, sn=sim_target.socket)
_pick_menu_item(device_target, True, "Settings")
time.sleep(.1)
_pick_menu_item(device_target, True, "Login Settings")
time.sleep(.1)
_pick_menu_item(device_target, True, "Trick PINs")
time.sleep(.1)
m = _cap_menu(device_target)
assert "11-11" in m[1]
# EOF

View File

@ -10,11 +10,13 @@ from msg import verify_message
from api import bitcoind, match_key
from api import bitcoind_wallet, bitcoind_d_wallet, bitcoind_d_wallet_w_sk, bitcoind_d_sim_sign, bitcoind_d_dev_watch
from api import bitcoind_d_sim_watch, finalize_v2_v0_convert
from electrum import electrum
from binascii import b2a_hex, a2b_hex
from constants import *
from charcodes import *
from core_fixtures import _need_keypress, _sim_exec, _cap_story, _cap_menu, _cap_screen, _sim_eval
from core_fixtures import _press_select, _pick_menu_item, _enter_complex, _dev_hw_label
from core_fixtures import _do_keypresses
from txn import render_address
from bbqr import split_qrs
@ -207,14 +209,11 @@ def enter_pin(enter_number, press_select, cap_screen, is_q1):
@pytest.fixture
def do_keypresses(need_keypress):
def do_keypresses(dev):
# do a series of keypresses, any kind
def doit(value):
for ch in value:
need_keypress(ch)
f = functools.partial(_do_keypresses, dev)
return f
return doit
@pytest.fixture
def enter_text(need_keypress, is_q1):
@ -1031,9 +1030,12 @@ def settings_set(sim_exec):
def settings_get(sim_exec):
def doit(key, def_val=None, prelogin=False):
source = "from nvstore import SettingsObject;SettingsObject.prelogin()" if prelogin else "settings"
cmd = f"RV.write(repr({source}.get('{key}', {def_val!r})))"
resp = sim_exec(cmd)
if prelogin:
src = f"from nvstore import SettingsObject;RV.write(repr(SettingsObject.prelogin().get('{key}', {def_val!r})))"
else:
src = f"RV.write(repr(settings.get('{key}', {def_val!r})))"
resp = sim_exec(src)
assert 'Traceback' not in resp, resp
return eval(resp)
@ -1053,8 +1055,9 @@ def master_settings_get(sim_exec):
@pytest.fixture
def settings_remove(sim_exec):
def doit(key):
x = sim_exec("settings.remove_key('%s')" % key)
def doit(key, prelogin=False):
source = "from nvstore import SettingsObject;SettingsObject.prelogin()" if prelogin else "settings"
x = sim_exec("%s.remove_key('%s')" % (source, key))
assert x == ''
return doit
@ -2242,7 +2245,7 @@ def verify_backup_file(goto_home, pick_menu_item, cap_story, need_keypress):
# Check on-device verify UX works.
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('Backup')
pick_menu_item('File Management')
pick_menu_item('Verify Backup')
time.sleep(0.1)
pick_menu_item(os.path.basename(fn))
@ -2641,7 +2644,8 @@ def txin_explorer(cap_story, press_cancel, need_keypress, is_q1, cap_menu,
time.sleep(.1)
title, story = cap_story()
ss = story.split("\n\n")
assert "Press RIGHT to see next group" in ss[-1]
if i < (num_inputs - 1):
assert "RIGHT to see next group" in ss[-1]
if i:
assert " LEFT to go back" in ss[-1]
else:
@ -2716,7 +2720,8 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1, verify_qr_addr
_, story = cap_story()
ss = story.split("\n\n")
assert len(ss) == (len(d) * 2) + 1
assert "Press RIGHT to see next group" in ss[-1]
if (i + n) < len(data):
assert "RIGHT to see next group" in ss[-1]
if i:
assert " LEFT to go back" in ss[-1]
else:
@ -3015,11 +3020,31 @@ def import_wif_to_store(goto_home, pick_menu_item, cap_story, press_select, cap_
return doit
@pytest.fixture
def bip322_txn(dev, pytestconfig):
from bip322 import bip322_txn
return functools.partial(bip322_txn, master_xpub=dev.master_xpub,
psbt_v2=pytestconfig.getoption('psbt2'))
@pytest.fixture
def bip322_ms_txn(pytestconfig):
from bip322 import bip322_ms_txn
return functools.partial(bip322_ms_txn, psbt_v2=pytestconfig.getoption('psbt2'))
@pytest.fixture
def bip322_verify():
from bip322 import bip322_verify
return bip322_verify
# useful fixtures
from test_backup import backup_system
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
from test_bip39pw import set_bip39_pw
from test_ccc import get_last_violation
from test_ccc import get_last_violation, setup_ccc, goto_ccc_menu, ccc_ms_setup, bitcoind_create_watch_only_wallet
from test_drv_entro import derive_bip85_secret, activate_bip85_ephemeral
from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto_eph_seed_menu
from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed
@ -3035,6 +3060,5 @@ from test_seed_xor import restore_seed_xor
from test_sign import txid_from_export_prompt
from test_ux import pass_word_quiz, word_menu_entry, enable_hw_ux
from txn import fake_txn
from bip322 import bip322_txn, bip322_ms_txn, create_msg_file
# EOF

View File

@ -7,7 +7,7 @@
# Below functions are injected with proper scoped `device` in conftest.py
# using funtools.partial.
#
import time
import time, re
from charcodes import *
from ckcc_protocol.client import CCProtocolPacker
@ -149,4 +149,116 @@ def _enter_complex(device, is_Q, target, apply=False, b39pass=True):
if apply:
_pick_menu_item(device, is_Q, "APPLY")
def _pass_word_quiz(device, is_Q, words, prefix='', preload=None):
if not preload:
_press_select(device, is_Q)
time.sleep(.01)
count = 0
last_title = None
while 1:
title, body = preload or _cap_story(device)
preload = None
if not title.startswith('Word ' + prefix): break
assert title.endswith(' is?')
assert not last_title or last_title != title, "gave wrong ans?"
wn = int(title.split()[1][len(prefix):])
assert 1 <= wn <= len(words)
wn -= 1
ans = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':']
assert len(ans) == 3
correct = ans.index(words[wn])
assert 0 <= correct < 3
# print("Pick %d: %s" % (correct, ans[correct]))
_need_keypress(device, chr(49 + correct))
time.sleep(.1)
count += 1
last_title = title
return count, title, body
def _do_keypresses(device, value):
for ch in value:
_need_keypress(device, ch)
def _word_menu_entry(device, is_Q, words, has_checksum=True, q_accept=True):
if is_Q:
# easier for us on Q, but have to anticipate the autocomplete
for n, w in enumerate(words, start=1):
_do_keypresses(device, w[0:2])
time.sleep(0.1)
if 'Next key' in _cap_screen(device):
_do_keypresses(device, w[2])
time.sleep(.01)
if 'Next key' in _cap_screen(device):
if len(w) > 3:
_do_keypresses(device, w[3])
else:
_do_keypresses(device, KEY_DOWN)
time.sleep(.01)
pat = rf'{n}:\s?{w}'
for x in range(10):
if re.search(pat, _cap_screen(device)):
break
time.sleep(0.02)
else:
raise RuntimeError('timeout')
if len(words) == 23:
_do_keypresses(device, KEY_DOWN)
time.sleep(.03)
cap_scr = _cap_screen(device)
while 'Next key' in cap_scr:
target = cap_scr.split("\n")[-1].replace("Next key: ", "")
# picks first choice!?
_do_keypresses(device, target[0])
time.sleep(.03)
cap_scr = _cap_screen(device)
else:
cap_scr = _cap_screen(device)
if has_checksum:
assert 'Valid words' in cap_scr
else:
assert 'Press ENTER if all done' in cap_scr
if q_accept:
_do_keypresses(device, '\r')
return
# do the massive drilling-down to pick a specific pass phrase
assert len(words) in {1, 12, 18, 23, 24}
for word in words:
while 1:
menu = _cap_menu(device)
which = None
for m in menu:
if '-' not in m:
if m == word:
which = m
break
else:
assert m[-1] == '-'
if m == word[0:len(m)-1]+'-':
which = m
break
assert which, "cant find: " + word
_pick_menu_item(device, is_Q, which)
if '-' not in which:
break
# EOF

96
testing/electrum.py Normal file
View File

@ -0,0 +1,96 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Lightweight pytest wrapper around the `electrum` CLI in --regtest --offline mode.
# No backend (electrs/ElectrumX) needed: UTXOs are fed via `addtransaction`,
# with raw tx hex coming from the `bitcoind` fixture. Targets Electrum 4.7+
import os, time, shutil, pytest, tempfile, subprocess
class Electrum:
def __init__(self, path):
self.electrum_path = path
self.datadir = tempfile.mkdtemp(prefix="electrum-test-")
self.daemon_started = False
def _cli(self, *args, offline=False):
# `--offline` is required for commands run *before* the daemon starts
# (setconfig, daemon -d) and rejected for commands that talk *to* the
# running daemon (restore, load_wallet, addtransaction, payto).
cmd = [self.electrum_path, "--regtest"]
if offline:
cmd.append("--offline")
cmd += ["-D", self.datadir, *args]
return subprocess.run(cmd, capture_output=True, text=True, check=True)
def start(self):
# Pre-daemon commands run --offline.
self._cli("setconfig", "log_to_file", "false", offline=True)
self._cli("daemon", "-d", offline=True)
self.daemon_started = True
time.sleep(1.5) # let RPC bind
def stop(self):
if self.daemon_started:
try:
self._cli("daemon", "stop")
except subprocess.CalledProcessError:
pass
self.daemon_started = False
if os.path.exists(self.datadir):
shutil.rmtree(self.datadir, ignore_errors=True)
def cleanup(self, *args, **kwargs):
self.stop()
def imported_addr_wallet(self, addr, name="paper"):
# Create and load a watch-only imported-address wallet. Returns the
# name; Electrum picks the actual on-disk location based on --regtest.
self._cli("restore", addr, "-w", name)
self._cli("load_wallet", "-w", name)
return name
def addtransaction(self, wallet, tx_hex):
# Feed a raw transaction so the wallet sees its UTXOs without a server.
self._cli("addtransaction", tx_hex, "-w", wallet)
def payto_unsigned_psbt(self, wallet, dest, amount, feerate=5):
# Build an unsigned PSBT spending to `dest`. Returns base64 PSBT.
# Offline daemon has no fee oracle, so we pass an explicit feerate
# (sat/byte). RBF is on by default in Electrum 4.7+.
r = self._cli("payto", dest, str(amount),
"--unsigned", "--feerate", str(feerate),
"-w", wallet)
# Electrum CLI wraps strings in quotes; strip them.
return r.stdout.strip().strip('"')
@staticmethod
def create(*args, **kwargs):
e = Electrum(*args, **kwargs)
e.start()
return e
def _find_electrum():
# Resolve the `electrum` binary, in order:
# 1. ELECTRUM_BIN env var — for users with a venv install
# (e.g. ELECTRUM_BIN=/home/me/electrum/ENV/bin/electrum)
# 2. `electrum` on PATH
path = os.environ.get("ELECTRUM_BIN") or shutil.which("electrum")
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
@pytest.fixture
def electrum():
# Electrum 4.7+ daemon in --regtest --offline mode.
# Skips if no usable binary — set ELECTRUM_BIN to point at one.
path = _find_electrum()
if not path:
pytest.skip("electrum not found — set $ELECTRUM_BIN or put it on PATH")
e = Electrum.create(path)
yield e
e.stop()
# EOF

View File

@ -50,6 +50,14 @@ def fake_dest_addr(style='p2pkh'):
if style == 'p2pkh':
return bytes([0x76, 0xa9, 0x14]) + prandom(20) + bytes([0x88, 0xac])
if style in ('p2pk', 'p2pk-compressed'):
pubkey = bytes([random.choice((2, 3))]) + prandom(32)
return bytes([len(pubkey)]) + pubkey + bytes([0xac])
if style == 'p2pk-uncompressed':
pubkey = bytes([4]) + prandom(64)
return bytes([len(pubkey)]) + pubkey + bytes([0xac])
if style == "p2tr":
return bytes([81, 32]) + prandom(32)
@ -58,8 +66,6 @@ def fake_dest_addr(style='p2pkh'):
hex_str = "049f7b2a5cb17576a914371c20fb2e9899338ce5e99908e64fd30b78931388ac"
return bytes.fromhex(hex_str)
# missing: if style == 'p2pk' => pay to pubkey, considered obsolete
raise ValueError('not supported: ' + style)
def make_change_addr(wallet, style):
@ -77,9 +83,13 @@ def make_change_addr(wallet, style):
assert len(target) == 20
is_segwit = True
pubkey = dest.sec(compressed=(style != 'p2pk-uncompressed'))
if style == 'p2pkh':
redeem_scr = bytes([0x76, 0xa9, 0x14]) + target + bytes([0x88, 0xac])
is_segwit = False
elif style in ('p2pk', 'p2pk-compressed', 'p2pk-uncompressed'):
redeem_scr = bytes([len(pubkey)]) + pubkey + bytes([0xac])
is_segwit = False
elif style == 'p2wpkh':
redeem_scr = bytes([0, 20]) + target
elif style == 'p2wpkh-p2sh':
@ -92,7 +102,7 @@ def make_change_addr(wallet, style):
else:
raise ValueError('cant make fake change output of type: ' + style)
return redeem_scr, actual_scr, is_segwit, dest.sec(), struct.pack('4I', xfp, *deriv)
return redeem_scr, actual_scr, is_segwit, pubkey, struct.pack('4I', xfp, *deriv)
def swab32(n):
# endian swap: 32 bits

View File

@ -501,7 +501,7 @@ def test_login_integration(request, nick, randomize, login_ctdwn, kill_btn, kill
def test_calc_login(request):
is_Q = request.config.getoption('--Q')
if not is_Q: raise pytest.skip("Q only")
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1"])
sim.start(start_wait=6)
@ -700,6 +700,49 @@ def test_sssp_bypass_pin(request, word_check, randomize):
device.close()
def test_sssp_bypass_pin_alone_no_login(request):
main_pin = "22-22"
bypass_pin = "111-111"
is_Q = request.config.getoption('--Q')
clean_sim_data()
sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_pick_menu_item(device, is_Q, "Advanced/Tools")
_pick_menu_item(device, is_Q, "Spending Policy")
_pick_menu_item(device, is_Q, "Single-Signer")
_press_select(device, is_Q)
_login(device, is_Q, bypass_pin)
_login(device, is_Q, bypass_pin)
time.sleep(2)
sim.stop()
device.close()
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, bypass_pin)
time.sleep(.1)
_, story = _cap_story(device)
assert "Spending Policy Unlock" in story
_press_select(device, is_Q)
time.sleep(.1)
_login(device, is_Q, bypass_pin) # bypass PIN a 2nd time, instead of main PIN
time.sleep(1.0)
# device lands on the EmptyWallet menu (no-secret session).
scr = _cap_screen(device)
assert "New Seed Words" in scr
assert "Import Existing" in scr
sim.stop()
device.close()
def test_sssp_login_countdown(request):
bypass_pin = "236-156"
is_Q = request.config.getoption('--Q')
@ -849,4 +892,206 @@ def test_sssp_trick_pins(request):
sim.stop()
device.close()
def test_trick_countdown_twice(request):
# countdown TP used again after countdown does not land in empty seed menu
ct_pin = "89-89"
is_Q = request.config.getoption('--Q')
headless = request.config.getoption('--headless')
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
headless=headless)
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, "22-22")
_pick_menu_item(device, is_Q, "Settings")
_pick_menu_item(device, is_Q, "Login Settings")
_pick_menu_item(device, is_Q, "Trick PINs")
_pick_menu_item(device, is_Q, "Add New Trick")
time.sleep(.1)
for ch in ct_pin[:2]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
if not is_Q:
# anti-phishing words
_press_select(device, is_Q)
for ch in ct_pin[-2:]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
_pick_menu_item(device, is_Q, "Login Countdown")
_press_select(device, is_Q)
time.sleep(.1)
_pick_menu_item(device, is_Q, "Just Countdown")
for _ in range(2):
_press_select(device, is_Q)
time.sleep(.1)
# adjust countdown to lowest possible value
_pick_menu_item(device, is_Q, f'{ct_pin}')
_pick_menu_item(device, is_Q, '↳Countdown')
_need_keypress(device, "4")
_pick_menu_item(device, is_Q, " 5 minutes")
time.sleep(2)
sim.stop()
device.close()
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
headless=headless)
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, ct_pin)
time.sleep(.15)
scr = " ".join(_cap_screen(device).split("\n"))
assert "Login countdown in effect" in scr
assert "Must wait:" in scr
assert "5s" in scr
time.sleep(6)
_login(device, is_Q, ct_pin)
time.sleep(.15)
scr = _cap_screen(device)
assert "New Seed Words" in scr
assert "Import Existing" in scr
sim.stop()
device.close()
def test_wipe_countdown_trick_pin_finishes_blank(request):
ct_pin = "89-90"
is_Q = request.config.getoption('--Q')
headless = request.config.getoption('--headless')
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
headless=headless)
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, "22-22")
_pick_menu_item(device, is_Q, "Settings")
_pick_menu_item(device, is_Q, "Login Settings")
_pick_menu_item(device, is_Q, "Trick PINs")
_pick_menu_item(device, is_Q, "Add New Trick")
time.sleep(.1)
for ch in ct_pin[:2]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
if not is_Q:
# anti-phishing words
_press_select(device, is_Q)
for ch in ct_pin[-2:]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
_pick_menu_item(device, is_Q, "Login Countdown")
_press_select(device, is_Q)
time.sleep(.1)
_pick_menu_item(device, is_Q, "Wipe & Countdown")
for _ in range(2):
_press_select(device, is_Q)
time.sleep(.1)
# adjust countdown to lowest possible value
_pick_menu_item(device, is_Q, f'{ct_pin}')
_pick_menu_item(device, is_Q, '↳Countdown')
_need_keypress(device, "4")
_pick_menu_item(device, is_Q, " 5 minutes")
time.sleep(2)
sim.stop()
device.close()
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"],
headless=headless)
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, ct_pin)
time.sleep(.15)
scr = " ".join(_cap_screen(device).split("\n"))
assert "Login countdown in effect" in scr
assert "Must wait:" in scr
assert "5s" in scr
time.sleep(6)
_login(device, is_Q, ct_pin)
time.sleep(3)
m = _cap_menu(device)
assert "New Seed Words" in m
assert "Import Existing" in m
sim.stop()
device.close()
def test_look_blank_trick_pin_empty_menu(request):
blank_pin = "55-55"
is_Q = request.config.getoption('--Q')
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, "22-22")
_pick_menu_item(device, is_Q, "Settings")
_pick_menu_item(device, is_Q, "Login Settings")
_pick_menu_item(device, is_Q, "Trick PINs")
_pick_menu_item(device, is_Q, "Add New Trick")
time.sleep(.1)
for ch in blank_pin[:2]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
if not is_Q:
# anti-phishing words
_press_select(device, is_Q)
for ch in blank_pin[-2:]:
_need_keypress(device, ch)
time.sleep(.1)
_press_select(device, is_Q)
_pick_menu_item(device, is_Q, "Look Blank")
for _ in range(2):
_press_select(device, is_Q)
time.sleep(.1)
time.sleep(2)
sim.stop()
device.close()
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
sim.start(start_wait=6)
device = ColdcardDevice(is_simulator=True)
_login(device, is_Q, blank_pin)
time.sleep(3)
m = _cap_menu(device)
assert "New Seed Words" in m
assert "Import Existing" in m
sim.stop()
device.close()
# EOF

View File

@ -14,58 +14,59 @@ b2a_hex = lambda a: str(_b2a_hex(a), 'ascii')
# BIP-174 aka PSBT defined values
#
# GLOBAL ===
PSBT_GLOBAL_UNSIGNED_TX = 0x00
PSBT_GLOBAL_XPUB = 0x01
PSBT_GLOBAL_VERSION = 0xfb
PSBT_GLOBAL_PROPRIETARY = 0xfc
PSBT_GLOBAL_UNSIGNED_TX = 0x00
PSBT_GLOBAL_XPUB = 0x01
PSBT_GLOBAL_VERSION = 0xfb
PSBT_GLOBAL_PROPRIETARY = 0xfc
# BIP-370
PSBT_GLOBAL_TX_VERSION = 0x02
PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03
PSBT_GLOBAL_INPUT_COUNT = 0x04
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
PSBT_GLOBAL_TX_VERSION = 0x02
PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03
PSBT_GLOBAL_INPUT_COUNT = 0x04
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09
# INPUTS ===
PSBT_IN_NON_WITNESS_UTXO = 0x00
PSBT_IN_WITNESS_UTXO = 0x01
PSBT_IN_PARTIAL_SIG = 0x02
PSBT_IN_SIGHASH_TYPE = 0x03
PSBT_IN_REDEEM_SCRIPT = 0x04
PSBT_IN_WITNESS_SCRIPT = 0x05
PSBT_IN_BIP32_DERIVATION = 0x06
PSBT_IN_FINAL_SCRIPTSIG = 0x07
PSBT_IN_FINAL_SCRIPTWITNESS = 0x08
PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves
PSBT_IN_RIPEMD160 = 0x0a
PSBT_IN_SHA256 = 0x0b
PSBT_IN_HASH160 = 0x0c
PSBT_IN_HASH256 = 0x0d
PSBT_IN_NON_WITNESS_UTXO = 0x00
PSBT_IN_WITNESS_UTXO = 0x01
PSBT_IN_PARTIAL_SIG = 0x02
PSBT_IN_SIGHASH_TYPE = 0x03
PSBT_IN_REDEEM_SCRIPT = 0x04
PSBT_IN_WITNESS_SCRIPT = 0x05
PSBT_IN_BIP32_DERIVATION = 0x06
PSBT_IN_FINAL_SCRIPTSIG = 0x07
PSBT_IN_FINAL_SCRIPTWITNESS = 0x08
PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves
PSBT_IN_RIPEMD160 = 0x0a
PSBT_IN_SHA256 = 0x0b
PSBT_IN_HASH160 = 0x0c
PSBT_IN_HASH256 = 0x0d
# BIP-370
PSBT_IN_PREVIOUS_TXID = 0x0e
PSBT_IN_OUTPUT_INDEX = 0x0f
PSBT_IN_SEQUENCE = 0x10
PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12
PSBT_IN_PREVIOUS_TXID = 0x0e
PSBT_IN_OUTPUT_INDEX = 0x0f
PSBT_IN_SEQUENCE = 0x10
PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12
# BIP-371
PSBT_IN_TAP_KEY_SIG = 0x13
PSBT_IN_TAP_SCRIPT_SIG = 0x14
PSBT_IN_TAP_LEAF_SCRIPT = 0x15
PSBT_IN_TAP_BIP32_DERIVATION = 0x16
PSBT_IN_TAP_INTERNAL_KEY = 0x17
PSBT_IN_TAP_MERKLE_ROOT = 0x18
PSBT_IN_TAP_KEY_SIG = 0x13
PSBT_IN_TAP_SCRIPT_SIG = 0x14
PSBT_IN_TAP_LEAF_SCRIPT = 0x15
PSBT_IN_TAP_BIP32_DERIVATION = 0x16
PSBT_IN_TAP_INTERNAL_KEY = 0x17
PSBT_IN_TAP_MERKLE_ROOT = 0x18
# OUTPUTS ===
PSBT_OUT_REDEEM_SCRIPT = 0x00
PSBT_OUT_WITNESS_SCRIPT = 0x01
PSBT_OUT_BIP32_DERIVATION = 0x02
PSBT_OUT_REDEEM_SCRIPT = 0x00
PSBT_OUT_WITNESS_SCRIPT = 0x01
PSBT_OUT_BIP32_DERIVATION = 0x02
# BIP-370
PSBT_OUT_AMOUNT = 0x03
PSBT_OUT_SCRIPT = 0x04
PSBT_OUT_AMOUNT = 0x03
PSBT_OUT_SCRIPT = 0x04
# BIP-371
PSBT_OUT_TAP_INTERNAL_KEY = 0x05
PSBT_OUT_TAP_TREE = 0x06
PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
PSBT_OUT_TAP_INTERNAL_KEY = 0x05
PSBT_OUT_TAP_TREE = 0x06
PSBT_OUT_TAP_BIP32_DERIVATION = 0x07
PSBT_PROP_CK_ID = b"COINKITE"
@ -352,6 +353,7 @@ class BasicPSBT:
self.outputs = []
self.txn_modifiable = None
self.fallback_locktime = None
self.bip322_msg = None
self.unknown = {}
self.parsed_txn = None
@ -360,6 +362,7 @@ class BasicPSBT:
a.input_count == b.input_count and \
a.output_count == b.output_count and \
a.fallback_locktime == b.fallback_locktime and \
a.bip322_msg == b.bip322_msg and \
a.txn_version == b.txn_version and \
a.version == b.version and \
len(a.inputs) == len(b.inputs) and \
@ -373,6 +376,9 @@ class BasicPSBT:
return (self.version == 2) or (not self.txn)
def parse(self, raw):
if isinstance(raw, str):
raw = raw.encode('ascii')
# auto-detect and decode Base64 and Hex.
if raw[0:10].lower() == b'70736274ff':
raw = a2b_hex(raw.strip())
@ -419,6 +425,8 @@ class BasicPSBT:
num_outs = self.output_count
elif kt == PSBT_GLOBAL_TX_MODIFIABLE:
self.txn_modifiable = val[0]
elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
self.bip322_msg = val
else:
self.unknown[key] = val
@ -431,8 +439,8 @@ class BasicPSBT:
if self.version == 0:
assert self.txn, 'v0: missing reqd section - PSBT_GLOBAL_UNSIGNED_TX'
elif self.version == 2:
# tx version needs to be at least 2 because locktimes
assert self.txn_version in {2, 3}, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
assert self.txn_version is not None, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
assert self.txn_version != 0 or self.bip322_msg, 'bad txn version'
assert self.input_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_INPUT_COUNT'
assert self.output_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_OUTPUT_COUNT'
@ -480,6 +488,9 @@ class BasicPSBT:
if self.version is not None:
wr(PSBT_GLOBAL_VERSION, struct.pack('<I', self.version))
if self.bip322_msg is not None:
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.bip322_msg)
if isinstance(self.unknown, list):
# just so I can test duplicate unknown values
# list of tuples [(key0, val0), (key1, val1)]
@ -506,10 +517,46 @@ class BasicPSBT:
def as_b64_str(self):
return b64encode(self.as_bytes()).decode()
def convert_witness_utxo_to_utxo(self, idx):
# Test helper: the original prev txn cannot be reconstructed from a
# witness_utxo, so retarget this PSBT input to a synthetic funding txn.
inp = self.inputs[idx]
assert inp.witness_utxo
assert inp.utxo is None
prev_txo = CTxOut()
prev_txo.deserialize(io.BytesIO(inp.witness_utxo))
if self.is_v2():
assert inp.prevout_idx is not None
prevout_idx = inp.prevout_idx
else:
assert self.parsed_txn
txin = self.parsed_txn.vin[idx]
prevout_idx = txin.prevout.n
funding = CTransaction()
funding.nVersion = 2
funding.vin = [CTxIn(COutPoint(0, 0xffffffff), nSequence=0xffffffff)]
funding.vout = [CTxOut(0, b'') for _ in range(prevout_idx)]
funding.vout.append(prev_txo)
funding.calc_sha256()
inp.utxo = funding.serialize_with_witness()
inp.witness_utxo = None
if self.is_v2():
inp.previous_txid = ser_uint256(funding.sha256)
else:
txin.prevout.hash = funding.sha256
self.txn = self.parsed_txn.serialize_with_witness()
return funding
def to_v2(self):
if self.version is None or self.version == 0:
self.version = 2
self.txn_version = 2
self.txn_version = self.parsed_txn.nVersion
self.txn = None
self.input_count = len(self.parsed_txn.vin)
self.output_count = len(self.parsed_txn.vout)
@ -580,4 +627,3 @@ def test_my_psbt():
assert chk == p
# EOF

View File

@ -24,4 +24,4 @@ git+https://github.com/coinkite/bsms-bitcoin-secure-multisig-setup.git@master#eg
git+https://github.com/coinkite/BBQr.git@master#egg=bbqr&subdirectory=python
# for backend testing
requests==2.32.4
requests==2.33.0

View File

@ -1,14 +1,14 @@
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
import pytest, pdb, time, random, os
from charcodes import KEY_CANCEL
from charcodes import KEY_CANCEL, KEY_QR
from core_fixtures import _pick_menu_item, _press_select
from core_fixtures import _need_keypress, _sim_exec
from core_fixtures import _need_keypress, _sim_exec, _cap_story
from run_sim_tests import ColdcardSimulator, clean_sim_data
from ckcc_protocol.client import ColdcardDevice
def test_status_bar_rewrite_after_restore_master(request):
def test_status_bar_rewrite_after_restore_master():
from PIL import Image
clean_sim_data() # remove all from previous
sim = ColdcardSimulator(args=["--q1", "-l"])
@ -37,4 +37,24 @@ def test_status_bar_rewrite_after_restore_master(request):
rv1 = Image.open(fn1)
rv0.show()
rv1.show()
sim.stop()
sim.stop()
def test_seedless_qr_import_bad_checksum():
clean_sim_data()
sim = ColdcardSimulator(args=["--q1", "-l"])
sim.start(start_wait=3)
device = ColdcardDevice(is_simulator=True)
try:
_need_keypress(device, KEY_QR)
time.sleep(.3)
# Inject a bad-checksum SeedQR via the simulator's scan queue
bad_seed = '0000' * 12
_sim_exec(device, 'glob.SCAN._q.put_nowait(%r)' % bad_seed.encode())
time.sleep(.5)
title, story = _cap_story(device)
assert 'checksum fail' in story
finally:
sim.stop()

55
testing/sighash.py Normal file
View File

@ -0,0 +1,55 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Bitcoin transaction signature hash helpers for tests.
#
import copy
import hashlib
import struct
from ctransaction import hash256
from serialize import ser_string
from pysecp256k1 import tagged_sha256
SIGHASH_DEFAULT = 0
SIGHASH_ALL = 1
def legacy_sighash(tx, in_idx, script_code, sighash=SIGHASH_ALL):
tmp = copy.deepcopy(tx)
for txin in tmp.vin:
txin.scriptSig = b''
tmp.vin[in_idx].scriptSig = script_code
return hash256(tmp.serialize_without_witness() + struct.pack('<I', sighash))
def segwit_v0_sighash(tx, in_idx, script_code, amount, sighash=SIGHASH_ALL):
hash_prevouts = hash256(b''.join(i.prevout.serialize() for i in tx.vin))
hash_sequence = hash256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin))
hash_outputs = hash256(b''.join(o.serialize() for o in tx.vout))
txin = tx.vin[in_idx]
preimage = struct.pack('<i', tx.nVersion)
preimage += hash_prevouts + hash_sequence
preimage += txin.prevout.serialize()
preimage += ser_string(script_code)
preimage += struct.pack('<q', amount)
preimage += struct.pack('<I', txin.nSequence)
preimage += hash_outputs
preimage += struct.pack('<I', tx.nLockTime)
preimage += struct.pack('<I', sighash)
return hash256(preimage)
def taproot_sighash(tx, in_idx, prevouts, sighash=SIGHASH_DEFAULT):
assert sighash in (SIGHASH_DEFAULT, SIGHASH_ALL)
preimage = bytes([sighash])
preimage += struct.pack('<i', tx.nVersion)
preimage += struct.pack('<I', tx.nLockTime)
preimage += hashlib.sha256(b''.join(i.prevout.serialize() for i in tx.vin)).digest()
preimage += hashlib.sha256(b''.join(struct.pack('<q', amount) for amount, spk in prevouts)).digest()
preimage += hashlib.sha256(b''.join(ser_string(spk) for amount, spk in prevouts)).digest()
preimage += hashlib.sha256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin)).digest()
preimage += hashlib.sha256(b''.join(o.serialize() for o in tx.vout)).digest()
preimage += b'\x00'
preimage += struct.pack('<I', in_idx)
return tagged_sha256(b"TapSighash", b'\x00' + preimage)

View File

@ -554,4 +554,67 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad
for p, a in addr_gen:
addr_vs_path(a, p, addr_fmt=which_fmt)
@pytest.mark.parametrize("prefix,label", [
("m/84h", "Segwit P2WPKH"),
("m/49h", "P2SH-Segwit"),
("m/44h", "Classic P2PKH"),
])
def test_pick_addr_fmt_menu_default(prefix, label, goto_address_explorer, is_q1, sim_exec,
pick_menu_item, need_keypress, press_select, cap_screen,
cap_story, use_testnet):
# PickAddrFmtMenu must pre-select the natural address format for common BIP paths
use_testnet()
goto_address_explorer()
pick_menu_item("Custom Path")
pick_menu_item(prefix + "/⋯")
need_keypress("0")
press_select()
path_to_pick = prefix + "/0h" if is_q1 else "⋯/0h"
pick_menu_item(path_to_pick)
time.sleep(.2)
# currently sitting at address format choice menu
cur_label = sim_exec(
'from ux import the_ux; top = the_ux.top_of_stack();'
'RV.write(top.items[top.cursor].label)'
)
assert cur_label == label, ("For %s: expected cursor on '%s', got '%s'" % (prefix, label, cur_label))
# choose menu item we're currently at
press_select()
need_keypress("3")
time.sleep(.2)
title, story = cap_story()
addr = addr_from_display_format(story.split("\n\n")[1].split("\n")[1])
if prefix == "m/84h":
assert addr.startswith("tb1")
elif prefix == "m/49h":
assert addr.startswith("2")
else:
assert addr.startswith("m") or addr.startswith("n")
# EOF
def test_change_account_cancel(goto_address_explorer, pick_menu_item, press_cancel, cap_menu):
goto_address_explorer()
time.sleep(.2)
pick_menu_item('Account Number')
time.sleep(.1)
press_cancel()
time.sleep(.2)
assert "Account Number" in cap_menu()
def test_change_start_idx_cancel(goto_address_explorer, pick_menu_item, press_cancel, settings_set,
settings_remove, cap_menu):
settings_set('aei', 1)
goto_address_explorer()
time.sleep(.2)
pick_menu_item('Start Idx: 0')
time.sleep(.1)
press_cancel()
time.sleep(.2)
assert 'Start Idx: 0' in cap_menu()
settings_remove('aei')
# EOF

View File

@ -2,7 +2,7 @@
#
# Testing backups.
#
import pytest, time, json, os, shutil, re
import pytest, time, json, os, shutil, re, struct
from constants import simulator_fixed_words, simulator_fixed_tprv
from charcodes import KEY_QR
from bip32 import BIP32Node
@ -842,4 +842,141 @@ def test_backup_long_name_display(fname, goto_home, pick_menu_item, need_keypres
press_cancel()
def test_header_magic_check(microsd_path, src_root_dir, verify_backup_file, cap_story):
fname = "backup.7z"
fn = microsd_path(fname)
with open(f'{src_root_dir}/docs/backup.7z', "rb") as f:
conts = f.read()
# from shared/compat7z.py
magic, major, minor, crc = struct.unpack('<6sBBL', conts[:12])
assert magic == b"7z\xbc\xaf'\x1c"
assert major == 0
assert minor >= 3
# invalid magic
with open(fn, "wb") as f:
f.write(b"8z\xbc\xaf'\x1c")
f.write(conts[6:])
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Bad magic bytes" in story
# invalid major
with open(fn, "wb") as f:
f.write(b"7z\xbc\xaf'\x1c")
f.write(bytes([1])) # major has to be 0
f.write(conts[7:])
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Bad magic bytes" in story
# invalid minor
with open(fn, "wb") as f:
f.write(b"7z\xbc\xaf'\x1c")
f.write(bytes([0]))
f.write(bytes([2])) # cannot be smaller than 3
f.write(conts[8:])
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Bad magic bytes" in story
def test_confused_file_check(microsd_path, src_root_dir, verify_backup_file, cap_story):
fname = "backup.7z"
fn = microsd_path(fname)
with open(f'{src_root_dir}/docs/backup.7z', "rb") as f:
conts = f.read()
# truncate last bytes so trailing header read returns fewer bytes than sh.size
with open(fn, "wb") as f:
f.write(conts[:-10])
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Confused file?" in story
assert "Truncated file?" in story
# remove AES+SHA marker so "not marked" assertion fires
marker = b'\x24\x06\xf1\x07\x01'
assert marker in conts
corrupted = conts.replace(marker, b'\x00\x00\x00\x00\x00', 1)
with open(fn, "wb") as f:
f.write(corrupted)
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Confused file?" in story
assert "Not marked as AES+SHA encrypted?" in story
def test_check_file_headers_errors(microsd_path, src_root_dir, verify_backup_file, cap_story):
from binascii import crc32 as host_crc32
fname = "backup.7z"
fn = microsd_path(fname)
with open(f'{src_root_dir}/docs/backup.7z', "rb") as f:
conts = f.read()
# Flip a byte in SectionHeader (file bytes 12-31); fh.crc stays the same
# but sh.actual_crc() changes --> mismatch --> error
corrupted = bytearray(conts)
corrupted[12] ^= 0xFF
with open(fn, "wb") as f:
f.write(bytes(corrupted))
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Second header has wrong CRC" in story
# Set sh.size (offset 8 in SectionHeader = file bytes 20-27) to > 10000,
# then update fh.crc (file bytes 8-11) so the CRC check passes first.
fh_bytes = bytearray(conts[:12])
sh_bytes = bytearray(conts[12:32])
struct.pack_into('<Q', sh_bytes, 8, 99999) # size field at offset 8 in SectionHeader
new_crc = host_crc32(bytes(sh_bytes)) & 0xFFFFFFFF
struct.pack_into('<L', fh_bytes, 8, new_crc) # fh.crc at offset 8 in FileHeader
with open(fn, "wb") as f:
f.write(bytes(fh_bytes) + bytes(sh_bytes) + conts[32:])
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Second header too big" in story
# Flip the last byte of the trailing header data
sh_offset_val, sh_size_val = struct.unpack_from('<QQ', conts, 12)
th_start = 0x20 + sh_offset_val
th_end = th_start + sh_size_val
corrupted = bytearray(conts)
corrupted[th_end - 1] ^= 0xFF
with open(fn, "wb") as f:
f.write(bytes(corrupted))
with pytest.raises(AssertionError):
verify_backup_file(fname)
title, story = cap_story()
assert "Trailing header has wrong CRC" in story
# EOF

View File

@ -1,82 +1,29 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# BIP-322 Message Signing and Proof of Reserves
# NOTE: Run this module with and without --psbt2 to cover both PSBT versions.
#
import pytest, time, os
from io import BytesIO
from decimal import Decimal
from constants import SIGHASH_MAP, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH
from bip322 import bip322_txn, bip322_ms_txn, bip322_msg_hash, BIP32Node
from ctransaction import CTransaction, CTxIn, COutPoint
from helpers import str_to_path
from charcodes import KEY_QR, KEY_NFC
from bbqr import split_qrs
from bip322 import bip322_txn, bip322_ms_txn, BIP32Node
from ctransaction import CTransaction, CTxIn, COutPoint, CTxOut
from helpers import addr_from_display_format, str_to_path
from txn import render_address
@pytest.fixture
def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel, cap_menu,
nfc_write_text, is_q1, press_nfc, scan_a_qr, split_scan_bbqr,
enter_complex, pick_menu_item):
def doit(msg, refuse=False, way="sd", fname=None):
title, story = cap_story()
assert "BIP-322" in title
# file was already created with bip322_txn fixture above
if "qr" in way and not is_q1:
raise pytest.xfail("Mk4 no QR")
if way == "input":
enter_complex(msg, b39pass=False)
elif way == "qr":
assert f"{KEY_QR} to scan QR code" in story
need_keypress(KEY_QR)
scan_a_qr(msg)
time.sleep(1)
elif way == "bbqr":
assert f"{KEY_QR} to scan QR code" in story
need_keypress(KEY_QR)
# def split_qrs(raw, type_code, encoding=None,
# min_split=1, max_split=1295, min_version=5, max_version=40
actual_vers, parts = split_qrs(msg, "U", max_version=20)
for p in parts:
scan_a_qr(p)
time.sleep(2.0 / len(parts)) # just so we can watch
time.sleep(1)
elif way == "nfc":
if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
pytest.xfail("NFC disabled")
else:
press_nfc()
time.sleep(0.2)
nfc_write_text(msg)
time.sleep(0.3)
else:
assert way in ["sd", "vdisk"]
if way == "vdisk":
if "(2) to import from Virtual Disk" not in story:
pytest.xfail("Vdisk disabled")
else:
need_keypress("2")
else:
need_keypress("1")
if fname:
pick_menu_item(fname)
time.sleep(.1)
def verify_msg_bip322_por(cap_story, press_select, press_cancel, cap_menu):
def doit(msg, is_por=True, refuse=False):
title, story = cap_story()
assert title == "OK TO SIGN?"
assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story
assert msg in story
if refuse:
press_cancel()
time.sleep(.1)
assert "Ready To Sign" in cap_menu()
else:
press_select()
return doit
@ -91,7 +38,8 @@ def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel,
[["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
])
def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
press_select, verify_msg_bip322_por, sim_root_dir):
press_select, verify_msg_bip322_por, sim_root_dir, press_cancel,
bip322_verify):
num_ins = len(ins)
amt = sum([i[2] or 0 for i in ins])
psbt, msg_challenge = bip322_txn(ins, msg=msg)
@ -100,51 +48,202 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way="sd")
is_por = num_ins > 1
verify_msg_bip322_por(msg.decode(), is_por=is_por)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story
assert 'Network fee' not in story # different story for POR
if len(ins) == 1:
# only the message signed input - amount is zero
assert "Amount 0.00000000 XTN" in story
assert "1 input" in story
assert "explore transaction" in story
if not is_por:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not in story
assert "sign message" in story
else:
assert ("Amount %s XTN" % str(Decimal(amt/100000000).quantize(Decimal('.00000001')))) in story
assert ("%d inputs" % num_ins) in story
assert "sign proof of reserves" in story
assert ("Message Hash:\n%s" % bip322_msg_hash(msg).hex()) in story
assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story
assert "1 output" in story
assert ("Message:\n%s" % msg.decode()) in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
assert ("1 output" in story) == is_por
assert "- OP_RETURN -" not in story
assert "null-data" not in story
signed = end_sign(accept=True, exit_export_loop=False)
bip322_verify(signed)
title, story = cap_story()
assert title == "PSBT Signed"
assert "Signed BIP-322 PSBT shared via USB." in story
assert "Finalized TX ready for broadcast" not in story
assert "TXID:" not in story
press_cancel()
def test_bip322_por_utf8_msg(bip322_txn, start_sign, end_sign, cap_story, press_select,
bip322_verify):
msg = "UTF-8 support: öäüéàè - test text".encode()
psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=msg)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "BIP-322 Message" in story
assert "Proof of Reserves" not in story
assert msg.decode() in story
assert "WARNING" in story
assert "non-ASCII characters" in story
assert "Message Hash:" not in story
signed = end_sign(accept=True)
bip322_verify(signed)
def test_bip322_global_msg_hash_mismatch(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.bip322_msg = b"wrong message"
psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=b"right message", psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "to_spend hash" in story
def test_bip322_missing_global_msg(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.bip322_msg = None
psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "msg" in story
def test_bip322_missing_input0_utxo(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.inputs[0].utxo = None
psbt_in.inputs[0].witness_utxo = None
psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "Missing own UTXO" in story
@pytest.mark.parametrize("ins,label", [
([["p2wpkh", None, None]], "BIP-322 Message"),
([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], "Proof of Reserves"),
])
def test_bip322_psbtv2_accepted(ins, label, bip322_txn, start_sign, end_sign, cap_story,
bip322_verify):
psbt, _ = bip322_txn(ins, psbt_v2=True)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert label in story
signed = end_sign(accept=True)
bip322_verify(signed)
@pytest.mark.parametrize("to_sign_nVersion", [1, 3])
def test_bip322_invalid_to_sign_version(to_sign_nVersion, bip322_txn, start_sign, cap_story):
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
to_sign_nVersion=to_sign_nVersion)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "bad txn version" in story
def test_bip322_input0_explorer(bip322_txn, start_sign, cap_story, need_keypress,
pick_menu_item, press_cancel):
psbt, msg_challenge = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]])
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Press (2) to explore transaction" in story
need_keypress("2")
time.sleep(.1)
pick_menu_item("Inputs")
time.sleep(.1)
title, story = cap_story()
assert title == "Input 0"
sections = story.split("\n\n")
txid, n = sections[0].split(":")
assert len(txid) == 64
assert n == "0"
assert "=== UTXO ===" in sections
utxo_idx = sections.index("=== UTXO ===")
assert sections[utxo_idx + 1] == "0.00000000 XTN"
assert sections[utxo_idx + 2] == msg_challenge.hex()
assert addr_from_display_format(sections[utxo_idx + 3]) == render_address(msg_challenge)
assert sections[utxo_idx + 4] == "Address Format: p2wpkh"
assert "=== PSBT ===" in sections
assert "Our key:" in sections
assert "- OP_RETURN -" not in story
assert "null-data" not in story
press_cancel()
def test_bip322_output_explorer(bip322_txn, start_sign, cap_story, need_keypress,
pick_menu_item, press_cancel):
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]])
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert "- OP_RETURN -" not in story
assert "null-data" not in story
assert "Press (2) to explore transaction" in story
need_keypress("2")
time.sleep(.1)
pick_menu_item("Outputs")
time.sleep(.1)
title, story = cap_story()
assert title == "0-0"
assert "Output 0:" in story
assert "0.00000000 XTN" in story
assert "- OP_RETURN -" in story
assert "null-data" in story
end_sign(accept=True, finalize=True)
press_cancel()
press_cancel()
press_cancel()
@pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL'])
def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set,
end_sign, verify_msg_bip322_por):
settings_set("sighshchk", 0) # disable checks
def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set):
settings_set("sighshchk", 1) # BIP-322 POR still requires SIGHASH_ALL in warn-only mode.
# all POR txns must have only SIGHASH_ALL
psbt, _ = bip322_txn([["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]],
sighash=SIGHASH_MAP[sighash])
start_sign(psbt, finalize=True)
title, story = cap_story()
if "NONE" in sighash:
assert title == "Failure"
return
verify_msg_bip322_por("POR", way="sd")
time.sleep(.1)
title, story = cap_story()
assert "warning" in story
with pytest.raises(Exception):
end_sign(accept=True, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "POR not SIGHASH_ALL" in story
@ -152,34 +251,57 @@ def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story,
[["p2wpkh", None, None]],
[["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
])
def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story):
# not allowed - 0th input needs to have full pre-segwit utxo
def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, end_sign, cap_story,
verify_msg_bip322_por, bip322_verify):
# allowed when the BIP-322 message is provided in the global PSBT field
psbt, _ = bip322_txn(ins, witness_utxo=[0])
start_sign(psbt, finalize=True)
verify_msg_bip322_por("POR", is_por=len(ins) > 1)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
signed = end_sign(accept=True)
bip322_verify(signed)
def test_bip322_0th_input_witness_utxo_requires_zero_value(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
txo = CTxOut()
txo.deserialize(BytesIO(psbt_in.inputs[0].witness_utxo))
txo.nValue = 1
psbt_in.inputs[0].witness_utxo = txo.serialize()
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
witness_utxo=[0], psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "utxo" in story
assert "input0 value" in story
@pytest.mark.parametrize("ins", [
[["p2wpkh", None, None], ["p2wpkh", None, 10000000], ["p2wpkh", None, 10000000]],
[["p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2pkh", None, 10000000]],
[["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
[["p2pkh", None, None], ["p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
])
def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign,
verify_msg_bip322_por):
# allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo
verify_msg_bip322_por, bip322_verify):
# allowed - input 0 has full utxo here, other inputs can be witness_utxo-only
msg = b"hellow world"
psbt, msg_challenge = bip322_txn(ins, witness_utxo=[1, 2], msg=msg)
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way="sd")
verify_msg_bip322_por(msg.decode())
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert bip322_msg_hash(msg).hex() in story
assert msg_challenge.hex() in story
end_sign(accept=True, finalize=True)
assert ("Message:\n%s" % msg.decode()) in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
signed = end_sign(accept=True)
bip322_verify(signed)
@pytest.mark.parametrize("ins", [
@ -194,8 +316,9 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto
verify_msg_bip322_por):
def hack(psbt_in):
without_paths = 0 if len(psbt_in.inputs) == 1 else 1
for i, inp in enumerate(psbt_in.inputs):
if i == 0:
if i == without_paths:
inp.bip32_paths = None
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
@ -205,12 +328,26 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto
assert title == "Failure"
assert 'PSBT does not contain any key path information.' in story
else:
verify_msg_bip322_por("POR", way="sd")
verify_msg_bip322_por("POR")
time.sleep(.1)
title, story = cap_story()
assert "warning" in story
assert "Limited Signing" in story
assert "because we do not know the key: 0" in story
assert "because we do not know the key: 1" in story
def test_bip322_por_input0_bip32_paths_required(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.inputs[0].bip32_paths = None
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
psbt_hacker=hack)
start_sign(psbt)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "not our key" in story
@pytest.mark.parametrize("ins", [
@ -265,7 +402,7 @@ def test_bip322_invalid_to_spend_scriptSig(ins, bip322_txn, start_sign, cap_stor
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "scriptSig" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -293,9 +430,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story)
to_spend_tx.calc_sha256()
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
to_sign_tx.vin = [spendable]
to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0))
psbt_in.txn = to_sign_tx.serialize_with_witness()
@ -304,7 +439,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "prevout" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -342,7 +477,7 @@ def test_bip322_invalid_to_spend_num_inputs(ins, bip322_txn, start_sign, cap_sto
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "num ins" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -380,7 +515,7 @@ def test_bip322_invalid_to_spend_num_outputs(ins, bip322_txn, start_sign, cap_st
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "num outs" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -416,7 +551,7 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "nVal" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH])
@ -424,7 +559,8 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story
@pytest.mark.parametrize("signed", [True, False])
@pytest.mark.parametrize("num_ins", [1, 7])
def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sign, cap_story,
import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por):
import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por,
bip322_verify):
clear_ms()
M, N = M_N
@ -448,29 +584,38 @@ def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sig
psbt, msg_challenge = bip322_ms_txn(num_ins, M, keys, path_mapper=path_mapper, inp_af=addr_fmt,
with_sigs=signed, input_amount=inp_amount)
start_sign(psbt, finalize=signed)
verify_msg_bip322_por("POR", way="sd")
is_por = num_ins > 1
verify_msg_bip322_por("POR", is_por=is_por)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story
assert 'Network fee' not in story
if num_ins == 1:
# only the message signed input - amount is zero
assert "Amount 0.00000000 XTN" in story
assert "1 input" in story
if not is_por:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not in story
else:
amt = (num_ins - 1) * inp_amount
str_amt = str(Decimal(amt / 100000000).quantize(Decimal('.00000001')))
assert ("Amount %s XTN" % str_amt) in story
assert ("%d inputs" % num_ins) in story
assert ("Message Hash:\n%s" % bip322_msg_hash(b"POR").hex()) in story
assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story
assert "1 output" in story
assert "- OP_RETURN -" in story
assert "null-data" in story
assert "Message:\nPOR" in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
assert ("1 output" in story) == is_por
assert "- OP_RETURN -" not in story
assert "null-data" not in story
end_sign(accept=True, finalize=signed)
signed_psbt = end_sign(accept=True)
if signed:
# with_sigs=True preloads placeholder cosigner signatures; the device
# can accept the PSBT shape, but a real signature verifier must reject it.
with pytest.raises(AssertionError):
bip322_verify(signed_psbt)
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2SH])
@ -504,82 +649,12 @@ def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story,
assert "Missing redeem/witness script" in story
@pytest.mark.parametrize("msg", [b"COLDCARD\n\nTHE\n\nBEST\n\nSIGNER", b"X" * 512])
@pytest.mark.parametrize("ins", [
[["p2sh-p2wpkh", None, None]],
[["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
])
@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk", "bbqr"])
def test_bip322_msg_import(msg, ins, way, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
press_select, verify_msg_bip322_por):
if b"\n" in msg and way == "qr":
raise pytest.skip("QR code with newlines not supported")
psbt, msg_challenge = bip322_txn(ins, msg=msg)
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way=way)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
def test_bip322_msg_import_fail(bip322_txn, start_sign, end_sign, cap_story, need_keypress,
press_select, OK, press_cancel, cap_menu, microsd_path, enter_complex):
msg = b"it's me!"
psbt, msg_challenge = bip322_txn([["p2wpkh", None, None]], msg=msg)
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("1") # SD
time.sleep(.1)
title, story = cap_story()
assert f"Press {OK} to approve message" in story
press_cancel() # refuse
time.sleep(.1)
assert "Ready To Sign" in cap_menu()
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("0") # manual input
# leave empty
press_cancel()
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "need msg" in story
assert "Msg verification failed" in story
press_cancel()
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("0") # manual input
enter_complex("AAA", apply=False, b39pass=False) # msg wrong
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Msg verification failed" in story
assert "hash verification failed" in story
press_cancel()
@pytest.mark.parametrize("num_ins", [1, 12])
@pytest.mark.parametrize("addr_fmt", ["p2pkh", "p2wpkh", "p2sh-p2wpkh"])
def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pick_menu_item,
need_keypress, start_sign, end_sign, cap_menu, cap_story,
press_cancel, settings_remove, press_select, import_wif_to_store):
press_cancel, settings_remove, press_select, import_wif_to_store,
bip322_verify):
settings_remove("wifs")
@ -600,7 +675,7 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic
ins.append([addr_fmt, None, amt , n.node.private_key.K.sec()])
msg = b"Coinkite"
psbt, msg_challenge = bip322_txn(ins, msg=b"Coinkite")
psbt, msg_challenge = bip322_txn(ins, msg=msg)
import_wif_to_store(wifs)
@ -610,22 +685,52 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "import message" in story
# msg file was auto-gened on SD card
need_keypress("1")
time.sleep(.1)
title, story = cap_story()
assert title == "Message:"
assert title == "OK TO SIGN?"
assert ("Proof of Reserves" if num_ins > 1 else "BIP-322 Message") in story
if num_ins == 1:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not in story
assert msg.decode() in story
press_select()
time.sleep(.1)
title, story = cap_story()
assert "Proof of Reserves" in story
assert "Message Hash:" not in story
assert "warning" in story
if num_ins == 1:
assert "WIF store: 0" in story
else:
assert f"WIF store: {', '.join([str(i) for i in range(num_ins)])}" in story
end_sign(finalize=True)
signed = end_sign()
bip322_verify(signed)
@pytest.mark.parametrize("bip32_paths", [True, False])
@pytest.mark.parametrize("por", [True, False])
def test_bip322_empty_message_challenge_rejected(bip32_paths, por, bip322_txn,
start_sign, cap_story):
def hack(psbt_in):
to_spend_tx = CTransaction()
to_sign_tx = CTransaction()
to_spend_tx.deserialize(BytesIO(psbt_in.inputs[0].utxo))
to_sign_tx.deserialize(BytesIO(psbt_in.txn))
if not bip32_paths:
psbt_in.inputs[0].bip32_paths = None
to_spend_tx.vout[0].scriptPubKey = b""
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
to_spend_tx.calc_sha256()
to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0),
nSequence=to_sign_tx.vin[0].nSequence)
psbt_in.txn = to_sign_tx.serialize_with_witness()
ins = [["p2wpkh", None, None]]
if por:
ins.append(["p2wpkh", None, 10000000])
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
start_sign(psbt)
title, story = cap_story()
assert title == "Failure"
# EOF

View File

@ -14,7 +14,7 @@ from pysecp256k1 import ec_seckey_verify, ec_pubkey_parse, ec_pubkey_serialize,
from mnemonic import Mnemonic
from bip32 import BIP32Node
from constants import AF_P2WSH
from charcodes import KEY_QR
from charcodes import KEY_QR, KEY_NFC
from bbqr import split_qrs
from psbt import BasicPSBT
@ -631,8 +631,9 @@ def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
@pytest.mark.parametrize("mag", [1000000, None, 2])
def test_ccc_magnitude(mag_ok, mag, setup_ccc, ccc_ms_setup,
bitcoind, settings_set, policy_sign,
bitcoind_create_watch_only_wallet):
bitcoind_create_watch_only_wallet, goto_home):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -669,8 +670,8 @@ def test_ccc_magnitude(mag_ok, mag, setup_ccc, ccc_ms_setup,
@pytest.mark.parametrize("whitelist_ok", [True, False])
def test_ccc_whitelist(whitelist_ok, setup_ccc, ccc_ms_setup,
bitcoind, settings_set, policy_sign,
bitcoind_create_watch_only_wallet):
bitcoind_create_watch_only_wallet, goto_home):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -702,13 +703,59 @@ def test_ccc_whitelist(whitelist_ok, setup_ccc, ccc_ms_setup,
policy_sign(bitcoind_wo, psbt, violation=None if whitelist_ok else "whitelist")
def test_ccc_whitelist_nfc_import(setup_ccc, settings_set, pick_menu_item, cap_story,
cap_menu, press_select, press_nfc, nfc_write_text,
settings_get, skip_if_useless_way, is_q1, goto_home):
skip_if_useless_way("nfc")
goto_home()
settings_set("ccc", None)
addr = "bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e"
setup_ccc(vel="Unlimited")
pick_menu_item("Spending Policy")
pick_menu_item("Whitelist Addresses" if is_q1 else "Whitelist")
time.sleep(.1)
m = cap_menu()
assert "(none yet)" in m
assert "Import from File" in m
pick_menu_item("Import from File")
time.sleep(.1)
_, story = cap_story()
if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
pytest.xfail("NFC disabled")
press_nfc()
time.sleep(.2)
nfc_write_text(addr)
time.sleep(.3)
_, story = cap_story()
assert "Added new address to whitelist" in story
assert addr in story
press_select()
time.sleep(.1)
m = cap_menu()
mi_addrs = [a for a in m if '' in a]
assert len(mi_addrs) == 1
_start, _end = mi_addrs[0].split('')
assert addr.startswith(_start)
assert addr.endswith(_end)
assert settings_get("ccc")["pol"]["addrs"] == [addr]
@pytest.mark.bitcoind
@pytest.mark.parametrize("velocity_mi", ['6 blocks (hour)', '48 blocks (8h)'])
def test_ccc_velocity(velocity_mi, setup_ccc, ccc_ms_setup, bitcoind, settings_set,
policy_sign, settings_get, bitcoind_create_watch_only_wallet,
enter_enabled_ccc, pick_menu_item, cap_story, need_keypress,
press_select, press_cancel):
press_select, press_cancel, goto_home):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -811,8 +858,8 @@ def test_ccc_velocity(velocity_mi, setup_ccc, ccc_ms_setup, bitcoind, settings_s
@pytest.mark.bitcoind
def test_ccc_warnings(setup_ccc, ccc_ms_setup, bitcoind, settings_set, policy_sign,
bitcoind_create_watch_only_wallet, settings_get):
bitcoind_create_watch_only_wallet, settings_get, goto_home):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -871,9 +918,10 @@ def test_ccc_warnings(setup_ccc, ccc_ms_setup, bitcoind, settings_set, policy_si
def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim_exec,
bitcoind, settings_get, load_export, press_cancel, restore_main_seed,
bitcoind_create_watch_only_wallet, policy_sign, goto_eph_seed_menu,
pick_menu_item, word_menu_entry, press_select, import_multisig):
pick_menu_item, word_menu_entry, press_select, import_multisig, goto_home):
# - maxed out values: 24 words, 25 whitelisted p2wsh values
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -930,12 +978,79 @@ def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim
restore_main_seed()
@pytest.mark.bitcoind
def test_ccc_whitelist_overlimit_no_mutation(settings_set, setup_ccc, enter_enabled_ccc,
ccc_ms_setup, bitcoind_create_watch_only_wallet,
settings_get, pick_menu_item, cap_menu, cap_story,
cap_screen, scan_a_qr, press_select, press_cancel, goto_home,
is_q1, microsd_path, need_keypress):
# An over-limit whitelist import must be rejected WITHOUT having already
# mutated the (settings-backed) policy address list.
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
c_words = "cluster comic depend absent grain circle demand tag pass clock certain strategy lunar bless pulse useful comfort fatigue glove decorate taste allow adult journey".split()
setup_ccc(c_words=c_words, mag=100000000, vel=None, whitelist=None)
b_words = "ceiling apology excite illegal accident define boat prosper decrease utility romance try trial dizzy win lawsuit much sustain similar meadow draw oil cousin wagon".split()
_, target_mi = ccc_ms_setup(b_words=b_words)
bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi)
enter_enabled_ccc(c_words)
desc_str = bitcoind_wo.listdescriptors()["descriptors"][0]["desc"]
addrs = bitcoind_wo.deriveaddresses(desc_str, (0, 25))
base, extra = addrs[:24], addrs[24:26]
setup_ccc(c_words, whitelist=base, first_time=False)
assert len(settings_get("ccc")["pol"]["addrs"]) == 24
# back at the CCC menu now -- import 2 more
pick_menu_item("Spending Policy")
pick_menu_item("Whitelist Addresses" if is_q1 else "Whitelist")
time.sleep(.1)
if is_q1:
pick_menu_item("Scan QR")
for i, a in enumerate(extra, start=1):
scan_a_qr(a)
for _ in range(10):
scr = cap_screen()
if (f"Got {i} so far" in scr) and ("ENTER to apply" in scr):
break
time.sleep(.2)
else:
assert False, "scan not registered"
press_select()
else:
fname = "ccc_over.txt"
with open(microsd_path(fname), "w") as f:
for a in extra:
f.write(a + "\n")
pick_menu_item("Import from File")
time.sleep(.1)
_, story = cap_story()
if "Press (1)" in story:
need_keypress("1")
pick_menu_item(fname)
time.sleep(.2)
_, story = cap_story()
assert "Max %d items in whitelist" % 25 in story
press_select()
assert settings_get("ccc")["pol"]["addrs"] == base
press_cancel()
press_cancel()
@pytest.mark.parametrize("seed_vault", [True, False])
def test_load_and_sign_key_C(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim_exec,
bitcoind_create_watch_only_wallet, pick_menu_item, load_export,
cap_story, press_cancel, bitcoind, policy_sign, restore_main_seed,
verify_ephemeral_secret_ui, word_menu_entry, import_multisig,
verify_ephemeral_secret_ui, word_menu_entry, import_multisig, goto_home,
press_select, settings_get, seed_vault, confirm_tmp_seed):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
@ -1269,4 +1384,105 @@ def test_ms_setup_cosigner_import(way, ftype, is_bbqr, N, goto_home, settings_se
for _, obj in keys:
assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc
def test_ccc_challenge_qr_bad_checksum_crash(setup_ccc, goto_ccc_menu, cap_story, need_keypress,
press_select, press_cancel, scan_a_qr, sim_exec,
settings_set, is_q1, goto_home):
if not is_q1:
pytest.skip('Q1 only (QR scan path)')
goto_home()
settings_set('ccc', None)
settings_set('seedvault', False) # avoid seed-vault bypass path
setup_ccc()
# reset the fail counter so the assertion below is unambiguous
sim_exec('import ccc; ccc.NUM_CHALLENGE_FAILS = 0')
goto_ccc_menu()
time.sleep(.1)
title, story = cap_story()
assert title == 'CCC Enabled'
assert 'policy cannot be viewed' in story
press_select()
time.sleep(.1)
need_keypress(KEY_QR)
time.sleep(.1)
# SeedQR with 12 zero-indices = "abandon" * 12 = wordlist-valid but
# consensus-invalid BIP-39 checksum
bad_seed_qr = '0000' * 12
scan_a_qr(bad_seed_qr)
time.sleep(.5)
press_select()
title, story = cap_story()
assert 'Sorry, those words are incorrect' in story
# The challenge callback must have been reached -- counter stays 1.
fails = int(sim_exec('import ccc; RV.write(str(ccc.NUM_CHALLENGE_FAILS))'))
assert fails == 1
press_cancel()
press_cancel()
def test_ccc_magnitude_cancel_preserves_value(setup_ccc, enter_enabled_ccc, settings_set,
settings_get, pick_menu_item, cap_menu, goto_home,
press_select, press_cancel, press_delete):
goto_home()
settings_set('ccc', None)
c_words = setup_ccc(mag=1) # 1 BTC magnitude
assert settings_get('ccc')['pol']['mag'] == 1
enter_enabled_ccc(c_words)
pick_menu_item('Spending Policy')
pick_menu_item('Max Magnitude')
time.sleep(.1)
press_delete() # delete 1
time.sleep(.1)
press_cancel()
time.sleep(.1)
menu = cap_menu()
# back in the menu, CANCEL on empty value
assert 'Max Magnitude' == menu[0]
# magnitude unchanged
time.sleep(.1)
mag = settings_get('ccc')['pol']['mag']
assert mag == 1
@pytest.mark.bitcoind
def test_ccc_whitelist_op_return(setup_ccc, ccc_ms_setup, bitcoind, settings_set,
policy_sign, bitcoind_create_watch_only_wallet,
goto_home):
goto_home()
settings_set("ccc", None)
settings_set("chain", "XRT")
settings_set("multisig", [])
whitelist = ["bcrt1qqca9eefwz8tzn7rk6aumhwhapyf5vsrtrddxxp"]
setup_ccc(whitelist=whitelist, vel="Unlimited")
_, target_mi = ccc_ms_setup()
bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi)
multi_addr = bitcoind_wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=5.0)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
op_return_data = b"Coldcard CCC OP_RETURN test"
send_to = whitelist[0]
psbt_resp = bitcoind_wo.walletcreatefundedpsbt(
[], [{send_to: 1}, {"data": op_return_data.hex()}], 0, {"fee_rate": 2}
)
psbt = psbt_resp.get("psbt")
policy_sign(bitcoind_wo, psbt, violation="whitelist")
# EOF

View File

@ -396,4 +396,23 @@ def test_export_nfc_when_disabled(pick_menu_item, goto_home, cap_story, press_se
assert "Ready To Sign" in m
def test_bip85_index_cancel(goto_home, pick_menu_item, press_select, press_cancel,
cap_screen, is_q1):
mi = 'Derive Seed B85' if not is_q1 else 'Derive Seeds (BIP-85)'
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item(mi)
press_select() # intro story
time.sleep(.1)
pick_menu_item('12 words')
time.sleep(.1)
screen = cap_screen()
assert 'Index Number' in screen
# cancel should pop back to the choices menu, can_cancel=True
press_cancel()
time.sleep(.2)
screen = cap_screen()
assert 'Index Number' not in screen
# EOF

View File

@ -512,6 +512,28 @@ def test_ephemeral_seed_generate(num_words, generate_ephemeral_words, dice,
restore_main_seed(preserve_settings)
def test_ephemeral_seed_import_qr_bad_checksum(reset_seed_words, goto_eph_seed_menu,
pick_menu_item, scan_a_qr, cap_story,
press_cancel, is_q1):
if not is_q1:
pytest.skip('Q1 only (QR scan path)')
reset_seed_words()
goto_eph_seed_menu()
pick_menu_item('Import from QR Scan')
time.sleep(.1)
# SeedQR with 12 zero-indices = "abandon" * 12, wordlist-valid but
# consensus-invalid BIP-39 checksum.
scan_a_qr('0000' * 12)
time.sleep(.5)
title, story = cap_story()
assert 'checksum fail' in story
press_cancel()
press_cancel()
@pytest.mark.parametrize("num_words", [12, 18, 24])
@pytest.mark.parametrize("way", ["input", "nfc", "qr"])
@pytest.mark.parametrize("truncated", [False, True])

View File

@ -20,7 +20,7 @@ import pytest, time, os, pdb
from bip32 import BIP32Node
from constants import simulator_fixed_words, simulator_fixed_xprv
from test_ephemeral import SEEDVAULT_TEST_DATA, WORDLISTS
from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
from test_ux import word_menu_entry
from charcodes import KEY_QR
@ -28,6 +28,7 @@ from charcodes import KEY_QR
def set_hobble(sim_exec, settings_set, settings_remove, goto_home):
def doit(mode, enabled={}): # okeys, words, notes
assert mode in { True, False, 2 }
assert not (set(enabled) - {'okeys', 'words', 'notes'}), enabled
if mode:
v = dict(en=True, pol={})
@ -138,8 +139,8 @@ def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes,
assert set(m) == fm_expect, "File Mgmt menu wrong"
def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes,
sim_exec, settings_remove):
def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set,
need_some_notes, sim_exec, settings_remove, press_cancel):
'''
* load a secure note/pw; check readonly once hobbled
* cannot export
@ -160,6 +161,12 @@ def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, ne
m = cap_menu()
assert m == [ '"Title Here"', 'View Note', 'Sign Note Text' ]
set_hobble(True, {'notes', 'okeys'})
pick_menu_item('Secure Notes & Passwords')
pick_menu_item('1: Title Here')
assert cap_menu() == ['"Title Here"', 'View Note', 'Sign Note Text', 'Apply as BIP-39 Passphrase']
# clear notes, should not be offered
settings_remove('notes')
settings_remove('secnap')
@ -247,7 +254,7 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
# clear keys from sv, should not be offered in menu, even if okeys set.
settings_remove('seedvault')
set_hobble(True, {'okey'})
set_hobble(True, {'okeys'})
m = cap_menu()
assert 'Seed Vault' not in m
@ -495,5 +502,5 @@ def test_empty_notes_bug(set_hobble, goto_notes, cap_menu, pick_menu_item, is_q1
m = cap_menu()
assert len(m) == 1
assert m[0] == "(none saved yet)"
# EOF

View File

@ -114,15 +114,13 @@ def compute_policy_hash(policy):
return b2a_hex(sha256(json_.encode()).digest()).decode()
@pytest.fixture(autouse=True)
def enable_hsm_commands(dev, sim_exec, is_q1):
def enable_hsm_commands(settings_remove, settings_set, is_q1):
if is_q1:
raise pytest.skip("Q does not have HSM support")
cmd = 'from glob import settings; settings.set("hsmcmd", 1)'
sim_exec(cmd)
settings_set("hsmcmd", 1)
yield
cmd = 'from glob import settings; settings.remove_key("hsmcmd")'
sim_exec(cmd)
settings_remove("hsmcmd")
@pytest.fixture
@ -425,8 +423,8 @@ def start_hsm(request, dev, hsm_reset, hsm_status, need_keypress, press_select):
assert 'Last chance' in body2
assert 'Policy hash:' in body2
ll = body2.split('\n')[-1]
assert ll.startswith("Press ")
ch = ll[6]
assert ll.startswith("Press (")
ch = ll[7]
need_keypress(ch)
time.sleep(.100)
@ -937,6 +935,74 @@ def test_sign_msg_any(quick_start_hsm, attempt_msg_sign, addr_fmt=AF_CLASSIC):
for p in permit+block:
attempt_msg_sign(None, msg, p, addr_fmt=addr_fmt)
def test_bip322_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_txn):
psbt, _ = bip322_txn([["p2wpkh", "0/0", None]], msg=b"HSM BIP-322 message")
quick_start_hsm(DICT(msg_paths=["m/0/0"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/9"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
change_hsm(DICT(rules=[{}]))
attempt_psbt(psbt, "Message signing not permitted")
def test_bip322_por_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_txn):
psbt, _ = bip322_txn([
["p2wpkh", "0/0", None],
["p2wpkh", "0/1", 1000000],
["p2sh-p2wpkh", "0/2", 2000000],
["p2pkh", "0/3", 3000000],
], msg=b"HSM BIP-322 proof of reserves")
quick_start_hsm(DICT(msg_paths=["m/0/*"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2", "m/0/3"]))
attempt_psbt(psbt)
change_hsm(DICT(rules=[{}]))
attempt_psbt(psbt, "Message signing not permitted")
@pytest.mark.parametrize("M_N", [(2,3),(1,1)]) # TODO verify https://github.com/coinkite/afirmware/pull/653 fixes 1of 1case
def test_bip322_ms_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_ms_txn, import_ms_wallet, clear_ms, M_N):
clear_ms()
deriv = "m/48h/1h/0h/2h"
M, N = M_N
def path_mapper(idx):
return [0x80000030, 0x80000001, 0x80000000, 0x80000002, 0, 0]
keys = import_ms_wallet(M, N, name="hsm_bip322_msg", accept=True, addr_fmt=AF_P2WSH,
common=deriv, do_import=True, descriptor=True)
psbt, _ = bip322_ms_txn(1, M, keys, path_mapper=path_mapper, inp_af=AF_P2WSH,
msg=b"HSM multisig BIP-322 message")
quick_start_hsm(DICT(msg_paths=[deriv + "/0/0"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/48h/1h/0h/2h/0/9"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
def test_must_log(dev, start_hsm, sd_cards_eject, attempt_msg_sign, fake_txn, attempt_psbt, is_simulator):
# stop everything if can't log
policy = DICT(must_log=True, msg_paths=['m'], rules=[{}])
@ -1623,4 +1689,95 @@ def test_backup_policy_worst(unit_test, start_hsm, load_hsm_users):
start_hsm(policy)
unit_test('devtest/backups.py')
# USB validation for HSM commands (hsmcmd=1 in this module)
def test_nwur_short_args(dev):
msg = b'nwur' + struct.pack('<B', 1)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_nwur_trailing_garbage(dev):
msg = b'nwur' + struct.pack('<BBB', 3, 4, 0) + b'test' + b'\xff'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_rmur_short_args(dev):
msg = b'rmur'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_rmur_trailing_garbage(dev):
msg = b'rmur' + struct.pack('<B', 4) + b'test' + b'\xff'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_user_short_args(dev):
msg = b'user' + struct.pack('<I', 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_user_trailing_garbage(dev):
msg = b'user' + struct.pack('<IBB', 0, 4, 6) + b'test' + b'123456' + b'\xff'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_hsms_short_args(dev):
msg = b'hsms' + struct.pack('<I', 100)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_hsms_trailing_garbage(dev):
msg = b'hsms' + struct.pack('<I', 100) + bytes(32) + b'\xff'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_nwur_ul_exceeds_payload(dev):
msg = struct.pack('<4sBBB', b'nwur', 1, 10, 0) + b'ab'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_nwur_invalid_sl(dev):
msg = struct.pack('<4sBBB', b'nwur', 1, 5, 7) + b'alice' + b'x' * 7
with pytest.raises(CCProtoError):
dev.send_recv(msg, encrypt=False)
def test_user_zero_ul(dev):
msg = struct.pack('<4sIBB', b'user', 0, 0, 6) + b'000000'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_user_zero_tl(dev):
msg = struct.pack('<4sIBB', b'user', 0, 5, 0) + b'alice'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_user_tl_exceeds_payload(dev):
msg = struct.pack('<4sIBB', b'user', 0, 5, 32) + b'alice' + b'000000'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_rmur_zero_ul(dev):
msg = struct.pack('<4sB', b'rmur', 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_rmur_ul_exceeds_payload(dev):
msg = struct.pack('<4sB', b'rmur', 10) + b'ab'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
# EOF

View File

@ -2,7 +2,7 @@
#
# Message signing.
#
import pytest, time, os, itertools, hashlib, json
import pytest, time, os, itertools, hashlib, json, random
from bip32 import BIP32Node
from msg import verify_message, RFC_SIGNATURE_TEMPLATE, sign_message, parse_signed_message
from base64 import b64encode, b64decode
@ -11,6 +11,7 @@ from ckcc_protocol.constants import *
from constants import addr_fmt_names, msg_sign_unmap_addr_fmt
from charcodes import KEY_QR, KEY_NFC
from helpers import addr_from_display_format
from bbqr import split_qrs
def addr_fmt_from_subpath(subpath):
@ -543,7 +544,69 @@ def test_sign_msg_fails(dev, sign_on_microsd, msg, subpath, addr_fmt, concern,
assert concern in story
@pytest.mark.parametrize('msg,num_iter,expect', [
def test_sign_msg_malformed_json_subpath_type(open_microsd, microsd_path, goto_home,
pick_menu_item, cap_story, press_cancel):
fname = 't-msgsign-bad.json'
try: os.unlink(microsd_path(fname))
except OSError: pass
with open_microsd(fname, 'wt') as sd:
sd.write(json.dumps({"msg": "hello", "subpath": 84}))
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('File Management')
pick_menu_item('Sign Text File')
time.sleep(.1)
pick_menu_item(fname)
time.sleep(.1)
title, story = cap_story()
assert not story.startswith('Ok to sign this?')
assert story.startswith('Problem: subpath')
press_cancel()
with open_microsd(fname, 'wt') as sd:
sd.write(json.dumps({"msg": "hello", "addr_fmt": 8}))
pick_menu_item('Sign Text File')
time.sleep(.1)
pick_menu_item(fname)
time.sleep(.1)
title, story = cap_story()
assert not story.startswith('Ok to sign this?')
assert story.startswith('Problem: Invalid address format')
press_cancel()
def test_sign_msg_json_rejects_ui_control_chars(open_microsd, microsd_path,
goto_home, pick_menu_item, cap_story):
# JSON message-sign relaxes printable validation to allow \n and \t, but
# other C0 control bytes (e.g. \x01) must still be rejected
fname = 't-msgsign-ctrl.json'
try: os.unlink(microsd_path(fname))
except OSError: pass
with open_microsd(fname, 'wt') as sd:
sd.write(json.dumps({"msg": "\x01CONFIRM SEND\nrealmsg", "subpath": "m"}))
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('File Management')
pick_menu_item('Sign Text File')
time.sleep(.1)
pick_menu_item(fname)
time.sleep(.2)
title, story = cap_story()
assert not story.startswith('Ok to sign this?')
assert story.startswith('Problem: ')
assert 'must be ascii printable, tab, or newline' in story
@pytest.mark.parametrize('msg,num_iter,expect', [
('Test2', 1, 'IHra0jSywF1TjIJ5uf7IDECae438cr4o3VmG6Ri7hYlDL+pUEXyUfwLwpiAfUQVqQFLgs6OaX0KsoydpuwRI71o='),
('Test', 2, 'IDgMx1ljPhLHlKUOwnO/jBIgK+K8n8mvDUDROzTgU8gOaPDMs+eYXJpNXXINUx5WpeV605p5uO6B3TzBVcvs478='),
('Test1', 3, 'IEt/v9K95YVFuRtRtWaabPVwWOFv1FSA/e874I8ABgYMbRyVvHhSwLFz0RZuO87ukxDd4TOsRdofQwMEA90LCgI='),
@ -1021,6 +1084,36 @@ def test_sparrow_qr_sign_msg(msg, path, skip_if_useless_way, need_keypress, scan
assert res is True
def test_sparrow_qr_sign_msg_via_bbqr(skip_if_useless_way, need_keypress, scan_a_qr,
cap_story, press_select, msg_sign_export,
addr_vs_path, verify_msg_sign_story):
skip_if_useless_way("qr")
path = "m/84h/0"
msg = "a" * 240
data = "signmessage %s ascii:%s" % (path, msg)
addr_fmt = addr_fmt_from_subpath(path)
need_keypress(KEY_QR)
_, parts = split_qrs(data, 'U', encoding='2', max_version=20)
random.shuffle(parts)
for p in parts:
scan_a_qr(p)
time.sleep(1)
title, story = cap_story()
subpath = verify_msg_sign_story(story, msg, path, addr_fmt)
press_select()
signed_msg = msg_sign_export("qr")
ret_msg, addr, sig = parse_signed_message(signed_msg)
assert ret_msg == msg
addr_vs_path(addr, subpath, addr_fmt)
assert verify_message(addr, sig, ret_msg) is True
@pytest.mark.parametrize("msg", [(50*"a")+"\n\n"+(100*"b"), "Balance replenish 564565456254"])
def test_verify_scanned_signed_msg(msg, scan_a_qr, need_keypress, goto_home, cap_story,
skip_if_useless_way):

View File

@ -1279,7 +1279,7 @@ def fake_ms_txn(pytestconfig):
# - but has UTXO's to match needs
from struct import pack
def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, segwit_in=False,
def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None,
outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False, hack_psbt=None,
hack_change_out=False, input_amount=1E8, psbt_v2=None, bip67=True,
violate_script_key_order=False, path_mapper=None, inp_af=AF_P2WSH,
@ -1297,6 +1297,8 @@ def fake_ms_txn(pytestconfig):
psbt.txn_version = 2
psbt.input_count = num_ins
psbt.output_count = num_outs
if lock_time:
psbt.fallback_locktime = lock_time
txn = CTransaction()
txn.nVersion = 2
@ -1327,21 +1329,14 @@ def fake_ms_txn(pytestconfig):
)
# lots of supporting details needed for p2sh inputs
if inp_af:
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + sha256(script).digest()
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
if segwit_in:
psbt.inputs[i].witness_script = script
else:
psbt.inputs[i].redeem_script = script
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + sha256(script).digest()
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(pack('<I', j) for j in xfp_path)
@ -1357,24 +1352,24 @@ def fake_ms_txn(pytestconfig):
supply.vout.append(CTxOut(int(input_amount), scriptPubKey))
if not segwit_in:
psbt.inputs[i].utxo = supply.serialize_with_witness()
else:
if inp_af in [AF_P2WSH, AF_P2WSH_P2SH]:
psbt.inputs[i].witness_utxo = supply.vout[-1].serialize()
supply.calc_sha256()
if psbt_v2:
psbt.inputs[i].previous_txid = supply.hash
psbt.inputs[i].prevout_idx = 0
# TODO sequence
# TODO height timelock
# TODO time timelock
else:
psbt.inputs[i].utxo = supply.serialize_with_witness()
if lock_time and not i:
seq = 0xfffffffd
else:
seq = 0xffffffff
supply.calc_sha256()
if psbt_v2:
psbt.inputs[i].previous_txid = supply.hash
psbt.inputs[i].prevout_idx = 0
psbt.inputs[i].sequence = seq
# psbt.inputs[i].req_time_locktime = None
# psbt.inputs[i].req_height_locktime = None
spendable = CTxIn(COutPoint(supply.sha256, 0), nSequence=seq)
txn.vin.append(spendable)
@ -1508,13 +1503,31 @@ def test_ms_sign_simple(M_N, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, impor
else:
try_sign(psbt)
@pytest.mark.parametrize("finalize", [True, False])
def test_1of1_multisig_sign(finalize, clear_ms, import_ms_wallet, fake_ms_txn, start_sign,
end_sign, cap_story):
# Minimal 1-of-1 multisig: import the wallet, then sign a 1-in/1-out PSBT.
clear_ms()
M = N = 1
keys = import_ms_wallet(M, N, accept=True)
psbt = fake_ms_txn(1, 1, M, keys)
start_sign(psbt, finalize=finalize)
title, story = cap_story()
assert title == "OK TO SEND?"
end_sign(accept=True, finalize=finalize)
@pytest.mark.unfinalized
@pytest.mark.bitcoind
@pytest.mark.parametrize('num_ins', [ 15 ])
@pytest.mark.parametrize('M', [ 2, 4, 1])
@pytest.mark.parametrize('segwit', [True, False])
@pytest.mark.parametrize('addr_fmt', [AF_P2SH, AF_P2WSH])
@pytest.mark.parametrize('incl_xpubs', [ True, False ])
def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms,
def test_ms_sign_myself(M, use_regtest, make_myself_wallet, addr_fmt, num_ins, dev, clear_ms,
fake_ms_txn, try_sign, incl_xpubs, bitcoind, sim_root_dir):
# IMPORTANT: wont work if you start simulator with --ms flag. Use no args
@ -1526,13 +1539,20 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev
use_regtest()
# create a wallet, with 3 bip39 pw's
keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs))
if addr_fmt == AF_P2WSH:
af = "p2wsh"
elif addr_fmt == AF_P2SH:
af = "p2sh"
else:
assert False
keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs), addr_fmt=af)
N = len(keys)
assert M<=N
psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs,
psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs,
outstyles=all_out_styles, change_outputs=list(range(1,num_outs)),
inp_af=AF_P2SH)
inp_af=addr_fmt)
with open(f'{sim_root_dir}/debug/myself-before.psbt', 'w') as f:
f.write(b64encode(psbt).decode())
@ -3094,7 +3114,7 @@ def test_ms_wallet_ordering(clear_ms, import_ms_wallet, try_sign_microsd, fake_m
name = f'ms2'
keys3 = import_ms_wallet(3, 5, name=name, accept=1, do_import=True, addr_fmt="p2wsh")
psbt = fake_ms_txn(5, 5, 3, keys3, outstyles=all_out_styles, segwit_in=True, incl_xpubs=True)
psbt = fake_ms_txn(5, 5, 3, keys3, outstyles=all_out_styles, incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
@ -3115,12 +3135,12 @@ def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wa
import_ms_wallet(M, N, keys=opt, name=name, accept=1, do_import=True,
addr_fmt="p2wsh", descriptor=descriptor)
psbt = fake_ms_txn(5, 5, M, opt, outstyles=all_out_styles,
segwit_in=True, incl_xpubs=True)
incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
for opt_1 in all_options:
# create PSBT with original keys order
psbt = fake_ms_txn(5, 5, M, opt_1, outstyles=all_out_styles,
segwit_in=True, incl_xpubs=True)
incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
@ -3381,6 +3401,34 @@ def test_bare_cc_ms_qr_import(N, make_multisig, scan_a_qr, clear_ms, goto_home,
press_cancel()
def test_ms_qr_import_per_cosigner_paths(make_multisig, scan_a_qr, clear_ms, goto_home,
pick_menu_item, cap_story, press_cancel, is_q1):
# this wasn't tested
# not needed on EDGE
if not is_q1:
raise pytest.skip("No QR support for Mk4")
clear_ms()
M, N = 2, 3
deriv_tmpl = "m/214748364{idx}h/" + "/".join(["2147483647h"] * 11) # 12 components
keys = make_multisig(M, N, deriv=deriv_tmpl)
config = "Name: per-path-qr\nPolicy: %d of %d\nFormat: P2WSH\n\n" % (M, N)
for idx, (xfp, master, sub) in enumerate(keys):
config += "Derivation: %s\n%s: %s\n\n" % (deriv_tmpl.format(idx=idx),
xfp2str(xfp), sub.hwif(as_private=False))
actual_vers, parts = split_qrs(config, 'U', max_version=20)
random.shuffle(parts)
goto_home()
pick_menu_item("Scan Any QR Code")
for p in parts:
scan_a_qr(p)
time.sleep(2.0 / len(parts))
time.sleep(.1)
title, story = cap_story()
assert "Create new multisig wallet?" in story
press_cancel()
@pytest.mark.parametrize("desc", ["multi", "sortedmulti"])
@pytest.mark.parametrize("data", [
# (out_style, amount, is_change)
@ -3443,6 +3491,78 @@ def test_import_duplicate_shuffled_keys_legacy(clear_ms, make_multisig, import_m
assert f'{OK} to approve' not in story
press_cancel()
def test_import_reorder_different_name_multi(clear_ms, make_multisig, offer_ms_import,
settings_set, cap_story, press_select,
press_cancel):
settings_set("unsort_ms", 1)
clear_ms()
M, N = 2, 3
keys = make_multisig(M, N)
def build_desc(klist):
key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist]
d = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH, is_sorted=False)
return d.serialize()
val_a = json.dumps({"name": "victim", "desc": build_desc(keys)})
title, story = offer_ms_import(val_a)
assert "Create new multisig" in story
press_select()
time.sleep(.1)
keys[0], keys[1] = keys[1], keys[0]
val_b = json.dumps({"name": "attacker", "desc": build_desc(keys)})
title, story = offer_ms_import(val_b)
assert "Update NAME only" not in story
assert "Duplicate wallet. key order" in story
press_cancel()
@pytest.mark.parametrize("is_sorted", [True, False])
def test_import_same_keys_same_order_rename(is_sorted, clear_ms, make_multisig, offer_ms_import,
settings_set, cap_story, press_select, press_cancel):
settings_set("unsort_ms", 1)
clear_ms()
M, N = 2, 3
keys = make_multisig(M, N)
key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in keys]
desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH,
is_sorted=is_sorted).serialize()
title, story = offer_ms_import(json.dumps({"name": "original", "desc": desc}))
assert "Create new multisig" in story
press_select()
time.sleep(.1)
title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": desc}))
assert "Update NAME only" in story
assert "Duplicate wallet" not in story
press_cancel()
def test_import_sortedmulti_reorder_rename(clear_ms, make_multisig, offer_ms_import,
cap_story, press_select, press_cancel):
clear_ms()
M, N = 2, 3
keys = make_multisig(M, N)
def build_desc(klist):
key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist]
return MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH,
is_sorted=True).serialize()
title, story = offer_ms_import(json.dumps({"name": "original", "desc": build_desc(keys)}))
assert "Create new multisig" in story
press_select()
time.sleep(.1)
keys[0], keys[1] = keys[1], keys[0]
title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": build_desc(keys)}))
assert "Update NAME only" in story
assert "Duplicate wallet" not in story
press_cancel()
@pytest.mark.parametrize("order", list(itertools.product([True, False], repeat=2)))
def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_wallet,
cap_story, press_cancel, order, OK):
@ -3982,7 +4102,7 @@ def test_change_output_script_type(clear_ms, import_ms_wallet, start_sign, end_s
sign_check(psbt)
psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh-p2wsh",
change_outputs=[0,1], inp_af=AF_P2SH, segwit_in=True)
change_outputs=[0,1], inp_af=AF_P2SH)
sign_check(psbt)
@ -4207,7 +4327,7 @@ def test_fwd_slash_in_name(import_ms_wallet, clear_ms, pick_menu_item, need_keyp
@pytest.mark.parametrize("chain", ["BTC", "XTN"])
@pytest.mark.parametrize("M_N", [(3, 5)])#, (14, 15)])
@pytest.mark.parametrize("complete", [True, False, None])
@pytest.mark.parametrize("complete", [False, None])
@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh", "p2sh-p2wsh"])
def test_txin_explorer(dev, chain, M_N, addr_fmt, fake_ms_txn, start_sign, settings_set, txin_explorer,
cap_story, pytestconfig, import_ms_wallet, complete, clear_ms):
@ -4222,9 +4342,7 @@ def test_txin_explorer(dev, chain, M_N, addr_fmt, fake_ms_txn, start_sign, setti
descriptor=True, addr_fmt=addr_fmt)
all_xfps = [xfp2str(k[0]) for k in keys][:-1] # remove myself
if complete:
target_xfps = all_xfps[:M]
elif complete is False:
if complete is False:
target_xfps = all_xfps[:M-1]
else:
target_xfps = []
@ -4275,4 +4393,54 @@ def test_txin_explorer_our_sig(dev, fake_ms_txn, start_sign, settings_set, clear
start_sign(psbt)
txin_explorer(num_ins, [(af, inp_amount, 0, "XTN", (M,N), None, None, False, [my_xfp])])
def test_ms_xpubs_account_cancel(goto_home, pick_menu_item, press_cancel, cap_menu, press_select):
goto_home()
pick_menu_item('Settings')
pick_menu_item('Multisig Wallets')
pick_menu_item('Export XPUB')
press_select() # confirm story
time.sleep(.1)
press_cancel()
time.sleep(.2)
assert "Export XPUB" in cap_menu()
@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh", "p2sh"])
@pytest.mark.parametrize("num_ins", [1, 10])
@pytest.mark.parametrize("incl_self", [True, False])
def test_fully_signed(addr_fmt, num_ins, import_ms_wallet, fake_ms_txn, start_sign, cap_story,
press_cancel, clear_ms, incl_self):
clear_ms()
M, N = 2, 4
keys = import_ms_wallet(M, N, name='fully_signed', accept=True, netcode="XTN",
descriptor=True, addr_fmt="p2wsh")
# both below cases include full necessary (dummy)signature set (M)
if incl_self:
i, j = 2, 4 # remove two random co-signers, keep myself as already signed
else:
i, j = 0, 2 # remove myself + one more random co-signer
xfps = [xfp2str(k[0]) for k in keys][i:j]
assert len(xfps) == M
def hack(psbt):
for inp in psbt.inputs:
for i, (pk, pth) in enumerate(inp.bip32_paths.items()):
xfp = pth[:4].hex().upper()
if xfp in xfps:
inp.part_sigs[pk] = os.urandom(71) # fake sig
psbt = fake_ms_txn(num_ins, 2, M, keys, inp_af=unmap_addr_fmt[addr_fmt],
hack_psbt=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert "Failure" == title
assert "completely signed already" in story
press_cancel()
# EOF

View File

@ -666,4 +666,36 @@ def test_nfc_share_files(fname, mode, ftype, nfc_read_json, nfc_read_text,
assert res == contents
os.remove(f'{sim_root_dir}/MicroSD/' + fname)
def test_verify_address_nfc_cancel(goto_home, pick_menu_item, press_cancel,
cap_story, enable_nfc, cap_menu, nfc_write,
nfc_write_text):
# pressing cancel during 'Verify Address' NFC prompt must not "crash".
enable_nfc()
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("NFC Tools")
pick_menu_item("Verify Address")
time.sleep(0.1)
press_cancel()
time.sleep(0.1)
assert "Verify Address" in cap_menu()
pick_menu_item("Verify Address")
nfc_write_text("empty")
time.sleep(0.1)
title, story = cap_story()
assert "Unable to find address from NFC data" in story
press_cancel()
time.sleep(.1)
assert "Verify Address" in cap_menu()
pick_menu_item("Verify Address")
nfc_write(b"empty")
time.sleep(0.1)
title, story = cap_story()
assert "No tag data" in story
press_cancel()
time.sleep(.1)
assert "Verify Address" in cap_menu()
# EOF

View File

@ -5,8 +5,11 @@
import pytest, time, json, random, os, pdb
from helpers import prandom
from charcodes import *
from constants import AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH
from constants import AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, simulator_fixed_words
from bbqr import split_qrs
from ckcc.protocol import CCProtocolPacker
from bip32 import BIP32Node
from mnemonic import Mnemonic
# All tests in this file are exclusively meant for Q
@ -98,7 +101,7 @@ def build_note(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
need_keypress, cap_screen_qr, readback_bbqr, nfc_read_text,
press_select, press_cancel, is_headless, nfc_disabled):
def doit(n_title, n_body):
def doit(n_title, n_body, group=None):
# we don't try to preserve leading/trailing spaces on note bodies
n_body= n_body.strip()
@ -107,6 +110,11 @@ def build_note(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
# create
enter_text(n_title)
enter_text(n_body, multiline=True)
if group:
pick_menu_item('New Group')
enter_text(group)
else:
pick_menu_item('(none)')
# view
time.sleep(0.1)
@ -172,6 +180,10 @@ def build_note(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
obj = obj[0]
assert obj['title'] == n_title
assert obj['misc'] == n_body
if group:
assert obj['group'] == group
else:
assert 'group' not in obj
# back to top notes menu
press_select()
@ -185,7 +197,8 @@ def build_password(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
cap_text_box, settings_get, settings_set, scan_a_qr,
press_select, press_cancel, is_headless):
def doit(n_title, n_user=None, n_pw='secret', n_site=None, n_body=None, key_pw=None):
def doit(n_title, n_user=None, n_pw='secret', n_site=None, n_body=None,
key_pw=None, group=None):
goto_notes('New Password')
enter_text(n_title)
if n_user:
@ -221,6 +234,11 @@ def build_password(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
enter_text(n_body, multiline=True)
else:
press_cancel()
if group:
pick_menu_item('New Group')
enter_text(group)
else:
pick_menu_item('(none)')
# view
time.sleep(0.1)
@ -282,7 +300,7 @@ def change_password(goto_notes, pick_menu_item, enter_text, cap_story,
cap_menu):
def doit(id_title, new_title=None, new_username=None, new_site=None,
new_misc=None, new_password=None, old_password=None):
new_misc=None, new_password=None, new_group=None):
goto_notes()
m = cap_menu()
found = [i for i in m if f': {id_title}' in i]
@ -313,6 +331,18 @@ def change_password(goto_notes, pick_menu_item, enter_text, cap_story,
need_in_story.append('Other Notes')
else:
press_cancel()
if new_group is not None:
if new_group:
if new_group in cap_menu():
pick_menu_item(new_group)
else:
pick_menu_item('New Group')
enter_text(new_group)
else:
pick_menu_item('(none)')
need_in_story.append('Group')
else:
press_cancel()
# approve change
time.sleep(0.1)
@ -349,6 +379,8 @@ def change_password(goto_notes, pick_menu_item, enter_text, cap_story,
assert note['misc'] == new_misc
if new_password:
assert note['password'] == new_password
if new_group is not None:
assert note.get('group', '') == new_group
return doit
@ -363,7 +395,7 @@ def test_build_note(n_title, n_body, build_note, delete_note):
@pytest.mark.parametrize('size', [ 4000, 30000])
@pytest.mark.parametrize('encoding', '2Z' )
def test_huge_notes(size, encoding, goto_notes, enter_text, cap_menu, need_keypress,
scan_a_qr, settings_set, settings_get):
scan_a_qr, settings_set, settings_get, pick_menu_item):
# Since we don't limit note sizes, by request of NVK ... test them
@ -387,8 +419,9 @@ def test_huge_notes(size, encoding, goto_notes, enter_text, cap_menu, need_keypr
time.sleep(2.0 / len(parts)) # just so we can watch
time.sleep(.5) # decompression time in some cases
pick_menu_item('(none)')
m = cap_menu()
assert m[-2] == 'Export'
assert 'Export' in m
notes = settings_get('notes')
assert len(notes) == 1
@ -479,6 +512,168 @@ def test_sort_by_title(goto_notes, pick_menu_item, cap_story, need_keypress, set
assert sorted((i['title'] for i in after), key=lambda i:i.lower()) \
== [i['title'] for i in after]
def test_grouped_note_menu(settings_set, settings_get, goto_notes, cap_menu,
pick_menu_item, build_note, press_cancel, press_select):
settings_set('notes', [])
settings_set('secnap', True)
build_note('group-note', 'body', group='Work')
notes = settings_get('notes')
assert notes[-1]['group'] == 'Work'
goto_notes()
m = cap_menu()
assert '↳ Work' in m
assert not any(': group-note' in i for i in m)
press_select()
m = cap_menu()
assert '1: group-note' in m
press_cancel()
def test_grouped_password_menu(settings_set, settings_get, goto_notes, cap_menu,
pick_menu_item, build_password, press_cancel, press_select):
settings_set('notes', [])
settings_set('secnap', True)
build_password('group-pw', n_pw='secret', group='Accounts')
press_cancel()
notes = settings_get('notes')
assert notes[-1]['group'] == 'Accounts'
goto_notes()
m = cap_menu()
assert '↳ Accounts' in m
assert not any(': group-pw' in i for i in m)
press_select()
m = cap_menu()
assert '1: group-pw' in m
press_cancel()
def test_grouped_and_ungrouped_menu(settings_set, goto_notes, cap_menu,
pick_menu_item, press_cancel):
settings_set('secnap', True)
settings_set('notes', [
{'title': 'loose-note', 'misc': 'aaa'},
{'title': 'work-note', 'misc': 'bbb', 'group': 'Work'},
{'title': 'work-pw', 'misc': '', 'password': 'secret', 'site': '',
'user': '', 'group': 'Work'},
])
goto_notes()
m = cap_menu()
assert '1: loose-note' in m
assert '↳ Work' in m
assert not any(': work-note' in i for i in m)
assert not any(': work-pw' in i for i in m)
pick_menu_item('↳ Work')
m = cap_menu()
assert '2: work-note' in m
assert '3: work-pw' in m
press_cancel()
def test_new_grouped_note_cancel_lands_in_group(settings_set, goto_notes, cap_menu,
pick_menu_item, enter_text, press_cancel):
settings_set('notes', [])
settings_set('secnap', True)
goto_notes('New Note')
enter_text('new-note')
enter_text('body', multiline=True)
pick_menu_item('New Group')
enter_text('Work')
assert '"new-note"' in cap_menu()
press_cancel()
m = cap_menu()
assert '1: new-note' in m
assert 'New Note' not in m
press_cancel()
m = cap_menu()
assert '↳ Work' in m
assert '1: new-note' not in m
def test_edit_note_group_moves(settings_set, settings_get, goto_notes, cap_menu,
pick_menu_item, enter_text, press_select,
press_cancel, cap_story):
settings_set('secnap', True)
settings_set('notes', [{'title': 'move-note', 'misc': 'body'}])
goto_notes()
pick_menu_item('1: move-note')
pick_menu_item('Edit')
press_select() # unchanged title
press_cancel() # unchanged note body
pick_menu_item('New Group')
enter_text('Work')
time.sleep(.1)
title, story = cap_story()
assert "SURE" in title
assert 'Group' in story
press_select()
time.sleep(.1)
goto_notes()
m = cap_menu()
assert '↳ Work' in m
assert '1: move-note' not in m
press_select()
pick_menu_item('1: move-note')
pick_menu_item('Edit')
press_select()
press_cancel()
pick_menu_item('New Group')
enter_text('Home')
time.sleep(.1)
title, story = cap_story()
assert 'SURE' in title
assert 'Group' in story
press_select()
goto_notes()
m = cap_menu()
assert '↳ Work' not in m
assert '↳ Home' in m
press_select()
pick_menu_item('1: move-note')
pick_menu_item('Edit')
press_select()
press_cancel()
pick_menu_item('(none)')
time.sleep(.1)
title, story = cap_story()
assert 'SURE' in title
assert 'Group' in story
press_select()
press_cancel()
m = cap_menu()
assert '↳ Home' not in m
assert '1: move-note' in m
assert '(none)' not in m
def test_old_records_without_group(settings_set, settings_get, goto_notes, cap_menu):
settings_set('secnap', True)
settings_set('notes', [{'title': 'old-note', 'misc': 'body'}])
goto_notes()
assert '1: old-note' in cap_menu()
assert settings_get('notes')[0].get('group', '') == ''
def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get,
settings_set, scan_a_qr, need_some_notes):
# make some
@ -520,6 +715,31 @@ def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get
goto_notes()
def test_top_import_u_typed_json(goto_notes, cap_menu, cap_story, need_keypress,
settings_get, settings_set, scan_a_qr):
settings_set('notes', [])
goto_notes('Import')
need_keypress(KEY_QR)
notes = {"coldcard_notes": [{"title": "demo", "misc": "x"}]}
jj = json.dumps(notes)
_, parts = split_qrs(jj, 'U', max_version=20) # deliberately U-typed
for p in parts:
scan_a_qr(p)
time.sleep(.5)
m = cap_menu()
for _ in range(3):
if "1:" in m[0]:
break
time.sleep(.2)
m = cap_menu()
assert settings_get('notes') == notes["coldcard_notes"]
goto_notes()
@pytest.mark.parametrize('qr,title', [
('otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30',
'ACME Co:john.doe@email.com'),
@ -672,6 +892,63 @@ def test_sign_note_body(msg, addr_fmt, acct, need_some_notes,
sign_msg_from_text(msg, addr_fmt, acct, False, 0, way)
def test_send_password_menu_item(need_some_passwords, goto_notes, cap_menu, pick_menu_item,
settings_set, settings_remove, press_cancel):
# covers regression where "Send Password" menu item was only shown when USB was disabled
settings_set("notes", [])
need_some_passwords()
settings_set('du', 1)
goto_notes()
pick_menu_item([i for i in cap_menu() if i.endswith(': A')][0])
time.sleep(.2)
m = cap_menu()
assert 'Send Password' not in m
press_cancel()
settings_set('du', 0)
goto_notes()
pick_menu_item([i for i in cap_menu() if i.endswith(': A')][0])
time.sleep(.2)
m = cap_menu()
assert 'Send Password' in m
for _ in range(3):
press_cancel()
@pytest.mark.onetime
def test_password_cancel_stores_empty_not_none(goto_notes, need_keypress, press_select,
press_cancel, enter_text, settings_get,
settings_set, cap_screen, pick_menu_item):
# canceling the password field when creating a new password entry stored
# None instead of ''. EmulatedKeyboard.can_type(None) then raised
# TypeError: 'NoneType' object is not iterable when "Send Password" was selected.
#
settings_set('secnap', True)
settings_set('notes', [])
goto_notes('New Password')
enter_text('cancel-pw-test') # title
press_select() # skip username
press_cancel() # cancel password field - bug, stores None
press_select() # skip site
press_cancel() # exit misc
pick_menu_item('(none)') # no group
time.sleep(0.2)
goto_notes()
pick_menu_item('1: cancel-pw-test')
pick_menu_item('Send Password')
time.sleep(.5)
scr = cap_screen()
assert 'Traceback' not in scr
assert "Place mouse at" in scr
for _ in range(5):
press_cancel()
@pytest.mark.parametrize("chain", ["BTC", "XTN"])
@pytest.mark.parametrize("change", [True, False])
@pytest.mark.parametrize("idx", [None, 0, 9999])
@ -692,4 +969,168 @@ def test_sign_password_free_form(chain, change, idx, need_some_passwords, settin
pick_menu_item("Sign Note Text")
sign_msg_from_text(msg, AF_P2WPKH, None, change, idx, "qr", chain)
@pytest.mark.parametrize("length", [1, 241])
def test_sign_misc_length(length, settings_set, cap_menu, goto_notes,
pick_menu_item, press_cancel):
msg = "a" * length
settings_set('notes', [
{'misc': msg,
'password': '89898989898989898989898989898',
'site': 'https://abaaba.com',
'title': "BA",
'user': 'BABA'},
{'title': "AB",
'misc': msg,}
])
goto_notes()
pick_menu_item(f"1: BA")
assert "Sign Note Text" not in cap_menu()
press_cancel()
pick_menu_item(f"2: AB")
assert "Sign Note Text" not in cap_menu()
@pytest.mark.parametrize("pw", [
"My secret BIP-39 passphrase!!",
"a" * 100,
"secret\n\t", # newline+tab will be stripped
"secret1 ", # space will be stripped
# below, not allowed
"a" * 101, # too long
"aaaaaaa\nbbbbbbbbb", # non-printable ASCII
])
@pytest.mark.parametrize("sv", [True, False]) # Seed Vault
@pytest.mark.parametrize("pwd", [True, False]) # whether note or password
def test_bip39_passphrase_from_note(dev, need_some_notes, settings_set, goto_notes, pick_menu_item,
cap_story, press_select, cap_menu, reset_seed_words, pw, sv, pwd,
seed_vault_enable, need_keypress, settings_remove):
reset_seed_words()
settings_remove("seeds") # clear
seed_vault_enable(enable=sv)
settings_set('notes', []) # clear
title = "A1"
if pwd:
settings_set('notes', [
{'misc': "some\nrandom\nnote",
'password': pw,
'site': 'https://a.com',
'title': title,
'user': 'AAA'}
])
mi = "Apply as BIP-39 Passphrase"
else:
need_some_notes(title=title, body=pw)
mi = "Apply as BIP-39 Passphrase"
goto_notes()
pick_menu_item(f"1: {title}")
time.sleep(.1)
if len(pw) > 100 or "\n" in pw:
# not allowed - must be ASCII 32-127 and length <= 100
assert mi not in cap_menu()
return # done
pick_menu_item(mi)
# firmware rstrips any note before using it
pw = pw.rstrip()
# what it should be
seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=pw)
expect = BIP32Node.from_master_secret(seed)
time.sleep(.1)
title, story = cap_story()
title_xfp = title[1:-1]
assert "created by adding passphrase to master seed [0F056943]" in story
assert expect.fingerprint().hex().upper() == title_xfp
press_select()
time.sleep(.2)
if sv:
title, story = cap_story()
assert "Press (1) to store temporary seed into Seed Vault" in story
time.sleep(.1)
need_keypress("1") # store it
time.sleep(.1)
title, story = cap_story()
assert "Saved to Seed Vault" in story
assert title_xfp in story
press_select()
assert title_xfp in cap_menu()[0]
xpub = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
got = BIP32Node.from_wallet_key(xpub)
assert got.sec() == expect.sec()
@pytest.mark.parametrize("words", [True, False])
@pytest.mark.parametrize("pwd", [True, False])
def test_b39_from_note_eph_seed(words, pwd, generate_ephemeral_words, set_bip39_pw, settings_remove,
reset_seed_words, settings_set, need_some_notes, goto_notes,
pick_menu_item, cap_menu, cap_story, press_select, dev):
reset_seed_words()
settings_remove("seeds")
settings_remove("seedvault")
if words:
e_seed_words = generate_ephemeral_words(num_words=12, seed_vault=False)
e_seed_words = " ".join(e_seed_words)
else:
set_bip39_pw('bdfhjkds', seed_vault=False, reset=False)
# enabling notes & pwds in temporary settings
settings_set('notes', []) # clear
title = "A1"
pw = "abcdefg" # allowed
if pwd:
settings_set('notes', [
{'misc': "some\nrandom\nnote",
'password': pw,
'site': 'https://a.com',
'title': title,
'user': 'AAA'}
])
mi = "Apply as BIP-39 Passphrase"
else:
need_some_notes(title=title, body=pw)
mi = "Apply as BIP-39 Passphrase"
goto_notes()
pick_menu_item(f"1: {title}")
time.sleep(.1)
if not words:
# no way to apply passphrase on secret that is not word-based
assert mi not in cap_menu()
return # done
pick_menu_item(mi)
# what it should be
e_xfp = BIP32Node.from_master_secret(Mnemonic.to_seed(e_seed_words)).fingerprint().hex().upper()
seed = Mnemonic.to_seed(e_seed_words, passphrase=pw)
expect = BIP32Node.from_master_secret(seed)
time.sleep(.1)
title, story = cap_story()
title_xfp = title[1:-1]
assert f"created by adding passphrase to current active temporary seed [{e_xfp}]" in story
assert expect.fingerprint().hex().upper() == title_xfp
press_select()
time.sleep(.2)
assert title_xfp in cap_menu()[0]
xpub = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None)
got = BIP32Node.from_wallet_key(xpub)
assert got.sec() == expect.sec()
# EOF

View File

@ -268,6 +268,56 @@ def test_ux(valid, testnet, method,
assert "1 wallet(s)" in story
assert 'without finding a match' in story
@pytest.mark.parametrize('addr', [
'7FzPuteovG12fi', # valid Base58Check, but not a payment address
'14h3c6cfU92', # valid Base58Check, but wrong payload length
'tb1pqqqq4cagmm', # valid Bech32, but wrong chain
bech32_encode('tb', 1, bytes(range(32))), # valid Bech32m, but not supported here
])
@pytest.mark.parametrize('method', ['qr', 'nfc'])
def test_invalid_address_ownership(addr, method, goto_home, pick_menu_item,
scan_a_qr, cap_story, need_keypress,
nfc_write, load_shared_mod, src_root_dir,
sim_root_dir, skip_if_useless_way, use_testnet):
skip_if_useless_way(method)
use_testnet()
if method == 'qr':
goto_home()
pick_menu_item('Scan Any QR Code')
scan_a_qr(addr)
time.sleep(1)
title, story = cap_story()
assert addr == addr_from_display_format(story.split("\n\n")[0])
assert '(1) to verify ownership' in story
need_keypress('1')
elif method == 'nfc':
cc_ndef = load_shared_mod('cc_ndef', f'{src_root_dir}/shared/ndef.py')
n = cc_ndef.ndefMaker()
n.add_text(addr)
ccfile = n.bytes()
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('NFC Tools')
pick_menu_item('Verify Address')
with open(f'{sim_root_dir}/debug/nfc-addr.ndef', 'wb') as f:
f.write(ccfile)
nfc_write(ccfile)
time.sleep(1)
title, story = cap_story()
assert addr == addr_from_display_format(story.split("\n\n")[0])
assert title == 'Unknown Address'
assert 'That address is not valid on Bitcoin Testnet' in story
assert 'without finding a match' not in story
@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "ms0"])
def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer,
pick_menu_item, need_keypress, sim_exec, clear_ms,
@ -481,8 +531,7 @@ def test_ae_saver(wipe_cache, settings_set, goto_address_explorer, cap_story,
def test_regtest_addr_on_mainnet(goto_home, is_q1, pick_menu_item, scan_a_qr, nfc_write, cap_story,
need_keypress, load_shared_mod, use_mainnet, src_root_dir, sim_root_dir):
# testing bug in chains.possible_address_fmt
# allowed regtest addresses to be allowed on main chain
# Regtest addresses must not be accepted on main chain.
goto_home()
use_mainnet()
addr = "bcrt1qmff7njttlp6tqtj0nq7svcj2p9takyqm3mfl06"

View File

@ -443,6 +443,49 @@ def test_xor_import_empty(parts, expect, pick_menu_item, cap_story, need_keypres
reset_seed_words()
def test_blank_tmp_seed_xor_restore(unit_test, goto_eph_seed_menu, pick_menu_item, cap_story,
choose_by_word_length, word_menu_entry, need_keypress, OK,
confirm_tmp_seed, verify_ephemeral_secret_ui, reset_seed_words):
# From the Temporary Seed menu, Seed XOR restore must not persist into a blank SE.
parts = [zero16, ones16]
expect = ones16
num_words = 12
unit_test('devtest/clear_seed.py')
goto_eph_seed_menu()
pick_menu_item('Restore Seed XOR')
time.sleep(0.1)
title, body = cap_story()
assert 'all the parts' in body
assert f"Press {OK} for 24 words" in body
assert "press (1)" in body
assert "press (2)" in body
choose_by_word_length(num_words)
time.sleep(0.01)
for n, part in enumerate(parts):
word_menu_entry(part.split())
time.sleep(0.01)
title, body = cap_story()
assert f"You've entered {n + 1} parts so far" in body
if n != len(parts) - 1:
assert "Or (2)" not in body
need_keypress('1')
else:
assert "Or (2) if done" in body
assert f"{num_words}: {expect.split()[-1]}" in body
need_keypress('2')
confirm_tmp_seed(seedvault=False)
verify_ephemeral_secret_ui(mnemonic=expect.split(), seed_vault=False)
reset_seed_words()
@pytest.mark.parametrize("num_words", [12, 24])
@pytest.mark.parametrize("num_parts", [2, 4, 20])
@pytest.mark.parametrize("incl_self", [True, False])

View File

@ -559,6 +559,9 @@ def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind,
chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo'
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
psbt = b4.as_bytes()
start_sign(psbt)
@ -568,7 +571,6 @@ def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind,
assert split_sory[0] == "Change back:"
assert chg_addr == addr_from_display_format(split_sory[-1])
b4 = BasicPSBT().parse(psbt)
check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[1,])
signed = end_sign(True)
@ -610,6 +612,7 @@ def test_change_fraud_path(start_sign, use_regtest, end_sign, case, check_agains
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
(pubkey, path), = b4.outputs[1].bip32_paths.items()
skp = bytearray(b4.outputs[1].bip32_paths[pubkey])
@ -654,6 +657,7 @@ def test_change_fraud_addr(start_sign, end_sign, use_regtest, check_against_bitc
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
# tweak output addr to garbage
t = CTransaction()
@ -691,6 +695,7 @@ def test_change_p2sh_p2wpkh(start_sign, end_sign, check_against_bitcoind, use_re
psbt = f.read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
t = CTransaction()
t.deserialize(BytesIO(b4.txn))
@ -840,15 +845,16 @@ def test_sign_multisig_partial_fail(start_sign, end_sign):
def test_sign_wutxo(start_sign, set_seed_words, end_sign, cap_story, sim_exec, sim_execfile,
sim_root_dir):
# Example from SomberNight: we can sign it, but signature won't be accepted by
# network because the PSBT lies about the UTXO amount and tries to give away to miners,
# as overly-large fee.
# Example from SomberNight, normalized to use a full UTXO so this test still
# reaches the fee display path after legacy witness-only UTXOs are rejected.
set_seed_words('fault lava rice chest uncle exclude power tornado catalog stool'
' swear rival sun aspect oyster deer pepper exchange scrap toward'
' mix second world shaft')
in_psbt = a2b_hex(open('data/snight-example.psbt', 'rb').read()[:-1])
snight = BasicPSBT().parse(a2b_hex(open('data/snight-example.psbt', 'rb').read()[:-1]))
snight.convert_witness_utxo_to_utxo(0)
in_psbt = snight.as_bytes()
for fin in (False, True):
start_sign(in_psbt, finalize=fin)
@ -1136,6 +1142,7 @@ def test_change_troublesome(dev, start_sign, cap_story, try_path, expect, sim_ro
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
pubkey = a2b_hex('03c80814536f8e801859fc7c2e5129895b261153f519d4f3418ffb322884a7d7e1')
path = [int(p) if ("'" not in p) else 0x80000000+int(p[:-1])
@ -1664,6 +1671,21 @@ def test_wrong_pubkey(dev, try_sign, fake_txn):
msg = ee.value.args[0]
assert ('pubkey vs. address wrong' in msg)
def test_p2sh_p2wpkh_multiple_bip32_paths_rejected(fake_txn, start_sign, cap_story):
psbt = fake_txn(1, 1, segwit_in=True, wrapped=True)
po = BasicPSBT().parse(psbt)
other = BIP32Node.from_master_secret(os.urandom(32))
other_key = other.subkey_for_path("0/0")
po.inputs[0].bip32_paths[other_key.sec()] = other.fingerprint() + struct.pack('<II', 0, 0)
start_sign(po.as_bytes(), finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "p2sh-p2wpkh needs one key" in story
def test_incomplete_signing(dev, try_sign, fake_txn, cap_story):
# psbt where we only sign one input
# - must not allow finalization
@ -1754,6 +1776,104 @@ def test_own_utxo_missing(segwit_in, num_missing, dev, fake_txn, start_sign, cap
assert "Missing own UTXO(s)" in story
press_cancel()
def _replace_input_utxo_with_witness_utxo(psbt, idx):
inp = psbt.inputs[idx]
txn = CTransaction()
txn.deserialize(BytesIO(inp.utxo))
assert len(txn.vout) == 1
inp.witness_utxo = txn.vout[0].serialize()
inp.utxo = None
def test_nested_segwit_witness_utxo_only_fee_shown(dev, fake_txn, start_sign,
cap_story, end_sign):
psbt = fake_txn(1, 2, dev.master_xpub, segwit_in=True, wrapped=True)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Network fee" in story
assert "unverified witness UTXO" not in story
end_sign(accept=True)
def test_own_legacy_witness_utxo_only_fails(dev, fake_txn, start_sign, cap_story, press_cancel):
def hack(psbt):
_replace_input_utxo_with_witness_utxo(psbt, 0)
psbt = fake_txn(1, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Legacy input #0 requires non-witness UTXO" in story
press_cancel()
def test_foreign_legacy_witness_utxo_only_ok(dev, fake_txn, start_sign, cap_story, end_sign):
def hack(psbt):
pk = list(psbt.inputs[1].bip32_paths.keys())[0]
pp = psbt.inputs[1].bip32_paths[pk]
psbt.inputs[1].bip32_paths[pk] = b'what' + pp[4:]
_replace_input_utxo_with_witness_utxo(psbt, 1)
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Limited Signing" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Legacy input #1 requires non-witness UTXO" not in story
signed = end_sign(accept=True)
assert signed != psbt
def test_mismatched_p2sh_witness_program_unverified(dev, fake_txn, start_sign,
cap_story, end_sign):
def hack(psbt):
pk = list(psbt.inputs[1].bip32_paths.keys())[0]
pp = psbt.inputs[1].bip32_paths[pk]
psbt.inputs[1].bip32_paths[pk] = b'what' + pp[4:]
inp = psbt.inputs[1]
txn = CTransaction()
txn.deserialize(BytesIO(inp.utxo))
assert len(txn.vout) == 1
redeem_script = bytes([0, 20]) + os.urandom(32)
inp.redeem_script = redeem_script
inp.witness_utxo = CTxOut(txn.vout[0].nValue,
bytes([0xa9, 0x14]) + hash160(redeem_script) + bytes([0x87])).serialize()
inp.utxo = None
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Limited Signing" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Network fee" not in story
signed = end_sign(accept=True)
assert signed != psbt
def test_presigned_own_legacy_witness_utxo_only_ok(dev, fake_txn, start_sign, cap_story, end_sign):
def hack(psbt):
pubkey = list(psbt.inputs[1].bip32_paths.keys())[0]
psbt.inputs[1].part_sigs[pubkey] = os.urandom(71)
_replace_input_utxo_with_witness_utxo(psbt, 1)
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Partly Signed Already" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Legacy input #1 requires non-witness UTXO" not in story
signed = end_sign(accept=True)
assert signed != psbt
@pytest.mark.bitcoind
def test_bitcoind_missing_foreign_utxo(bitcoind, bitcoind_d_sim_watch, microsd_path, try_sign):
# batch tx created from three different psbts (using joinpsbts)
@ -1863,7 +1983,7 @@ def test_op_return_signing(op_return_data, dev, fake_txn, bitcoind_d_sim_watch,
# tx = cc.finalizepsbt(base64.b64encode(signed).decode())["hex"]
res = cc.testmempoolaccept([tx])[0]
if len(op_return_data) > 80:
if (bitcoind.version < 300000) and (len(op_return_data) > 80):
# policy
assert res["allowed"] is False
assert res["reject-reason"] == "scriptpubkey"
@ -1873,6 +1993,25 @@ def test_op_return_signing(op_return_data, dev, fake_txn, bitcoind_d_sim_watch,
assert isinstance(tx_id, str) and len(tx_id) == 64
def test_op_return_trailing_data_not_hidden(fake_txn, start_sign, cap_story):
weird = b'\x6a\x00\x04hide' # OP_RETURN OP_0 <push b"hide">
def hack(psbt):
t = CTransaction()
t.deserialize(BytesIO(psbt.txn))
t.vout[0].scriptPubKey = weird
psbt.txn = t.serialize_with_witness()
psbt = fake_txn(1, 2, segwit_in=True, psbt_v2=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == 'OK TO SEND?'
flat = story.lower().replace(' ', '')
assert 'null-data' not in flat
assert weird.hex() in flat
@pytest.mark.parametrize("unknowns", [
# tuples (unknown_global, unknown_ins, unknown_outs)
({b"x" * 16: b"y" * 16}, {b"q": b"p"}, {b"w" * 5: b"z" * 22}),
@ -3225,6 +3364,32 @@ def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_stor
end_sign(finalize=finalize)
def test_txout_explorer_qr_too_big_single_item(fake_txn, start_sign, cap_story, cap_screen,
need_keypress, pick_menu_item, press_cancel,
is_q1):
if not is_q1:
raise pytest.skip("Q1 QR fallback")
psbt = fake_txn(1, 10, segwit_in=True, psbt_v2=False, op_return=[(0, b'a' * 1000)])
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
need_keypress("2")
pick_menu_item("Outputs")
time.sleep(.1)
need_keypress(KEY_RIGHT)
time.sleep(.1)
need_keypress(KEY_QR)
time.sleep(.5)
scr = cap_screen()
assert "QR too big" in scr
press_cancel()
press_cancel()
def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_import,
cap_story, try_sign, reset_seed_words, clear_ms):
reset_seed_words()
@ -3552,13 +3717,38 @@ def test_unknown_input_script(stype, fake_txn , start_sign, cap_story, use_testn
txin_explorer(len(ins), ins)
@pytest.mark.parametrize("mi", ["Inputs", "Outputs"])
def test_tx_explorer_goto_idx_single_item_yikes(mi, fake_txn, start_sign, cap_story, use_testnet,
need_keypress, pick_menu_item, press_cancel, cap_menu):
use_testnet()
psbt = fake_txn(1, 1, segwit_in=True)
start_sign(psbt)
title, story = cap_story()
assert title == "OK TO SEND?"
need_keypress("2")
pick_menu_item(mi)
time.sleep(.1)
title, story = cap_story()
assert "(2)" not in story
need_keypress("2") # must not yikes
press_cancel()
menu = cap_menu()
assert "Inputs" in menu
assert "Outputs" in menu
press_cancel()
press_cancel()
def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need_keypress,
pick_menu_item, cap_screen, enter_number, press_cancel, is_q1):
pick_menu_item, cap_screen, enter_number, press_cancel, is_q1,
goto_home):
use_testnet()
num_ins = 27
num_outs = 32
psbt = fake_txn(num_ins, num_outs, segwit_in=True, change_outputs=[0])
goto_home()
start_sign(psbt)
title, story = cap_story()
assert title == "OK TO SEND?"
@ -3618,6 +3808,46 @@ def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need
press_cancel()
def test_input_explorer_foreign_bad_sighash(fake_txn, start_sign, cap_story,
need_keypress, pick_menu_item, press_cancel,
use_testnet, goto_home):
# PSBT has a foreign input (not ours) carrying a PSBT_IN_SIGHASH_TYPE value
# outside ALL_SIGHASH_FLAGS. consider_dangerous_sighash() only validates
# our-key inputs, so the PSBT passes validation and reaches the approval UX.
# Browsing the TX Explorer -> Inputs must not crash on the foreign input.
use_testnet()
def hack(psbt):
# Make input 0 foreign: replace its xfp prefix with a non-matching one.
foreign_xfp = b"\xab\xcd\xef\x01"
new_paths = {}
for pk, path_bytes in psbt.inputs[0].bip32_paths.items():
new_paths[pk] = foreign_xfp + path_bytes[4:]
psbt.inputs[0].bip32_paths = new_paths
psbt.inputs[0].sighash = 0x05
psbt = fake_txn(2, 2, segwit_in=True, psbt_hacker=hack)
goto_home()
start_sign(psbt)
time.sleep(.1)
title, _ = cap_story()
assert title == "OK TO SEND?"
need_keypress("2")
time.sleep(.1)
pick_menu_item("Inputs")
time.sleep(.2)
title, story = cap_story()
# foreign input shown first; must render the raw value, not crash
assert title == "Input 0"
assert "sighash: 0x05 (non-standard)" in story
press_cancel()
press_cancel()
press_cancel()
@pytest.mark.parametrize("segwit", [True, False])
def test_txn_nVersion_zero(segwit, fake_txn, start_sign, cap_story, goto_home):
goto_home()
@ -3640,7 +3870,9 @@ def test_txn_nVersion_zero(segwit, fake_txn, start_sign, cap_story, goto_home):
@pytest.mark.parametrize("segwit_in", [True, False])
@pytest.mark.parametrize("num_ins", [2, 110])
def test_duplicate_inputs(segwit_in, num_ins, fake_txn, start_sign, end_sign, cap_story):
def test_duplicate_inputs(segwit_in, num_ins, fake_txn, start_sign, end_sign, cap_story,
goto_home):
goto_home()
psbt = fake_txn(num_ins, 2, segwit_in=segwit_in, dupe_ins=[num_ins-1])
start_sign(psbt)
title, story = cap_story()
@ -3650,7 +3882,8 @@ def test_duplicate_inputs(segwit_in, num_ins, fake_txn, start_sign, end_sign, ca
assert title == "OK TO SEND?"
def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select):
def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select, goto_home):
goto_home()
psbt = fake_txn(1, 2, change_outputs=[0])
start_sign(psbt, finalize=False)
press_select() # confirm signing
@ -3666,4 +3899,240 @@ def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select):
assert "(6) for QR Code of TXID" in story
press_cancel()
@pytest.mark.parametrize("segwit_in", [True, False])
def test_empty_input_scriptPubKey(segwit_in, dev, fake_txn, start_sign, cap_story):
def hack(psbt):
target_idx = 0
if segwit_in:
txo = CTxOut()
txo.deserialize(BytesIO(psbt.inputs[target_idx].witness_utxo))
txo.scriptPubKey = b""
psbt.inputs[target_idx].witness_utxo = txo.serialize()
else:
supply_tx = CTransaction()
supply_tx.deserialize(BytesIO(psbt.inputs[target_idx].utxo))
supply_tx.vout[0].scriptPubKey = b""
psbt.inputs[target_idx].utxo = supply_tx.serialize_with_witness()
supply_tx.calc_sha256()
spend_tx = CTransaction()
spend_tx.deserialize(BytesIO(psbt.txn))
spend_tx.vin[target_idx] = CTxIn(
COutPoint(supply_tx.sha256, 0),
nSequence=spend_tx.vin[target_idx].nSequence,
)
psbt.txn = spend_tx.serialize_with_witness()
psbt = fake_txn(2, 1, dev.master_xpub, psbt_hacker=hack, segwit_in=segwit_in)
start_sign(psbt)
title, _ = cap_story()
assert title == "Failure"
@pytest.mark.bitcoind
@pytest.mark.parametrize('finalize', [True, False])
@pytest.mark.parametrize('mode', ['compressed', 'uncompressed'])
def test_spend_p2pk(mode, finalize, bitcoind, bitcoind_d_wallet, dev,
start_sign, end_sign, cap_story, use_regtest, goto_home):
use_regtest()
goto_home()
xfp_str = xfp2str(dev.master_fingerprint).lower()
path = "44h/1h/0h/0/0"
xpub = dev.send_recv(CCProtocolPacker.get_xpub("m/" + path), timeout=5000)
node = BIP32Node.from_wallet_key(xpub)
if mode == 'compressed':
pubkey_hex = node.node.public_key.sec(compressed=True).hex()
else:
pubkey_hex = node.node.public_key.sec(compressed=False).hex()
# pk() descriptor with origin info — Core writes the (xfp, path) into the
# PSBT's BIP32_DERIVATION so Coldcard recognizes it as its own key.
desc = "pk([%s/%s]%s)" % (xfp_str, path, pubkey_hex)
desc = bitcoind.rpc.getdescriptorinfo(desc)["descriptor"]
res = bitcoind_d_wallet.importdescriptors([{
"desc": desc, "timestamp": "now", "watchonly": True,
}])
assert res[0]["success"], res
# Fund the P2PK output. No Core RPC accepts a raw scriptPubKey as an
# output, so we hand-build a skeleton tx with the P2PK output and let
# fundrawtransaction fill in inputs, change, and fee from supply_wallet.
push_op = b'\x21' if mode == 'compressed' else b'\x41'
spk = push_op + bytes.fromhex(pubkey_hex) + b'\xac'
txn = CTransaction()
txn.vout = [CTxOut(5_00_000_000, spk)] # 5 BTC
funded = bitcoind.supply_wallet.fundrawtransaction(txn.serialize().hex())
signed = bitcoind.supply_wallet.signrawtransactionwithwallet(funded["hex"])
bitcoind.rpc.sendrawtransaction(signed["hex"])
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
assert bitcoind_d_wallet.listunspent()
dest = bitcoind.supply_wallet.getnewaddress()
resp = bitcoind_d_wallet.walletcreatefundedpsbt(
[], [{dest: bitcoind_d_wallet.getbalance()}], 0,
{"fee_rate": 3, "subtractFeeFromOutputs": [0]})
psbt_bytes = base64.b64decode(resp["psbt"])
# Sanity: confirm Core encoded the pubkey form we asked for in the PSBT's
# BIP32_DERIVATION (key field). 33 bytes for compressed, 65 for uncompressed.
# Single-sig P2PK has exactly one entry; `all` rejects any extra entries
# in unexpected form.
expect_pk_len = 33 if mode == 'compressed' else 65
pre_po = BasicPSBT().parse(psbt_bytes)
pre_keys = list(pre_po.inputs[0].bip32_paths.keys())
assert pre_keys
assert all(len(k) == expect_pk_len for k in pre_keys)
start_sign(psbt_bytes, finalize=finalize)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
out = end_sign(accept=True, finalize=finalize)
if finalize:
# Coldcard returned the network tx; broadcast directly.
tx_hex = out.hex()
else:
# Coldcard returned a partially-signed PSBT. Verify the partial sig
# carries the same-form pubkey as keydata, then have bitcoind finalize.
signed_po = BasicPSBT().parse(out)
sig_keys = list(signed_po.inputs[0].part_sigs.keys())
assert sig_keys
assert all(len(k) == expect_pk_len for k in sig_keys)
finalized = bitcoind.rpc.finalizepsbt(base64.b64encode(out).decode())
assert finalized["complete"], finalized
tx_hex = finalized["hex"]
accept = bitcoind.rpc.testmempoolaccept([tx_hex])
assert accept[0]["allowed"], accept
txid = bitcoind.rpc.sendrawtransaction(tx_hex)
assert len(txid) == 64
goto_home()
@pytest.mark.parametrize('finalize', [True, False])
@pytest.mark.parametrize('mode', ['compressed', 'uncompressed'])
def test_fake_txn_spend_p2pk(mode, finalize, fake_txn, start_sign, end_sign, cap_story):
expect_pk_len = 33 if mode == 'compressed' else 65
style = 'p2pk' if mode == 'compressed' else 'p2pk-uncompressed'
psbt = fake_txn(1, 2, p2pk_in=mode, outstyles=[style, 'p2pkh'], change_outputs=[0])
pre = BasicPSBT().parse(psbt)
change_keys = list(pre.outputs[0].bip32_paths.keys())
assert change_keys and all(len(k) == expect_pk_len for k in change_keys)
start_sign(psbt, finalize=finalize)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Change back:" in story
out = end_sign(accept=True, finalize=finalize)
if not finalize:
signed = BasicPSBT().parse(out)
sig_keys = list(signed.inputs[0].part_sigs.keys())
assert sig_keys and all(len(k) == expect_pk_len for k in sig_keys)
@pytest.mark.parametrize('mode', ['compressed', 'uncompressed'])
@pytest.mark.parametrize('change', [True, False])
def test_p2pk_change_output_renders(mode, change, fake_txn, start_sign, cap_story, dev,
goto_home, need_keypress, pick_menu_item, press_cancel):
master_xpub = dev.master_xpub or simulator_fixed_tprv
mk = BIP32Node.from_wallet_key(master_xpub)
xfp = mk.fingerprint()
input_leaf = mk.subkey_for_path("0/0")
input_pk = input_leaf.node.public_key.sec(compressed=(mode == 'compressed'))
input_spk = bytes([len(input_pk)]) + input_pk + b'\xac'
leaf = mk.subkey_for_path("0/77")
if mode == 'compressed':
pk = leaf.node.public_key.sec(compressed=True)
push_op = b'\x21'
else:
pk = leaf.node.public_key.sec(compressed=False)
push_op = b'\x41'
assert len(pk) == (33 if mode == 'compressed' else 65)
p2pk_spk = push_op + pk + b'\xac' # <push> <pubkey> OP_CHECKSIG
def hack(psbt):
t = CTransaction()
t.deserialize(BytesIO(psbt.txn))
t.vout[0].scriptPubKey = p2pk_spk
psbt.txn = t.serialize_with_witness()
if change:
psbt.outputs[0].bip32_paths = {pk: xfp + struct.pack('<II', 0, 77)}
outvals = [1_000_000, 98_990_000]
psbt = fake_txn(1, 2, p2pk_in=mode, psbt_v2=False, psbt_hacker=hack,
outvals=outvals)
goto_home()
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?" # approval screen built, no crash
assert ("Change back:" in story) == change
if change:
assert p2pk_spk.hex() in story.replace(" ", "").replace("\n", "")
need_keypress("2")
pick_menu_item("Outputs")
time.sleep(.1)
_, story = cap_story()
assert (f"Output 0 (change):" if change else "Output 0:") in story
assert p2pk_spk.hex() in story.replace(" ", "").replace("\n", "")
press_cancel()
time.sleep(.1)
pick_menu_item("Inputs")
time.sleep(.1)
title, story = cap_story()
assert title == "Input 0"
stripped = story.replace(" ", "").replace("\n", "")
assert input_spk.hex() in stripped
assert input_pk.hex() in stripped
for _ in range(3):
press_cancel()
def test_malformed_p2pk_change_output(fake_txn, start_sign, cap_story, end_sign, dev):
master_xpub = dev.master_xpub or simulator_fixed_tprv
mk = BIP32Node.from_wallet_key(master_xpub)
xfp = mk.fingerprint()
leaf = mk.subkey_for_path("0/77")
pk = leaf.node.public_key.sec(compressed=True)
malformed_p2pk = b'\x21' + pk + os.urandom(32) + b'\xac'
assert len(malformed_p2pk) == 67
def hack(psbt):
t = CTransaction()
t.deserialize(BytesIO(psbt.txn))
t.vout[0].scriptPubKey = malformed_p2pk
psbt.txn = t.serialize_with_witness()
psbt.outputs[0].bip32_paths = {pk: xfp + struct.pack('<II', 0, 77)}
psbt = fake_txn(1, 2, segwit_in=True, psbt_v2=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "to script" in story
assert "Sending to 1 not well understood script(s)" in story
assert "Change back:" not in story
end_sign(accept=True)
# EOF

View File

@ -35,7 +35,7 @@ def setup_sssp(goto_sssp_menu, pick_menu_item, cap_story, press_select, pass_wor
title, story = cap_story()
# it is possible that PIN was set beforehand
if title == "Spending Policy":
if title == ("Spending Policy" if is_q1 else "Spend Policy"):
assert "stops you from signing transactions unless conditions are met" in story
assert "locked into a special mode" in story
assert "First step is to define a new PIN" in story
@ -767,4 +767,60 @@ def test_sssp_notes_enable(only_q1, setup_sssp):
def test_sssp_word_check(setup_sssp):
# just test menu item works
setup_sssp("11-11", mag=2, vel='6 blocks (hour)', word_check=True)
@pytest.mark.bitcoind
def test_ccc_with_sssp_block_h(setup_ccc, ccc_ms_setup, setup_sssp, bitcoind, policy_sign,
settings_get, settings_set, bitcoind_create_watch_only_wallet,
pick_menu_item, press_select, cap_story):
settings_set("ccc", None)
settings_set("sssp", None)
settings_set("multisig", [])
settings_set("chain", "XRT")
setup_ccc(mag=10, vel='Unlimited')
_, target_mi = ccc_ms_setup()
bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi)
setup_sssp(pin="11-11", mag=10, vel='48 blocks (8h)')
pick_menu_item("Test Drive")
time.sleep(.1)
_, story = cap_story()
assert "COLDCARD operation will look like with Spending Policy" in story
press_select()
multi_addr = bitcoind_wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
cur_h = bitcoind.supply_wallet.getblockchaininfo()["blocks"]
psbt1 = bitcoind_wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], cur_h
)["psbt"]
policy_sign(bitcoind_wo, psbt1)
assert cur_h == settings_get("sssp")["pol"]["block_h"]
assert cur_h == settings_get("ccc")["pol"]["block_h"]
baseline_block_h = cur_h
bitcoind.supply_wallet.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress())
# second signing -> SSSP velocity BLOCKS but CCC overrides and signing allowed
chosen_lock_time = baseline_block_h + 1
psbt2 = bitcoind_wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], chosen_lock_time
)["psbt"]
policy_sign(bitcoind_wo, psbt2)
assert chosen_lock_time == settings_get("ccc")["pol"]["block_h"]
# SSSP block_h is updated too
assert chosen_lock_time == settings_get("sssp")["pol"]["block_h"]
pick_menu_item("EXIT TEST DRIVE")
settings_set("ccc", None)
settings_set("sssp", None)
# EOF

View File

@ -542,6 +542,8 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, num_ins, dev, clea
if 'Finalized TX' in body:
break
assert "shared via USB" not in body
assert "Updated PSBT is" not in story # assert not written to SD/Vdisk
assert '(T) to use Key Teleport to send PSBT to other co-signers' in body
num_sigs_needed -= 1
@ -651,6 +653,52 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, ca
press_cancel()
def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_ms_txn, cap_story,
need_keypress, cap_menu, pick_menu_item, grab_payload,
rx_complete, set_master_key, goto_home, settings_get,
settings_set, open_microsd, import_ms_wallet, press_cancel):
clear_ms()
M, N = 2, 4
keys = import_ms_wallet(M, N, name='ms-tp', unique=11, accept=True,
descriptor=False, bip67=True)
psbt = fake_ms_txn(1, 1, M, keys)
fname = 'ms-tp.psbt'
open_microsd(fname, 'wb').write(psbt)
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('File Management')
pick_menu_item('Teleport Multisig PSBT')
need_keypress('1')
try:
pick_menu_item(fname)
except KeyError:
pass
m = cap_menu()
assert len(m) == N
target = next(i for i in m if 'YOU' not in i)
target_xfp = str2xfp(target[1:9])
# forward the (unsigned) file to this co-signer, instead of signing first and forwarding after
pick_menu_item(target)
pw, data, qr_raw = grab_payload('E')
tmp_ms = settings_get('multisig')
# become that co-signer; give it the one wallet it shares with us
node, = [n for x, n, _ in keys if x == target_xfp]
set_master_key(node.hwif(as_private=True))
settings_set('multisig', [tmp_ms[-1]])
# with the bug the payload is stale/zero bytes or whatever was in OUT_OFFSET -> PSBT load fails;
rx_complete(('E', qr_raw), pw, expect_xfp=simulator_fixed_xfp)
title, body = cap_story()
assert title == 'OK TO SEND?'
press_cancel()
@pytest.mark.manual
def test_teleport_real_ms(dev, fake_ms_txn):
#
@ -686,7 +734,7 @@ def test_teleport_real_ms(dev, fake_ms_txn):
# match the default paths created by CC in airgapped MS wallet creation.
return str_to_path(deriv)
psbt = fake_ms_txn(3, 2, M, keys, fee=10000, outvals=None, segwit_in=False,
psbt = fake_ms_txn(3, 2, M, keys, fee=10000, outvals=None, inp_af=AF_P2WSH,
outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False,
hack_change_out=False, input_amount=1E8, path_mapper=p2wsh_mapper)
@ -757,6 +805,29 @@ def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick
settings_set('notes', [])
def test_teleport_backup_invalid_raw_secret(grab_payload, rx_complete, goto_home,
pick_menu_item, cap_story, is_q1):
# yikes. Must instead show a clean FAILED story.
if not is_q1:
raise pytest.skip("Q1 Key Teleport")
from teleport_protocol import sender_step1
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('Key Teleport (start)')
code, _qr_data, qr_raw = grab_payload('R')
bad_backup = b'chain = "XTN"\n'
cleartext = b'b' + bad_backup
noid_txt, encrypted_payload, _, _ = sender_step1(code, qr_raw, cleartext)
rx_complete(('S', encrypted_payload), noid_txt)
time.sleep(.5)
title, body = cap_story()
assert title == 'FAILED'
assert "Invalid backup" in body
def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload,
rx_complete, cap_story, press_cancel, press_select, settings_get,
settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret,

View File

@ -238,7 +238,39 @@ def test_cleanup_deriv_path_fails(path, ans, sim_exec, star=True):
assert 'Traceback' in rv
assert ans in rv
@pytest.mark.parametrize('script_hex, expect', [
# not OP_RETURN -> None
('51', None), # OP_1
('0014' + '00'*20, None), # p2wpkh
# real null-data -> b""
('6a', b''), # bare OP_RETURN
('6a00', b''), # OP_RETURN OP_0
('6a4c00', b''), # OP_RETURN PUSHDATA1 len 0 (empty push)
# single push -> the data
('6a0468696465', b'hide'), # OP_RETURN <push "hide">
('6a01ff', b'\xff'), # OP_RETURN <push 0xff>
# data behind OP_RETURN -> None (caller shows raw script)
('6a000468696465', None), # OP_RETURN OP_0 <push "hide">
('6a04414141410442424242', None), # OP_RETURN <push><push>
('6a55', None), # OP_RETURN OP_5
# non-push opcode after OP_RETURN (not OP_0) -> None, not null-data
('6a76', None), # OP_RETURN OP_DUP
('6a6a', None), # OP_RETURN OP_RETURN
# truncated / malformed pushes after OP_RETURN -> None (show raw script)
('6a04ff', None), # OP_RETURN <direct push len 4, only 1 byte>
('6a4c04ff', None), # OP_RETURN PUSHDATA1 len 4, only 1 byte
('6a4d', None), # OP_RETURN PUSHDATA2 truncated length
('', None), # empty script
])
def test_op_return_decode(script_hex, expect, sim_exec):
cmd = ('from chains import BitcoinMain; from ubinascii import unhexlify; '
'RV.write(repr(BitcoinMain.op_return(unhexlify(%r))))' % script_hex)
rv = sim_exec(cmd)
assert 'Traceback' not in rv, rv
assert rv == repr(expect)
@pytest.mark.parametrize('patterns, paths, answers', [
(["m"], ("m", "m/2", "*", "any"), [True, False, False, False]),
@ -326,16 +358,30 @@ def test_word_wrap(txt, target, width, sim_exec):
assert lines == target
def check_own_address_detect(addr, sim_exec):
cmd = f"""
from glob import settings
from utils import validate_own_address
rv = []
for ctype in ('BTC', 'XTN', 'XRT'):
settings.set('chain', ctype)
try:
rv.append((ctype, validate_own_address({addr!r})[1]))
except:
rv.append((ctype, 0))
settings.set('chain', 'XTN')
RV.write(repr(rv))
"""
lst = sim_exec(cmd)
assert 'Error' not in lst
return eval(lst)
@pytest.mark.parametrize('addr,net,fmt', [
( 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 'BTC', AF_P2WPKH ),
])
def test_addr_detect(addr, net, fmt, sim_exec):
cmd = f'from chains import AllChains; RV.write(repr([(ch.ctype, ch.possible_address_fmt({addr!r})) for ch in AllChains]))'
print(cmd)
lst = sim_exec(cmd)
assert 'Error' not in lst
for got_net, match in eval(lst):
for got_net, match in check_own_address_detect(addr, sim_exec):
if match:
assert net == got_net
assert match == fmt
@ -356,16 +402,11 @@ def test_addr_fake_detect(addr_fmt, testnet, sim_exec):
addr = fake_address(addr_fmt, testnet)
cmd = f'from chains import AllChains; RV.write(repr([(ch.ctype, ch.possible_address_fmt({addr!r})) for ch in AllChains]))'
lst = sim_exec(cmd)
assert 'Error' not in lst
#print(lst)
expect_net = ('BTC' if not testnet else 'XTN')
expect_addr_fmt = addr_fmt if addr_fmt not in { AF_P2WSH_P2SH, AF_P2WPKH_P2SH } else AF_P2SH
for got_net, match in eval(lst):
for got_net, match in check_own_address_detect(addr, sim_exec):
if match:
if got_net == 'XRT':
assert expect_net == 'XTN'

View File

@ -4,11 +4,12 @@
#
# - not working well on simulator right now, but that's not key
#
import pytest, struct
import pytest, struct, hashlib, os
from bip32 import BIP32Node
from binascii import b2a_hex
from constants import simulator_fixed_tprv
from ckcc_protocol.protocol import MAX_MSG_LEN, CCProtocolPacker, CCProtoError
from ckcc_protocol.constants import MSG_SIGNING_MAX_LENGTH
@pytest.mark.skip
def test_usb_fuzz(dev):
@ -98,7 +99,7 @@ def test_xpub_invalid(dev, path):
# some bad paths
with pytest.raises(CCProtoError):
xpub = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None)
dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None)
def test_version(dev, is_q1):
@ -120,8 +121,6 @@ def test_version(dev, is_q1):
@pytest.mark.parametrize('data_len', [1, 24, 60, 61, 62, 63, 64, 1000])
def test_upload_short(dev, data_len):
# upload a few really short files
from hashlib import sha256
data = b'a'*data_len
@ -129,7 +128,7 @@ def test_upload_short(dev, data_len):
assert v == 0
chk = dev.send_recv(CCProtocolPacker.sha256())
assert chk == sha256(data).digest(), 'bad hash'
assert chk == hashlib.sha256(data).digest(), 'bad hash'
# clear screen / test a degerate case
dev.send_recv(CCProtocolPacker.upload(256, 256, b''))
@ -137,9 +136,6 @@ def test_upload_short(dev, data_len):
@pytest.mark.parametrize('pkt_len', [256, 1024, 2048])
def test_upload_long(dev, pkt_len, count=5, data=None):
# upload a larger "file"
from hashlib import sha256
import os
data = data or os.urandom(pkt_len * count)
@ -147,7 +143,7 @@ def test_upload_long(dev, pkt_len, count=5, data=None):
v = dev.send_recv(CCProtocolPacker.upload(pos, len(data), data[pos:pos+pkt_len]))
assert v == pos
chk = dev.send_recv(CCProtocolPacker.sha256())
assert chk == sha256(data[0:pos+pkt_len]).digest(), 'bad hash'
assert chk == hashlib.sha256(data[0:pos+pkt_len]).digest(), 'bad hash'
# clear screen / test a degerate case
dev.send_recv(CCProtocolPacker.upload(256, 256, b''))
@ -206,7 +202,6 @@ def test_mitm(dev):
assert dev.mitm_verify(sig2, dev.master_xpub) == False
def test_remote_upload(dev):
import os
dev.upload_file(b'testing')
dev.upload_file(os.urandom(3000))
@ -216,7 +211,6 @@ def test_remote_up_download(f_len, dev, mk_num):
if f_len > (384*1024) and mk_num <= 3:
raise pytest.skip('mk4+ only case')
import os
data = os.urandom(f_len)
ll, sha = dev.upload_file(data, verify=True)
assert ll == len(data) == f_len
@ -224,4 +218,188 @@ def test_remote_up_download(f_len, dev, mk_num):
rb = dev.download_file(ll, sha, file_number=0)
assert rb == data
def test_dwld_offset_at_max(dev, mk_num):
max_txn = 2*1024*1024
msg = struct.pack('<4sIII', b'dwld', max_txn, 1, 1)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'bad offset' in str(e.value)
def test_dwld_offset_one_past_max(dev, mk_num):
max_txn = 2*1024*1024
msg = struct.pack('<4sIII', b'dwld', max_txn + 1, 1, 1)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'bad offset' in str(e.value)
def test_smsg_zero_length_message(dev):
subpath = b'm'
msg = struct.pack('<4sIII', b'smsg', 0x01, len(subpath), 0) + subpath
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'msg too short (min. 2)' in str(e.value)
def test_smsg_oversized_message(dev):
subpath = b'm'
raw_msg = b'a' * (MSG_SIGNING_MAX_LENGTH + 1)
msg = struct.pack('<4sIII', b'smsg', 0x01, len(subpath), len(raw_msg)) + subpath + raw_msg
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'msg too long (max. 240)' in str(e.value)
def test_ncry_invalid_pubkey(dev):
msg = struct.pack('<4sI64s', b'ncry', 0x01, bytes(64))
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'secp256k1_ec_pubkey_parse' in str(e.value)
@pytest.mark.parametrize("file_no", [0, 1])
def test_dwld_oob_psram_read(file_no, dev, mk_num):
max_txn = 2*1024*1024
msg = struct.pack('<4sIII', b'dwld', max_txn - 1, 2, file_no)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'bad offset' in str(e.value)
def test_p2sh_truncated_xfp_paths(dev):
AF_P2SH = 0x08
header = struct.pack('<IBBH', AF_P2SH, 1, 2, 30)
script = bytes(30)
xfp0 = struct.pack('<BI', 1, 0xDEADBEEF) # one uint32
msg = b'p2sh' + header + script + xfp0
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_p2sh_xfp_path_data_too_short(dev):
AF_P2SH = 0x08
header = struct.pack('<IBBH', AF_P2SH, 1, 2, 30)
script = bytes(30)
xfp0 = struct.pack('<BI', 1, 0xDEADBEEF)
xfp1_ln = struct.pack('<B', 2)
msg = b'p2sh' + header + script + xfp0 + xfp1_ln
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_rest_zero_file_len(dev):
empty_sha = hashlib.sha256(b'').digest()
msg = b'rest' + struct.pack('<I32sB', 0, empty_sha, 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_rest_oversized_file_len(dev):
empty_sha = hashlib.sha256(b'').digest()
max_txn_len = 2 * 1024 * 1024
msg = b'rest' + struct.pack('<I32sB', max_txn_len + 1, empty_sha, 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_upld_zero_total_size(dev):
msg = struct.pack('<4sII', b'upld', 0, 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'long' in str(e.value)
def test_upld_short_args(dev):
msg = b'upld' + struct.pack('<I', 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_ncry_short_args(dev):
msg = b'ncry' + struct.pack('<I', 1)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_stxn_short_args(dev):
msg = b'stxn' + struct.pack('<II', 100, 0)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_smsg_short_args(dev):
msg = b'smsg' + struct.pack('<II', 0, 5)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_enrl_short_args(dev):
msg = b'enrl' + struct.pack('<I', 200)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_rest_short_args(dev):
msg = b'rest' + struct.pack('<I', 100)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_show_short_args(dev):
msg = b'show'
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_p2sh_short_args(dev):
msg = b'p2sh' + struct.pack('<I', 0x08)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_dwld_short_args(dev):
msg = b'dwld' + struct.pack('<II', 0, 256)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_msck_short_args(dev):
msg = b'msck' + struct.pack('<II', 1, 2)
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'buffer too small' in str(e.value)
def test_dwld_trailing_garbage(dev):
msg = b'dwld' + struct.pack('<III', 0, 256, 0) + b'\xff' # 13 bytes, need exactly 12
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_ncry_trailing_garbage(dev):
msg = b'ncry' + struct.pack('<I', 1) + bytes(64) + b'\xff' # 69 bytes, need exactly 68
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_enrl_trailing_garbage(dev):
msg = b'enrl' + struct.pack('<I', 200) + bytes(32) + b'\xff' # 37 bytes, need exactly 36
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_msck_trailing_garbage(dev):
msg = b'msck' + struct.pack('<III', 1, 2, 0xAB) + b'\xff' # 13 bytes, need exactly 12
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_stxn_trailing_garbage(dev):
msg = b'stxn' + struct.pack('<II', 100, 0) + bytes(32) + b'\xff' # 41 bytes, need exactly 40
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
def test_rest_trailing_garbage(dev):
empty_sha = hashlib.sha256(b'').digest()
msg = b'rest' + struct.pack('<I32sB', 100, empty_sha, 0) + b'\xff' # 38 bytes, need exactly 37
with pytest.raises(CCProtoError) as e:
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
# EOF

View File

@ -1,11 +1,13 @@
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
import pytest, time, os, re, hashlib, shutil
from helpers import xfp2str, prandom, addr_from_display_format
from charcodes import KEY_DOWN, KEY_QR, KEY_NFC, KEY_DELETE, KEY_UP
import pytest, time, os, re, hashlib, shutil, functools, ndef
from binascii import b2a_hex
from helpers import xfp2str, prandom
from charcodes import KEY_QR, KEY_NFC, KEY_DELETE
from constants import AF_CLASSIC, simulator_fixed_words, simulator_fixed_xfp
from mnemonic import Mnemonic
from bip32 import BIP32Node
from core_fixtures import _pass_word_quiz, _word_menu_entry
mnem = Mnemonic('english')
wordlist = mnem.wordlist
@ -97,117 +99,14 @@ def test_home_menu(cap_menu, cap_story, cap_screen, need_keypress, reset_seed_wo
press_cancel()
@pytest.fixture
def word_menu_entry(cap_menu, pick_menu_item, is_q1, do_keypresses, cap_screen):
def doit(words, has_checksum=True, q_accept=True):
if is_q1:
# easier for us on Q, but have to anticipate the autocomplete
for n, w in enumerate(words, start=1):
do_keypresses(w[0:2])
time.sleep(0.05)
if 'Next key' in cap_screen():
do_keypresses(w[2])
time.sleep(.01)
if 'Next key' in cap_screen():
if len(w) > 3:
do_keypresses(w[3])
else:
do_keypresses(KEY_DOWN)
time.sleep(.01)
pat = rf'{n}:\s?{w}'
for x in range(10):
if re.search(pat, cap_screen()):
break
time.sleep(0.02)
else:
raise RuntimeError('timeout')
if len(words) == 23:
do_keypresses(KEY_DOWN)
time.sleep(.03)
cap_scr = cap_screen()
while 'Next key' in cap_scr:
target = cap_scr.split("\n")[-1].replace("Next key: ", "")
# picks first choice!?
do_keypresses(target[0])
time.sleep(.03)
cap_scr = cap_screen()
else:
cap_scr = cap_screen()
if has_checksum:
assert 'Valid words' in cap_scr
else:
assert 'Press ENTER if all done' in cap_scr
if q_accept:
do_keypresses('\r')
return
# do the massive drilling-down to pick a specific pass phrase
assert len(words) in {1, 12, 18, 23, 24}
for word in words:
while 1:
menu = cap_menu()
which = None
for m in menu:
if '-' not in m:
if m == word:
which = m
break
else:
assert m[-1] == '-'
if m == word[0:len(m)-1]+'-':
which = m
break
assert which, "cant find: " + word
pick_menu_item(which)
if '-' not in which:
break
return doit
def word_menu_entry(dev, cap_menu, pick_menu_item, is_q1, do_keypresses, cap_screen):
f = functools.partial(_word_menu_entry, dev, is_q1)
return f
@pytest.fixture
def pass_word_quiz(need_keypress, cap_story, press_select):
def doit(words, prefix='', preload=None):
if not preload:
press_select()
time.sleep(.01)
count = 0
last_title = None
while 1:
title, body = preload or cap_story()
preload = None
if not title.startswith('Word '+prefix): break
assert title.endswith(' is?')
assert not last_title or last_title != title, "gave wrong ans?"
wn = int(title.split()[1][len(prefix):])
assert 1 <= wn <= len(words)
wn -= 1
ans = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':']
assert len(ans) == 3
correct = ans.index(words[wn])
assert 0 <= correct < 3
#print("Pick %d: %s" % (correct, ans[correct]))
need_keypress(chr(49 + correct))
time.sleep(.1)
count += 1
last_title = title
return count, title, body
return doit
def pass_word_quiz(dev, need_keypress, cap_story, press_select, is_q1):
f = functools.partial(_pass_word_quiz, dev, is_q1)
return f
@pytest.mark.qrcode
@ -860,7 +759,7 @@ def test_sign_file_from_list_files(f_len, goto_home, cap_story, pick_menu_item,
def test_rename_from_list_files(goto_home, cap_story, pick_menu_item, need_keypress, is_q1,
microsd_path, press_select, cap_screen, enter_complex):
microsd_path, press_select, cap_screen, enter_complex, cap_menu):
def clear(fname):
for i in range(len(fname)):
if not is_q1 and not i:
@ -920,6 +819,15 @@ def test_rename_from_list_files(goto_home, cap_story, pick_menu_item, need_keypr
assert not os.path.exists(fpath)
assert os.path.exists(microsd_path(new_fname))
# delete (6) from the same loop must blank the *renamed* file, not the stale old path
assert "(6) to delete" in story
need_keypress("6")
time.sleep(.1)
menu = cap_menu()
assert "List Files" in menu
assert not os.path.exists(microsd_path(new_fname))
assert not os.path.exists(fpath)
def test_bip39_pw_signing_xfp_ux(pick_menu_item, press_select, cap_story, enter_complex,
reset_seed_words, cap_menu, go_to_passphrase, microsd_wipe):
@ -959,6 +867,28 @@ def test_q1_seed_word_entry_bug(word_menu_entry, unit_test, pick_menu_item,
expect_ftux()
def test_q1_seed_word_bad_qr_keeps_words(unit_test, pick_menu_item, is_q1, do_keypresses,
need_keypress, scan_a_qr, cap_screen):
if not is_q1:
raise pytest.skip("Q only")
unit_test('devtest/clear_seed.py')
pick_menu_item('Import Existing')
pick_menu_item('12 Words')
do_keypresses("aba")
time.sleep(1)
assert "1: abandon" in cap_screen()
need_keypress(KEY_QR)
scan_a_qr("not a seed qr")
time.sleep(1)
screen = cap_screen()
assert "1: abandon" in screen
assert "Unable to decode as secret" in screen
def test_custom_pushtx_url(goto_home, pick_menu_item, press_select, enter_complex,
cap_story, cap_menu, settings_remove, need_keypress,
press_cancel, is_q1, settings_get, OK):
@ -1123,6 +1053,53 @@ def test_qr_share_files(fname, pick_menu_item, goto_home, is_q1, cap_menu, cap_s
assert res == qr.decode()
os.remove(f'{sim_root_dir}/MicroSD/' + fname)
@pytest.mark.parametrize("way", ["nfc", "qr"])
def test_share_binary_txn_file(way, goto_home, pick_menu_item, src_root_dir, sim_root_dir,
press_select, cap_story, cap_screen_qr, is_q1,enable_nfc,
nfc_read, nfc_block4rf, garbage_collector):
if way == "qr" and not is_q1:
pytest.skip("QR share is Q1 only")
if way == "nfc":
enable_nfc()
with open(f"{src_root_dir}/testing/data/devils-txn.txn", "r") as f:
binary = bytes.fromhex(f.read().strip())
assert binary[2:8] != bytes(6)
fname = "binary-l01.txn"
dst = f"{sim_root_dir}/MicroSD/{fname}"
garbage_collector.append(dst)
with open(dst, "wb") as f:
f.write(binary)
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("File Management")
pick_menu_item("NFC File Share" if way == "nfc" else "QR File Share")
time.sleep(.1)
pick_menu_item(fname)
time.sleep(.2)
title, story = cap_story()
assert "ERROR" not in title
if way == "nfc":
nfc_block4rf()
res = nfc_read()
got_txn = None
for got in ndef.message_decoder(res):
if got.type == 'urn:nfc:ext:bitcoin.org:txn':
got_txn = bytes(got.data)
break
assert got_txn == binary
press_select()
else:
qr = cap_screen_qr()
assert qr.decode().lower() == b2a_hex(binary).decode().lower()
@pytest.mark.parametrize("word,cs_word", [
# few combos with all words with length 8 + their longest possible checksum word
("acoustic", "decrease"),
@ -1143,11 +1120,11 @@ def test_q1_24_8char_words(set_seed_words, is_q1, goto_home, pick_menu_item, pre
if not is_q1:
raise pytest.skip("only Q")
goto_home()
# longest words in wordlist_en have 8 chars
words = ([word] * 23) + [cs_word]
set_seed_words(" ".join(words))
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("Danger Zone")
pick_menu_item("Seed Functions")
@ -1228,6 +1205,109 @@ def test_file_picker_suffixes(pick_menu_item, goto_home, cap_story, microsd_wipe
microsd_wipe()
@pytest.mark.parametrize("already_set", [True, False])
def test_nickname_cancel_preserves_existing(already_set, goto_home, pick_menu_item, need_keypress,
settings_set, settings_get, press_cancel, press_select,
settings_remove, sim_exec):
nick = 'CancelTest'
if already_set:
settings_set("nick", nick, prelogin=True)
else:
settings_remove("nick", prelogin=True)
goto_home()
pick_menu_item('Settings')
pick_menu_item('Login Settings')
pick_menu_item('Set Nickname')
if not already_set:
press_select() # intro
press_cancel()
new_nick = settings_get("nick", False, prelogin=True)
if already_set:
assert nick == new_nick
else:
assert new_nick is False
settings_remove("nick") # clean-up
@pytest.mark.parametrize('chain', ['BTC', 'XTN'])
@pytest.mark.parametrize('rz', [8, 5, 2, 0])
@pytest.mark.parametrize('amount', [
'1.1',
'50',
'0.12345678',
'1.10000000',
])
def test_bip21_amount_display(amount, chain, rz, settings_set, settings_remove, scan_a_qr,
cap_story, goto_home, need_keypress, press_cancel):
settings_set('chain', chain)
settings_set('rz', rz)
whole, _, frac = amount.partition('.')
sats = int((whole or '0') + (frac + '00000000')[:8])
if rz == 8:
amt = '%d.%08d %s' % (sats // 100000000, sats % 100000000, chain)
elif rz == 5:
amt = '%d.%05d m%s' % (sats // 100000, sats % 100000, chain)
elif rz == 2:
amt = '%d.%02d bits' % (sats // 100, sats % 100)
else:
assert rz == 0
amt = '%d sats' % sats
expected = 'Amount: %s' % amt
# base58 P2PKH decodes regardless of chain setting (we exploit bug here to not need to specify 2 addrs)
addr = 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R'
url = 'bitcoin:%s?amount=%s' % (addr, amount)
goto_home()
need_keypress(KEY_QR)
time.sleep(.1)
scan_a_qr(url)
time.sleep(.5)
title, body = cap_story()
assert title == 'Payment Address', title
assert expected in body
press_cancel()
settings_set('chain', 'XTN')
settings_remove('rz')
@pytest.mark.parametrize('amount', [
'999999999', # 9-digit whole part: 99,999,999 > 21M BTC supply
'999999999.0', # same, with explicit fractional zero
'1.123456789', # 9-digit fractional part: sub-satoshi precision
'abc', # not numeric at all
'1.5a', # mixed digits + alpha in fractional part
'-1.0', # negative sign breaks isdigit()
'1,5', # comma not handled (no dot found, whole isn't digits)
'', # empty string
])
def test_bip21_amount_display_corrupt(amount, scan_a_qr, cap_story, goto_home,
need_keypress, press_cancel):
addr = 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R'
url = 'bitcoin:%s?amount=%s' % (addr, amount)
goto_home()
need_keypress(KEY_QR)
time.sleep(.1)
scan_a_qr(url)
time.sleep(.5)
title, body = cap_story()
assert title == 'Payment Address', title
assert 'Amount: (corrupt)' in body
press_cancel()
@pytest.mark.onetime
def test_dump_menutree(sim_execfile):
# saves to ../unix/work/menudump.txt

View File

@ -1,6 +1,8 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
import pytest, time, os
import pytest, time, os, base64
from conftest import microsd_path
from helpers import prandom, addr_from_display_format
from charcodes import KEY_QR, KEY_NFC, KEY_UP
from constants import unmap_addr_fmt, AF_P2WSH, AF_P2SH
@ -101,7 +103,7 @@ def test_wif_store_import_paper_wallet(goto_home, pick_menu_item, press_select,
def test_wif_store_import_fail(way, wif, err, import_wif_to_store, skip_if_useless_way,
settings_remove, press_select, cap_story, use_testnet, settings_get):
err = err or "no valid WIF found"
err = err or "No valid WIF key found"
skip_if_useless_way(way)
use_testnet()
settings_remove("wifs")
@ -149,9 +151,10 @@ def test_wif_store_detail(netcode, import_wif_to_store, use_mainnet, cap_menu, p
time.sleep(.1)
menu = cap_menu()
assert menu[0] == "Detail"
assert menu[1] == "Addresses"
assert menu[2] == "Sign MSG"
assert menu[3] == "Delete"
assert menu[1] == "Descriptors"
assert menu[2] == "Addresses"
assert menu[3] == "Sign MSG"
assert menu[4] == "Delete"
pick_menu_item("Detail")
@ -226,9 +229,9 @@ def test_wif_store_addresses(netcode, import_wif_to_store, use_mainnet, cap_menu
assert addr == target_addr
if not is_q1:
assert "Press (1) to show address QR code." in story
assert "(4) to show QR code" in story
need_keypress(KEY_QR if is_q1 else "1")
need_keypress(KEY_QR if is_q1 else "4")
time.sleep(.1)
qr_addr = cap_screen_qr().decode()
if af == "p2wpkh":
@ -238,7 +241,7 @@ def test_wif_store_addresses(netcode, import_wif_to_store, use_mainnet, cap_menu
if nfc_is_enabled():
if not is_q1:
assert "(3) to share via NFC." in story
assert "(3) to share via NFC" in story
press_nfc()
time.sleep(0.3)
@ -336,6 +339,38 @@ def test_wif_store_capacity(import_wif_to_store, settings_remove, press_select,
assert "Import WIF" in menu
def test_visualize_wif_store_capacity(is_q1, goto_home, use_testnet, settings_remove,
import_wif_to_store, settings_get, need_keypress,
scan_a_qr, cap_story, press_select):
if not is_q1:
raise pytest.skip("need scanner")
settings_remove("wifs")
use_testnet()
goto_home()
import_wif_to_store([make_fake_wif() for _ in range(30)])
assert len(settings_get("wifs", [])) == 30
goto_home()
need_keypress(KEY_QR)
scan_a_qr("cUR6JLQCmdPPt3op4jEYmFhjHpWC2AoZaWmZqoDaBQYMXN4QeKuc")
time.sleep(1)
title, story = cap_story()
assert title == "WIF Key"
assert "Press (1) to import to WIF Store" in story
need_keypress("1")
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Max 30 items allowed in WIF Store" in story
assert len(settings_get("wifs", [])) == 30
press_select()
def test_wif_store_import_duplicate(settings_remove, import_wif_to_store, settings_get, cap_menu, cap_story,
goto_home):
goto_home()
@ -799,7 +834,448 @@ def test_visualize_wif(wif, testnet, is_q1, goto_home, need_keypress, use_testne
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Already saved in WIF Store" in story
assert "duplicate WIF" in story
press_select()
# EOF
@pytest.mark.bitcoind
def test_descriptor_export(import_wif_to_store, cap_menu, goto_home, settings_remove,
pick_menu_item, skip_if_useless_way, need_keypress, load_export,
cap_story, is_q1, bitcoind, press_cancel):
goto_home()
settings_remove("wifs")
node = BIP32Node.from_master_secret(os.urandom(32))
pk = node.node.private_key
wif_str = pk.wif(testnet=True)
target = f"wpkh({node.node.public_key.sec().hex()})"
target = bitcoind.rpc.getdescriptorinfo(target)["descriptor"]
import_wif_to_store([wif_str])
# now in wif store menu, only one menu item besides "Import WIF"
menu = cap_menu()
assert len(menu) == 2
pick_menu_item(menu[1])
pick_menu_item("Descriptors")
pick_menu_item("Segwit P2WPKH")
time.sleep(.1)
title, story = cap_story()
story_desc = story.split("\n\n")[0]
assert story_desc.strip() == target
need_keypress("1") # SD
sd_desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
assert sd_desc.strip() == target
time.sleep(.1)
title, story = cap_story()
if "QR" in story:
qr_desc = load_export("qr", "Descriptor", is_json=False, sig_check=False)
press_cancel() # exit QR disaply
assert qr_desc.strip() == target
time.sleep(.1)
title, story = cap_story()
if "NFC" in story:
nfc_desc = load_export("nfc", "Descriptor", is_json=False, sig_check=False)
assert nfc_desc.strip() == target
press_cancel()
goto_home()
@pytest.mark.bitcoind
@pytest.mark.parametrize('mode', [ "Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH"])
def test_spend_paper_wallet_desc_core(mode, bitcoind, settings_remove, import_wif_to_store,
start_sign, end_sign, cap_story, use_regtest, cap_menu,
pick_menu_item, goto_home, need_keypress, load_export):
use_regtest()
goto_home()
settings_remove("wifs")
amount = 5 # BTC
node = BIP32Node.from_master_secret(os.urandom(32))
pk = node.node.private_key
wif_str = pk.wif(testnet=True)
import_wif_to_store([wif_str])
# now in wif store menu, only one menu item besides "Import WIF"
menu = cap_menu()
assert len(menu) == 2
pick_menu_item(menu[1])
pick_menu_item("Descriptors")
pick_menu_item(mode)
need_keypress("1") # SD
desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
# must match pubkey from device
assert pk.K.sec().hex() in desc
paper_addr = bitcoind.rpc.deriveaddresses(desc)[0]
paper = bitcoind.create_wallet(wallet_name="paper-wif", disable_private_keys=True,
blank=True, descriptors=True)
res = paper.importdescriptors([{
"desc": desc, "timestamp": 0, "watchonly": True,
}])
assert len(res) == 1 and res[0]["success"]
bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
assert paper.listunspent()
dest = bitcoind.supply_wallet.getnewaddress()
resp = paper.walletcreatefundedpsbt([], [{dest: amount}], 0,
{"fee_rate": 3, "subtractFeeFromOutputs": [0]})
po = BasicPSBT().parse(base64.b64decode(resp["psbt"]))
# first sign as provided by core
psbt_bytes = po.as_bytes()
start_sign(psbt_bytes, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "WIF store: 0" in story
signed = end_sign(accept=True, finalize=True)
# remove BIP-32 paths from PSBT inputs
# causes auto-detection on CC side
for i in range(len(po.inputs)):
po.inputs[i].bip32_paths = None
psbt1_bytes = po.as_bytes()
assert len(psbt_bytes) > len(psbt1_bytes)
start_sign(psbt1_bytes, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "WIF store: 0" in story
signed1 = end_sign(accept=True, finalize=True)
assert signed1 == signed
tx_hex = signed.hex()
accept = bitcoind.rpc.testmempoolaccept([tx_hex])
assert accept[0]["allowed"]
txid = bitcoind.rpc.sendrawtransaction(tx_hex)
assert len(txid) == 64
settings_remove("wifs")
goto_home()
@pytest.mark.parametrize("wif_store", [True, False])
@pytest.mark.parametrize("subpaths", [True, False])
def test_no_keys(wif_store, subpaths, fake_txn, settings_set, start_sign,
cap_story, settings_remove):
hack = None
if subpaths is False:
def hack(psbt):
psbt.inputs[0].bip32_paths = None
node = BIP32Node.from_master_secret(os.urandom(32))
psbt = fake_txn(1, 1, segwit_in=True, master_xpub=node.hwif(), psbt_v2=True, psbt_hacker=hack)
# overwrite node, causing PSBT to be from completely different WIF/address
node = BIP32Node.from_master_secret(os.urandom(32))
n = node.subkey_for_path("0/0")
sk = bytes(n.node.private_key).hex()
pk = n.node.private_key.K.sec().hex()
if wif_store:
settings_set("wifs", [(pk, sk)])
else:
settings_remove("wifs")
start_sign(psbt, finalize=True)
title, story = cap_story()
assert "Failure" == title
if wif_store is False and subpaths is False:
assert "PSBT does not contain any key path information" in story
else:
assert "None of the keys involved in this transaction belong to this Coldcard" in story
def test_unrelated_wif_does_not_allow_presigned_foreign_psbt(fake_txn, settings_set,
start_sign, cap_story):
foreign = BIP32Node.from_master_secret(os.urandom(32))
psbt = fake_txn(2, 1, segwit_in=True, master_xpub=foreign.hwif(), psbt_v2=True)
po = BasicPSBT().parse(psbt)
pubkey = list(po.inputs[0].bip32_paths.keys())[0]
po.inputs[0].part_sigs[pubkey] = b'\x30' + os.urandom(70)
unrelated = BIP32Node.from_master_secret(os.urandom(32)).subkey_for_path("0/0")
sk = bytes(unrelated.node.private_key).hex()
pk = unrelated.node.private_key.K.sec().hex()
settings_set("wifs", [(pk, sk)])
start_sign(po.as_bytes(), finalize=False)
title, story = cap_story()
assert title == "Failure"
assert "None of the keys involved in this transaction belong to this Coldcard" in story
@pytest.mark.bitcoind
@pytest.mark.parametrize('mode', ["Classic P2PKH", "Segwit P2WPKH", "P2SH-Segwit"])
def test_spend_paper_wallet_addr_only(mode, bitcoind, settings_remove, import_wif_to_store,
start_sign, end_sign, cap_story, use_regtest,
pick_menu_item, goto_home, cap_menu, need_keypress,
load_export):
use_regtest()
goto_home()
settings_remove("wifs")
amount = 10 # BTC
node = BIP32Node.from_master_secret(os.urandom(32))
pk = node.node.private_key
wif_str = pk.wif(testnet=True)
import_wif_to_store([wif_str])
menu = cap_menu()
assert len(menu) == 2
# Use the device-exported descriptor only to derive the address; we'll
# build the watch-only wallet around addr() instead of pkh/wpkh.
pick_menu_item(menu[1])
pick_menu_item("Descriptors")
pick_menu_item(mode)
need_keypress("1") # SD
desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
paper_addr = bitcoind.rpc.deriveaddresses(desc.strip())[0]
# Watch-only wallet built from addr() — no pubkey knowledge at all.
addr_desc = bitcoind.rpc.getdescriptorinfo("addr(%s)" % paper_addr)["descriptor"]
wname = "paper-addr-%s" % mode.replace(' ', '-')
paper = bitcoind.create_wallet(wallet_name=wname, disable_private_keys=True,
blank=True, descriptors=True)
res = paper.importdescriptors([{
"desc": addr_desc, "timestamp": "now", "watchonly": True,
}])
assert len(res) == 1 and res[0]["success"], res
# two inputs
bitcoind.supply_wallet.sendtoaddress(paper_addr, amount/2)
bitcoind.supply_wallet.sendtoaddress(paper_addr, amount/2)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
assert paper.listunspent()
dest = bitcoind.supply_wallet.getnewaddress()
# solving_data lets Core estimate the dummy signature size for fee
# selection; it is NOT written into the PSBT, so bip32_paths stays empty.
pubkey_hex = node.node.public_key.sec().hex()
resp = paper.walletcreatefundedpsbt(
[], [{dest: amount}], 0,
{"fee_rate": 3, "subtractFeeFromOutputs": [0],
"solving_data": {"pubkeys": [pubkey_hex]}})
psbt_bytes = base64.b64decode(resp["psbt"])
# Sanity check
po = BasicPSBT().parse(psbt_bytes)
for i, inp in enumerate(po.inputs):
assert not inp.bip32_paths
start_sign(psbt_bytes, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "WIF store: 0" in story
signed = end_sign(accept=True, finalize=True)
tx_hex = signed.hex()
accept = bitcoind.rpc.testmempoolaccept([tx_hex])
assert accept[0]["allowed"], accept
txid = bitcoind.rpc.sendrawtransaction(tx_hex)
assert len(txid) == 64
settings_remove("wifs")
goto_home()
@pytest.mark.bitcoind
def test_spend_paper_wallet_addr_only_p2sh_segwit_signed_psbt_finalizes(
bitcoind, settings_remove, import_wif_to_store, start_sign, end_sign,
cap_story, use_regtest, pick_menu_item, goto_home, cap_menu, need_keypress,
load_export):
use_regtest()
goto_home()
settings_remove("wifs")
amount = 10 # BTC
node = BIP32Node.from_master_secret(os.urandom(32))
pk = node.node.private_key
wif_str = pk.wif(testnet=True)
import_wif_to_store([wif_str])
menu = cap_menu()
assert len(menu) == 2
pick_menu_item(menu[1])
pick_menu_item("Descriptors")
pick_menu_item("P2SH-Segwit")
need_keypress("1") # SD
desc = load_export("sd", "Descriptor", is_json=False, sig_check=False)
paper_addr = bitcoind.rpc.deriveaddresses(desc.strip())[0]
addr_desc = bitcoind.rpc.getdescriptorinfo("addr(%s)" % paper_addr)["descriptor"]
paper = bitcoind.create_wallet(wallet_name="paper-addr-p2sh-segwit-signed-psbt",
disable_private_keys=True, blank=True,
descriptors=True)
res = paper.importdescriptors([{
"desc": addr_desc, "timestamp": "now", "watchonly": True,
}])
assert len(res) == 1 and res[0]["success"], res
bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
assert paper.listunspent()
dest = bitcoind.supply_wallet.getnewaddress()
pubkey_hex = node.node.public_key.sec().hex()
resp = paper.walletcreatefundedpsbt(
[], [{dest: amount}], 0,
{"fee_rate": 3, "subtractFeeFromOutputs": [0],
"solving_data": {"pubkeys": [pubkey_hex]}})
psbt_bytes = base64.b64decode(resp["psbt"])
po = BasicPSBT().parse(psbt_bytes)
for inp in po.inputs:
assert not inp.bip32_paths
start_sign(psbt_bytes, finalize=False)
time.sleep(.1)
title, story = cap_story()
assert "WIF store: 0" in story
signed_psbt = end_sign(accept=True, finalize=False)
finalize_res = bitcoind.rpc.finalizepsbt(base64.b64encode(signed_psbt).decode(), True)
assert finalize_res["complete"], finalize_res
accept = bitcoind.rpc.testmempoolaccept([finalize_res["hex"]])
assert accept[0]["allowed"], accept
settings_remove("wifs")
goto_home()
def test_spend_paper_wallet_addr_only_wif_input_details(
fake_txn, settings_set, settings_remove, start_sign, cap_story,
pick_menu_item, need_keypress, press_cancel):
settings_remove("wifs")
node = BIP32Node.from_master_secret(os.urandom(32))
n = node.subkey_for_path("0/0")
pubkey_hex = n.node.private_key.K.sec().hex()
settings_set("wifs", [(pubkey_hex, bytes(n.node.private_key).hex())])
def hack(psbt):
psbt.inputs[0].bip32_paths = None
psbt.inputs[0].redeem_script = None
psbt = fake_txn(1, 1, segwit_in=True, wrapped=True, master_xpub=node.hwif(),
psbt_hacker=hack)
po = BasicPSBT().parse(psbt)
for inp in po.inputs:
assert not inp.bip32_paths
assert inp.redeem_script is None
start_sign(psbt, finalize=False)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "WIF store: 0" in story
assert "Press (2) to explore transaction" in story
need_keypress("2")
pick_menu_item("Inputs")
time.sleep(.1)
title, story = cap_story()
assert title == "Input 0"
assert "WIF Store" in story
assert pubkey_hex in story
for _ in range(3):
press_cancel()
settings_remove("wifs")
@pytest.mark.bitcoind
@pytest.mark.parametrize('mode', ["Classic P2PKH", "Segwit P2WPKH", "P2SH-Segwit"])
def test_spend_paper_wallet_via_electrum(mode, bitcoind, electrum, settings_remove,
import_wif_to_store, start_sign, end_sign,
cap_story, use_regtest, pick_menu_item,
goto_home, cap_menu, press_cancel,
need_keypress, cap_screen_qr, is_q1):
use_regtest()
goto_home()
settings_remove("wifs")
amount = 5 # BTC
node = BIP32Node.from_master_secret(os.urandom(32))
pk = node.node.private_key
wif_str = pk.wif(testnet=True)
import_wif_to_store([wif_str])
menu = cap_menu()
assert len(menu) == 2
pick_menu_item(menu[1])
pick_menu_item("Addresses")
pick_menu_item(mode)
time.sleep(.1)
need_keypress(KEY_QR if is_q1 else "4")
time.sleep(.1)
paper_addr = cap_screen_qr().decode()
if mode == "Segwit P2WPKH":
paper_addr = paper_addr.lower()
goto_home()
# Electrum imported-address watch-only wallet.
wallet_path = electrum.imported_addr_wallet(
paper_addr, name="paper-%s" % mode.replace(' ', '-'))
# Fund the address via bitcoind, confirm.
txid = bitcoind.supply_wallet.sendtoaddress(paper_addr, amount)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# `getrawtransaction` won't see this once it's mined (no -txindex), but
# the supply wallet still has the tx in its own history.
funding_hex = bitcoind.supply_wallet.gettransaction(txid)["hex"]
# Tell Electrum about the funding tx so its wallet sees the UTXO without
# needing an Electrum server backend.
electrum.addtransaction(wallet_path, funding_hex)
# Build the unsigned PSBT in Electrum.
dest = bitcoind.supply_wallet.getnewaddress()
spend_amt = round(amount - 0.001, 8)
psbt_b64 = electrum.payto_unsigned_psbt(wallet_path, dest, spend_amt)
psbt_bytes = base64.b64decode(psbt_b64)
# Sanity: confirm Electrum did NOT include any bip32 derivations.
# If this changes upstream, the test below would no longer be exercising
# the scriptPubKey auto-detect path.
po = BasicPSBT().parse(psbt_bytes)
for i, inp in enumerate(po.inputs):
assert not inp.bip32_paths
# Sign on Coldcard — must use scriptPubKey hash auto-detect.
start_sign(psbt_bytes, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "WIF store: 0" in story
signed = end_sign(accept=True, finalize=True)
tx_hex = signed.hex()
accept = bitcoind.rpc.testmempoolaccept([tx_hex])
assert accept[0]["allowed"], accept
txid = bitcoind.rpc.sendrawtransaction(tx_hex)
assert len(txid) == 64
settings_remove("wifs")
goto_home()
# EOF

View File

@ -25,7 +25,7 @@ def fake_txn(dev, pytestconfig):
outstyles=['p2pkh'], psbt_hacker=None, change_outputs=[],
capture_scripts=None, add_xpub=None, op_return=None, taproot_in=False,
psbt_v2=None, input_amount=1E8, unknown_out_script=None, lock_time=0,
sequences=None, sighashes=None, dupe_ins=[]):
sequences=None, sighashes=None, dupe_ins=[], p2pk_in=False):
psbt = BasicPSBT()
@ -40,6 +40,8 @@ def fake_txn(dev, pytestconfig):
psbt.txn_version = 2
psbt.input_count = num_ins
psbt.output_count = num_outs
if lock_time:
psbt.fallback_locktime = lock_time
txn = CTransaction()
txn.nLockTime = lock_time
@ -58,12 +60,25 @@ def fake_txn(dev, pytestconfig):
# - each input is 1BTC
# addr where the fake money will be stored.
subkey = mk.subkey_for_path(subpath % i)
sec = subkey.sec()
assert len(sec) == 33, "expect compressed"
assert subpath[0:2] == '0/'
if subpath is None:
subkey = mk
sec = mk.sec()
bytes_path = b""
else:
subkey = mk.subkey_for_path(subpath % i)
if p2pk_in:
assert not segwit_in and not taproot_in and not wrapped
assert p2pk_in in (True, 'compressed', 'uncompressed')
sec = subkey.sec(compressed=(p2pk_in != 'uncompressed'))
else:
sec = subkey.sec()
assert len(sec) == 33, "expect compressed"
assert subpath[0:2] == '0/'
psbt.inputs[i].bip32_paths[sec] = xfp + struct.pack('<II', 0, i)
# TODO does not respect subpath parameter
bytes_path = struct.pack('<II', 0, i)
psbt.inputs[i].bip32_paths[sec] = xfp + bytes_path
# UTXO that provides the funding for to-be-signed txn
supply = CTransaction()
@ -74,7 +89,10 @@ def fake_txn(dev, pytestconfig):
)
supply.vin = [CTxIn(out_point, nSequence=0xffffffff)]
if segwit_in:
if p2pk_in:
# p2pk: <push pubkey> OP_CHECKSIG
scr = bytes([len(sec)]) + sec + b'\xac'
elif segwit_in:
# p2wpkh
scr = bytes([0x00, 0x14]) + subkey.hash160()
if wrapped:
@ -250,6 +268,10 @@ def render_address(script, testnet=True):
b58_script = bytes([196])
b58_privkey = bytes([239])
if ll in (35, 67) and script[0] == (ll - 2) and script[-1] == 0xac:
# does not have address format - just show raw scriptPubKey
return b2a_hex(script).decode()
# P2PKH
if ll == 25 and script[0:3] == b'\x76\xA9\x14' and script[23:26] == b'\x88\xAC':
return encode_base58_checksum(b58_addr + script[3:3+20])

Some files were not shown because too many files have changed in this diff Show More