Compare commits
59 Commits
2026-03-05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542dcd32c7 | ||
|
|
0ef6413cd8 | ||
|
|
97d86c9571 | ||
|
|
edae8c1ee6 | ||
|
|
553405776f | ||
|
|
ad2088d231 | ||
|
|
67a5c6c270 | ||
|
|
eb112eb3a1 | ||
|
|
0d04e5e1f8 | ||
|
|
59eb529a20 | ||
|
|
d5aba396a6 | ||
|
|
6fd256dbdc | ||
|
|
6716fcbacb | ||
|
|
1dddd88525 | ||
|
|
d656f371c7 | ||
|
|
7e92e5162a | ||
|
|
74d34cfcf7 | ||
|
|
64658621bb | ||
|
|
755353f029 | ||
|
|
2981d15933 | ||
|
|
9ff3f5c447 | ||
|
|
f5a1ef32c9 | ||
|
|
841e44335e | ||
|
|
38616234e7 | ||
|
|
8e3bbfdf84 | ||
|
|
f9b65ce968 | ||
|
|
8d71040acf | ||
|
|
5feae87e03 | ||
|
|
0949c0ac86 | ||
|
|
c36eac23d2 | ||
|
|
a24a894cfd | ||
|
|
ca06dfd250 | ||
|
|
3a1ef6fe50 | ||
|
|
883be60fc5 | ||
|
|
393ebf5b43 | ||
|
|
2b5178bd63 | ||
|
|
44e7be3681 | ||
|
|
300323f18d | ||
|
|
c998432fc4 | ||
|
|
9c6cfcbbd7 | ||
|
|
be614dab92 | ||
|
|
02bd428786 | ||
|
|
6869ba87b0 | ||
|
|
d0f834570b | ||
|
|
00afe533ca | ||
|
|
621523c1a8 | ||
|
|
fdf630ab31 | ||
|
|
d2ad7a5923 | ||
|
|
29ef16be63 | ||
|
|
3344975607 | ||
|
|
15191eaaa5 | ||
|
|
717a3f591a | ||
|
|
74790ab80d | ||
|
|
50b1704c60 | ||
|
|
1e9338c550 | ||
|
|
17fc097cbd | ||
|
|
6f6563c2cb | ||
|
|
4904d38bb6 | ||
|
|
1ea83d9b70 |
21
README.md
21
README.md
@ -8,7 +8,7 @@ with the latest updates and security alerts.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Quick Links
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -49,14 +51,15 @@ such as Taproot or Miniscript. Our standards for releasing new Edge
|
||||
versions are lower, so we can iterate faster and get these advancements
|
||||
out to other developers.
|
||||
|
||||
Q and Mk4 share the same code base. Individual files that are added,
|
||||
Q and Mk series share the same code base. Individual files that are added,
|
||||
or removed, can be see in differences between `shared/manifest_mk4.py`
|
||||
and `shared/manifest_q1.py`. Common files are in `shared/manifest.py`.
|
||||
Firmware built for Mk5, supports the Mk4 without any functional differences.
|
||||
|
||||
|
||||
## Check-out and Setup
|
||||
|
||||
**NOTE** This is the `master` branch and covers the latest hardware (Mk4 and Q).
|
||||
**NOTE** This is the `master` branch and covers the latest hardware (Mk and Q).
|
||||
See branch `v4-legacy` for firmware which supports only Mk3/Mk2 and earlier.
|
||||
|
||||
Do a checkout, recursively, to get all the submodules:
|
||||
@ -232,8 +235,8 @@ Top-level dirs:
|
||||
|
||||
- shared code between desktop test version and real-deal
|
||||
- expected to be largely in python, and higher-level
|
||||
- new code found only on the Mk4 will be listed in `manifest_mk4.py` code exclusive
|
||||
to earlier hardware is in `manifest_mk3.py`
|
||||
- code exclusive to the Mk4 or Mk5 will be listed in `manifest_mk4.py`, and
|
||||
to the Q will be listed in `manifest_q1.py`
|
||||
|
||||
`unix`
|
||||
|
||||
@ -265,7 +268,7 @@ Top-level dirs:
|
||||
`stm32/mk4-bootloader`
|
||||
`stm32/q1-bootloader`
|
||||
|
||||
- 128k of factory-set code that you cannot change for Mk4 or Q
|
||||
- 128k of factory-set code that you cannot change
|
||||
- however, you can inspect what code is on your coldcard and compare to this.
|
||||
|
||||
`hardware`
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>`
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@ -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
BIN
docs/nfc-tap-locations.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Amount 0.20000000 XTN
|
||||
Message:
|
||||
POR
|
||||
|
||||
Message Hash:
|
||||
11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
|
||||
Amount 0.20000000 BTC
|
||||
|
||||
Message Challenge:
|
||||
00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
|
||||
Challenge Address:
|
||||
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
|
||||
|
||||
21 inputs
|
||||
1 output
|
||||
21 inputs
|
||||
1 output
|
||||
|
||||
0.00000000 XTN
|
||||
- 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.
|
||||
```
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
2
external/ckcc-protocol
vendored
2
external/ckcc-protocol
vendored
@ -1 +1 @@
|
||||
Subproject commit 51de25089ef0154f6cc4b54a849e611e8c88a3fd
|
||||
Subproject commit 3d1dfa858beb58b8dac37d8c66d7aed2909812f2
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
# Coldcard Hardware Details
|
||||
|
||||
This directory contains enough information for you to be able to
|
||||
@ -6,7 +5,6 @@ build your own Coldcard from off-the-shelf parts.
|
||||
We are sharing this information for the benefit of security
|
||||
researchers who wish to analyse the Coldcard more completely.
|
||||
|
||||
|
||||
# Schematic
|
||||
|
||||

|
||||
@ -15,6 +13,12 @@ researchers who wish to analyse the Coldcard more completely.
|
||||
|
||||
This is the Q rev D schematic.
|
||||
|
||||

|
||||
|
||||
`schematic-mark5f.png`
|
||||
|
||||
This is the Mark4 rev F schematic.
|
||||
|
||||

|
||||
|
||||
`schematic-mark4d.png`
|
||||
@ -30,27 +34,20 @@ This is the Mark3 rev B schematic.
|
||||
|
||||
# BOM - Bill of Materials
|
||||
|
||||
The parts used in the Coldcard are detailed in this spreadsheet file.
|
||||
The parts used in the Coldcard are detailed in these spreadsheets.
|
||||
Most of them could be bought on Digikey, but some are direct from suppliers.
|
||||
|
||||
- BOM for Q rev D: `bom-q1d.xlsx`
|
||||
- BOM for Mk5 rev F: `bom-mark5f.xlsx`
|
||||
- BOM for Mk4 rev D: `bom-mark4d.xlsx`
|
||||
- BOM for Mk3 rev B: `bom-mark3b.xlsx`
|
||||
|
||||
Not included are these minor bits:
|
||||
|
||||
- the plastic case (custom)
|
||||
- the secure bag (with barcode serial number)
|
||||
- pin-recovery card
|
||||
|
||||
`bom-q1d.xlsx`
|
||||
|
||||
- BOM for Q rev D.
|
||||
|
||||
`bom-mark4d.xlsx`
|
||||
|
||||
- BOM for Mk3 rev D.
|
||||
|
||||
`bom-mark3b.xlsx`
|
||||
|
||||
- BOM for Mk3 rev B.
|
||||
|
||||
# Important
|
||||
|
||||
- No promises that these files are 100% current because we constantly make quality improvements.
|
||||
|
||||
BIN
hardware/bom-mark5f.xlsx
Normal file
BIN
hardware/bom-mark5f.xlsx
Normal file
Binary file not shown.
BIN
hardware/schematic-mark5f.png
Normal file
BIN
hardware/schematic-mark5f.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
@ -2,27 +2,40 @@
|
||||
|
||||
This lists the changes in the most recent firmware, for each hardware platform.
|
||||
|
||||
# Shared Improvements - Both Mk4 and Q
|
||||
# Shared Improvements - Both Mk and Q
|
||||
|
||||
- Enhancement: Address format guessing changed away from using PSBT XPUB's derivation paths.
|
||||
Now based on witness/redeem script of first PSBT input instead.
|
||||
- Enhancement: Show master XFP of backup secret and ask user for confirmation before loading backup.
|
||||
- Enhancement: Show firmware version added to hobbled Advanced/Tools menu.
|
||||
- Bugfix: Exiting text input of Custom Backup Password caused yikes.
|
||||
- Bugfix: Temporary seeds in SSSP mode were not able to update block height.
|
||||
- New Feature: Sign [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) Proof of Reserve PSBT files.
|
||||
- Requires a carefully crafted PSBT that does not represent a monetary transaction, but instead is demonstrating
|
||||
control over the keys for a list of UTXO, and commits to a short text message.
|
||||
- Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/proof-of-reserves-bip-322.md).
|
||||
- New Feature: WIF Store. Ability to import foreign WIF keys (Wallet Import Format) and use them for PSBT signing.
|
||||
- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
|
||||
Navigate to "Advanced/Tools -> Export Wallet -> Key Expression"
|
||||
- New Feature: Transaction Input Explorer. Shows data about UTXO(s) being spent. Press (2) before approving
|
||||
transaction to enter Transaction Explorer.
|
||||
- New Feature: Support for v3 transactions in PSBT files.
|
||||
- New Feature: Option to type a derived BIP-85 secret as an emulated USB keyboard.
|
||||
- New Feature: Nuke Device: purges all sensitive data and makes your COLDCARD e-waste.
|
||||
- Enhancement: CCC debug menu allows you to reset block height.
|
||||
- Enhancement: Show the BIP-39 passphrase on-screen (must scroll down) once new key is in effect.
|
||||
- Enhancement: New "Buried Settings" menu, inside Settings menu, for rarely-applied settings.
|
||||
- Enhancement: Add "Blue Wallet" option to "Export Wallet"
|
||||
- Enhancement: Detect duplicated inputs in PSBT file.
|
||||
- Bugfix: Replace `/` with `-` in exported file names of multisig wallet export artifacts.
|
||||
|
||||
# Mk4 Specific Changes
|
||||
# Mk Specific Changes
|
||||
|
||||
## 5.4.5 - 2025-11-03
|
||||
## 5.5.0 - 2065-03-05
|
||||
|
||||
- None.
|
||||
- This release supports both the newer Mk5 hardware and existing Mk4.
|
||||
- Enhancement: Show QR of XOR-split seeds.
|
||||
|
||||
|
||||
# Q Specific Changes
|
||||
|
||||
## 1.3.5Q - 2025-11-03
|
||||
## 1.4.0Q - 2065-03-05
|
||||
|
||||
- Enhancement: Show backup filename at the top of the screen during backup password entry.
|
||||
- Bugfix: Empty notes in hobbled mode caused yikes upon menu entry.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
*See ChangeLog.md for more recent changes, these are historic versions*
|
||||
|
||||
|
||||
## 5.4.5 - 2025-11-03
|
||||
|
||||
- Enhancement: Address format guessing changed away from using PSBT XPUB's derivation paths.
|
||||
Now based on witness/redeem script of first PSBT input instead.
|
||||
- Enhancement: Show master XFP of backup secret and ask user for confirmation before loading backup.
|
||||
- Enhancement: Show firmware version added to hobbled Advanced/Tools menu.
|
||||
- Bugfix: Exiting text input of Custom Backup Password caused yikes.
|
||||
- Bugfix: Temporary seeds in SSSP mode were not able to update block height.
|
||||
|
||||
## 5.4.4 - 2025-09-30
|
||||
|
||||
- Spending policies for "Single Signers" adds new spending policy options:
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
*See ChangeLog.md for more recent changes, these are historic versions*
|
||||
|
||||
|
||||
## 1.3.5Q - 2025-11-03
|
||||
|
||||
- Enhancement: Address format guessing changed away from using PSBT XPUB's derivation paths.
|
||||
Now based on witness/redeem script of first PSBT input instead.
|
||||
- Enhancement: Show master XFP of backup secret and ask user for confirmation before loading backup.
|
||||
- Enhancement: Show firmware version added to hobbled Advanced/Tools menu.
|
||||
- Bugfix: Exiting text input of Custom Backup Password caused yikes.
|
||||
- Bugfix: Temporary seeds in SSSP mode were not able to update block height.
|
||||
- Enhancement: Show backup filename at the top of the screen during backup password entry.
|
||||
|
||||
|
||||
## 1.3.4Q - 2025-09-30
|
||||
|
||||
- Spending policies for "Single Signers" adds new spending policy options:
|
||||
|
||||
@ -4,36 +4,71 @@ This lists the new changes that have not yet been published in a normal release.
|
||||
|
||||
# Shared Improvements - Both Mk and Q
|
||||
|
||||
- New Feature: Sign [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) Proof of Reserve PSBT files.
|
||||
- Requires a carefully crafted PSBT that does not represent a monetary transaction, but instead is demonstrating
|
||||
control over the keys for a list of UTXO, and commits to a short text message.
|
||||
- Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/proof-of-reserves-bip-322.md).
|
||||
- New Feature: WIF Store. Ability to import foreign WIF keys (Wallet Import Format) and use them for PSBT signing.
|
||||
- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
|
||||
Navigate to "Advanced/Tools -> Export Wallet -> Key Expression"
|
||||
- New Feature: Transaction Input Explorer. Shows data about UTXO(s) being spent. Press (2) before approving
|
||||
transaction to enter Transaction Explorer.
|
||||
- New Feature: Support for v3 transactions in PSBT files.
|
||||
- New Feature: Option to type a derived BIP-85 secret as an emulated USB keyboard.
|
||||
- New Feature: Nuke Device: purges all sensitive data and makes your COLDCARD e-waste.
|
||||
- Enhancement: CCC debug menu allows you to reset block height.
|
||||
- Enhancement: Show the BIP-39 passphrase on-screen (must scroll down) once new key is in effect.
|
||||
- Enhancement: New "Buried Settings" menu, inside Settings menu, for rarely-applied settings.
|
||||
- Enhancement: Add "Blue Wallet" option to "Export Wallet"
|
||||
- Enhancement: Detect duplicated inputs in PSBT file.
|
||||
- Bugfix: Replace `/` with `-` in exported file names of multisig wallet export artifacts.
|
||||
- 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
|
||||
|
||||
## 5.5.0 - 2065-03-05
|
||||
## 5.5.x - 2065-04-xx
|
||||
|
||||
- Enhancement: Show QR of XOR-split seeds.
|
||||
- tbd
|
||||
|
||||
|
||||
# Q Specific Changes
|
||||
|
||||
## 1.4.0Q - 2065-03-05
|
||||
|
||||
- Bugfix: Empty notes in hobbled mode caused yikes upon menu entry.
|
||||
|
||||
## 1.4.xQ - 2065-04-xx
|
||||
|
||||
- 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.
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
Hash: SHA256
|
||||
|
||||
95eff9e044cdb6b3d00961ae72d450684d5441c6a3661ab550a3c3aa0882e754 README.md
|
||||
f95107424ee7f02981045c494d9746498246178ff94a92297a7cbe1d348c99c9 Next-ChangeLog.md
|
||||
d292902dbeb7b7e2ccdca7512194157018625c1fd23268f0d1ee4f357d09822f History-Q.md
|
||||
3a914286f544cd5c2ed9ef1196451dcd24aec2416045efb61672a6204c9843e0 History-Mk4.md
|
||||
412597a0e30684400cb61ee04650c13ef9fc3dc16fc2570bd5e33a1dc0085d7a Next-ChangeLog.md
|
||||
72458ab9eb2872d263bf4d3f4ca0fbf0ff9c6186f08d27f13fd600cb511ed2a7 History-Q.md
|
||||
d4891b509915800650a881556cca37604caab7a268afc0b1ed31021cea125891 History-Mk4.md
|
||||
c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md
|
||||
c14793ad2ef0ebca0a97dba9a0b41657ce48d7b121eb107101977385564fdf5a ChangeLog.md
|
||||
9ebab063b57ff07e5d8df20c266ac94736a6ad0e4c71ad1f1db46ec16b0c94be ChangeLog.md
|
||||
b2fa9f4b9a9778b71cf4b09ad79732192fdb457214f868a3af5234094deea33f 2026-03-25T1408-v6.5.0X-mk-coldcard.dfu
|
||||
f7bed9f1d2d49a35e7c53c8208e73ceaccbee2ab3e7fcd7c020fbd4923140313 2026-03-25T1407-v6.5.0QX-q1-coldcard.dfu
|
||||
2b7b4d95cd5d606b0a32e692db7a27c1d860140c6e919e20ea6672ad6afc3088 2026-03-05T2052-v5.5.0-mk-coldcard.dfu
|
||||
15f26aa0b8fe33e29e338b74acc52d5532922af56bcf486e38085de55c86b82a 2026-03-05T2052-v5.5.0-mk-coldcard-factory.dfu
|
||||
b7ce3ba55ae4bb1e1ebe0090507bcada5a4439e57a19552c78da7ef103bd144c 2026-03-05T2051-v1.4.0Q-q1-coldcard.dfu
|
||||
82c2ed5ee5cf75cc8af0f54839b9a1bc8e4a329174657d61861a6576fb6e31d9 2026-03-05T2051-v1.4.0Q-q1-coldcard-factory.dfu
|
||||
372fa1f82e54f632574c56a695a1ed332464bf029bd733b2db2131a591d8f126 2025-11-25T1618-v6.4.1X-mk4-coldcard.dfu
|
||||
@ -123,12 +127,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192
|
||||
bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmmp7MgACgkQo6MbrVoq
|
||||
WxB4EQgAq5INNuihzpMYXSo1PSOvH7MP3wA67+ILGfTGDSmne8pWogfqA2WqEhDt
|
||||
jpM9BxnQqleB1dKTTezQjoI0zC3bgK9847MnZaA7/2YyICz67V46IEx5RXZAytpD
|
||||
19nBabgdR7YTuJnmm4FPJX3EaBFES1At6BM65MVEHuLaJIvRvCjqS2+rQZMT7YeN
|
||||
5+RuuuxzG+WJUwkDyFyl55fScRSj59wFCGnLq+++OnDr4Dbnq6W7UrwKPCZ31/g+
|
||||
OaAZ7U4i2gKcL7SJZeqYauXLF28dF0ZTI4sbDMP0rI5qdkV62Zm4n0mFULYJxpaV
|
||||
Q7IaSPGC7DChJNeN0wB5P8x1jYhvcg==
|
||||
=2L9K
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmnD9ncACgkQo6MbrVoq
|
||||
WxD9RAf+JkP/XVUPMDyfz+79AxBWFNU9r6RuYzXdzX3Z/XCKomZCZDtV7Ak6XlZi
|
||||
GTNfsUNHaPC8WP6smFzYg07NoY2U1fVdY7+qeOi7UXF0hBBDJw7Gsa49P2zmt+DB
|
||||
lfzivQG2n+mT4cM64Z0WF3BYBWmCuDJdctqUAnLJe2p8bh6S8n5hFeKqndRhffNK
|
||||
773amkUrDW3RkHkIuevH4MQlR4ozWBmHzcehFDlTYT8BVLR8gg6hBzBEylxyDJNO
|
||||
Ld4W5SzsT6We0RGX2uOpMERDjkizqT9t5J63drzpuPrUQA8XVQPaOc07vpFHRbbZ
|
||||
BhA61XO8yazNLVvata611pSTikNnDQ==
|
||||
=8Ti0
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
@ -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")
|
||||
@ -2201,7 +2218,6 @@ Coldcard Firmware
|
||||
{rel}
|
||||
{built}
|
||||
|
||||
|
||||
Bootloader:
|
||||
{bl}
|
||||
{chk}
|
||||
@ -2297,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
|
||||
@ -2366,6 +2382,8 @@ async def change_seed_vault(is_enabled):
|
||||
|
||||
async def change_which_chain(*a):
|
||||
# setting already changed, but reflect that value in other settings
|
||||
from glob import dis
|
||||
dis.fullscreen("Wait...")
|
||||
try:
|
||||
# update xpub stored in settings
|
||||
import stash
|
||||
@ -2429,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 = [
|
||||
|
||||
@ -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):
|
||||
|
||||
223
shared/auth.py
223
shared/auth.py
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
9
shared/block_height.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
@ -353,26 +338,11 @@ class BitcoinTestnet(ChainsBase):
|
||||
b44_cointype = 1
|
||||
|
||||
|
||||
class BitcoinRegtest(ChainsBase):
|
||||
class BitcoinRegtest(BitcoinTestnet):
|
||||
ctype = 'XRT'
|
||||
name = 'Bitcoin Regtest'
|
||||
|
||||
slip132 = {
|
||||
AF_CLASSIC: Slip132Version(0x043587cf, 0x04358394, 't'),
|
||||
AF_P2WPKH_P2SH: Slip132Version(0x044a5262, 0x044a4e28, 'u'),
|
||||
AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'),
|
||||
AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'),
|
||||
AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'),
|
||||
}
|
||||
|
||||
bech32_hrp = 'bcrt'
|
||||
|
||||
b58_addr = bytes([111])
|
||||
b58_script = bytes([196])
|
||||
b58_privkey = bytes([239])
|
||||
|
||||
b44_cointype = 1
|
||||
|
||||
|
||||
def get_chain(short_name):
|
||||
# lookup object from name: 'BTC' or 'XTN'
|
||||
@ -480,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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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...")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
208
shared/notes.py
208
shared/notes.py
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
335
shared/psbt.py
335
shared/psbt.py
@ -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())
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)" \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
214
shared/wif.py
214
shared/wif.py
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
81
stm32/make_block_height.py
Normal file
81
stm32/make_block_height.py
Normal 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
|
||||
@ -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('.')]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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 bip32 import BIP32Node
|
||||
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,239 +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
|
||||
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:
|
||||
prev_tx = CTransaction()
|
||||
prev_tx.deserialize(BytesIO(psbt.inputs[idx].utxo))
|
||||
prev = prev_tx.vout[txin.prevout.n]
|
||||
prevouts.append((prev.nValue, prev.scriptPubKey))
|
||||
|
||||
# we have a key; use it to provide "plausible" value inputs
|
||||
mk = BIP32Node.from_wallet_key(master_xpub)
|
||||
mfp = mk.fingerprint()
|
||||
for idx, txin in enumerate(tx.vin):
|
||||
amount, spk = prevouts[idx]
|
||||
|
||||
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
|
||||
psbt.outputs = []
|
||||
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
|
||||
|
||||
for i, inp in enumerate(inputs):
|
||||
sp = f"0/{i}"
|
||||
af = addr_fmt
|
||||
ia = input_amount
|
||||
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]
|
||||
except:
|
||||
pass
|
||||
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
|
||||
|
||||
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:
|
||||
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 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"
|
||||
|
||||
|
||||
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):
|
||||
|
||||
msg_challenge = None
|
||||
|
||||
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)
|
||||
subkey = mk.subkey_for_path(sp)
|
||||
sec = subkey.sec()
|
||||
assert len(sec) == 33, "expect compressed"
|
||||
sec = mk.subkey_for_path(sp).sec()
|
||||
|
||||
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
|
||||
subkey = PublicKey.parse(sec)
|
||||
|
||||
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.hash160()
|
||||
assert len(sec) == 33, "expect compressed"
|
||||
|
||||
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])
|
||||
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 == "p2pkh":
|
||||
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack('<II', 0, i)
|
||||
scr = bytes([0x76, 0xa9, 0x14]) + subkey.hash160() + bytes([0x88, 0xac])
|
||||
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()
|
||||
|
||||
else:
|
||||
raise ValueError("unknown addr_fmt %s" % af)
|
||||
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])
|
||||
|
||||
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))
|
||||
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
|
||||
if sighash is not None:
|
||||
psbt.inputs[i].sighash = sighash
|
||||
|
||||
to_spend.calc_sha256()
|
||||
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 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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
96
testing/electrum.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
138
testing/psbt.py
138
testing/psbt.py
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
55
testing/sighash.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
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
|
||||
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,74 +649,88 @@ 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):
|
||||
@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,
|
||||
bip322_verify):
|
||||
|
||||
if b"\n" in msg and way == "qr":
|
||||
raise pytest.skip("QR code with newlines not supported")
|
||||
settings_remove("wifs")
|
||||
|
||||
node = BIP32Node.from_master_secret(os.urandom(32))
|
||||
|
||||
ins = []
|
||||
wifs = []
|
||||
for i in range(num_ins):
|
||||
n = node.subkey_for_path("0/%d" % i)
|
||||
wifs.append(n.node.private_key.wif(testnet=True))
|
||||
if i == 0:
|
||||
amt = None
|
||||
elif i // 2 == 0:
|
||||
amt = 10000000
|
||||
else:
|
||||
amt = 900000000
|
||||
|
||||
ins.append([addr_fmt, None, amt , n.node.private_key.K.sec()])
|
||||
|
||||
msg = b"Coinkite"
|
||||
psbt, msg_challenge = bip322_txn(ins, msg=msg)
|
||||
|
||||
import_wif_to_store(wifs)
|
||||
|
||||
menu = cap_menu()
|
||||
assert menu[0] == "Import WIF"
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
signed = end_sign()
|
||||
bip322_verify(signed)
|
||||
|
||||
|
||||
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):
|
||||
@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))
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
start_sign(psbt, finalize=True)
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert 'BIP-322' in title
|
||||
ins = [["p2wpkh", None, None]]
|
||||
if por:
|
||||
ins.append(["p2wpkh", None, 10000000])
|
||||
|
||||
need_keypress("0") # manual input
|
||||
# leave empty
|
||||
press_cancel()
|
||||
time.sleep(.1)
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
|
||||
start_sign(psbt)
|
||||
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()
|
||||
|
||||
# EOF
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user