diff --git a/docs/seed-xor.md b/docs/seed-xor.md index e2ac1615..719a32cf 100644 --- a/docs/seed-xor.md +++ b/docs/seed-xor.md @@ -22,7 +22,7 @@ This one more solution for your game-theory arsenal. - *Q*: I'm lazy, can I do this to my Existing Seed? - *A*: Yes. You can split the words you have already in your Coldcard, making - 2, 3 or 4 new SEEDPLATES. You could also any number of existing SEEDPLATES + 2, 3 or 4 new SEEDPLATES. You could also use any number of existing SEEDPLATES you have, and combine them to make a new random wallet that is the XOR of their values. Effectively that makes a new random wallet. diff --git a/docs/taproot.md b/docs/taproot.md index 62d1bc10..b3ed6f34 100644 --- a/docs/taproot.md +++ b/docs/taproot.md @@ -25,7 +25,7 @@ MUST be generated with above-mentoned methods to be considered change. ## Provably unspendable internal key -There are few methods to provide/generate provably unspendable internal key, if users wish to only use tapscript script path. +There are 2 methods to provide provably unspendable internal key, if users wish to only use tapscript script path. 1. **(recommended)** Origin-less extended key serialization with H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs) as BIP-32 key and random chaincode. @@ -35,6 +35,7 @@ There are few methods to provide/generate provably unspendable internal key, if `tr(unspend(77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76)/<0:1>/*, sortedmulti_a(2,@0,@1))` +### Below option were deprecated in version 6.3.5X & 6.3.5QX 3. use **static** provably unspendable internal key H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs). `tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0, sortedmulti_a(2,@0,@1))` diff --git a/external/micropython b/external/micropython index 97d35f05..4107246f 160000 --- a/external/micropython +++ b/external/micropython @@ -1 +1 @@ -Subproject commit 97d35f058f504a354fc6df79a8b3db5c91862501 +Subproject commit 4107246f8a080807b62c3b4838e71e812ea68b6f diff --git a/misc/binfonter/config.py b/misc/binfonter/config.py index e028da50..dbe190b3 100644 --- a/misc/binfonter/config.py +++ b/misc/binfonter/config.py @@ -74,4 +74,9 @@ special_chars = dict(small=[ x x x x x '''), +# thin space +('\u2009', dict(y=0, w=5), '''\ + +'''), + ]) diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index f9390be1..4f91647b 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -4,53 +4,64 @@ This lists the changes in the most recent firmware, for each hardware platform. # Shared Improvements - Both Mk4 and Q -- New Feature: Opt-in support for unsorted multisig, which ignores BIP-67 policy. Use - descriptor with `multi(...)`. Disabled by default, Enable in - `Settings > Multisig Wallets > Legacy Multisig`. Recommended for existing multisig - wallets, not new ones. -- New Feature: Named multisig descriptor imports. Wrap descriptor in json: - `{"name:"ms0", "desc":""}` to provide a name for the menu in `name`. - instead of the filename. Most useful for USB and NFC imports which have no filename, - (name is created from descriptor checksum in those cases). -- New Feature: XOR from Seed Vault (select other parts of the XOR from seeds in the vault). -- Enhancement: upgrade to latest - [libsecp256k1: 0.5.0](https://github.com/bitcoin-core/secp256k1/releases/tag/v0.5.0) -- Enhancement: Signature grinding optimizations. Now about 30% faster signing! -- Enhancement: Improve side-channel protection: libsecp256k1 context randomization now happens - before each signing session. -- Enhancement: Allow JSON files in `NFC File Share`. -- Change: Do not require descriptor checksum when importing multisig wallets. -- Bugfix: Do not allow import of multisig wallet when same keys are shuffled. -- Bugfix: Do not read whole PSBT into memory when writing finalized transaction (performance). -- Bugfix: Prevent user from restoring Seed XOR when number of parts is smaller than 2. -- Bugfix: Fix display alignment of Seed Vault menu. -- Bugfix: Properly handle null data in `OP_RETURN`. -- Bugfix: Do not allow lateral scroll in Address Explorer when showing single address - from custom path. -- Change: Remove Lamp Test from Debug Options (covered by selftest). +- New signing features: + - Sign message from note text, or password note + - JSON message signing. Use JSON object to pass data to sign in form + `{"msg":"","subpath":"","addr_fmt": ""}` + - Sign message with key resulting from positive ownership check. Press (0) and + enter or scan message text to be signed. + - Sign message with key selected from Address Explorer Custom Path menu. Press (2) and + enter or scan message text to be signed. +- Enhancement: New address display format improves address verification on screen (groups of 4). +- Deltamode enhancements: + - Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. + - Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. + - Catch more DeltaMode cases in XOR submenus. Thanks [@dmonakhov](https://github.com/dmonakhov) +- Enhancement: Add ability to switch between BIP-32 xpub, and obsolete SLIP-132 format + in `Export XPUB` +- Enhancement: Use the fact that master seed cannot be used as ephemeral seed, to show message + about successful master seed verification. +- Enhancement: Allow devs to override backup password. +- Enhancement: Add option to show/export full multisg addresses without censorship. Enable + in `Settings > Multisig Wallets > Full Address View`. +- Enhancement: If derivation path is omitted during message signing, derivation path + default is no longer root (m), instead it is based on requested address format + (`m/44h/0h/0h/0/0` for p2pkh, and `m/84h/0h/0h/0/0` for p2wpkh). Conversely, + if address format is not provided but subpath derivation starts with: + `m/84h/...` or `m/49h/...`, then p2wpkh or p2sh-p2wpkh respectively, is used. +- Bugfix: Sometimes see a struck screen after _Verifying..._ in boot up sequence. + On Q, result is blank screen, on Mk4, result is three-dots screen. +- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode. +- Bugfix: Bless Firmware causes hanging progress bar. +- Bugfix: Prevent yikes in ownership search. +- Bugfix: Factory-disabled NFC was not recognized correctly. +- Bugfix: Be more robust about flash filesystem holding the settings. +- Bugfix: Do not include sighash in PSBT input data, if sighash value is `SIGHASH_ALL`. +- Bugfix: Allow import of multisig descriptor with root (m) keys in it. + Thanks [@turkycat](https://github.com/turkycat) +- Change: Do not purge settings of current active tmp seed when deleting it from Seed Vault. +- Change: Rename Testnet3 -> Testnet4 (all parameters unchanged). + # Mk4 Specific Changes -## 5.4.0 - 2024-09-12 +## 5.4.1 - 2024-02-13 -- Shared enhancements and fixes listed above. -- Bugfix: Correct intermittent card inserted/not inserted detection error. +- Enhancement: Export single sig descriptor with simple QR. # Q Specific Changes -## 1.3.0Q - 2024-09-12 +## 1.3.1Q - 2024-02-13 -- New Feature: Seed XOR can be imported by scanning SeedQR parts. -- New Feature: Input backup password from QR scan. -- New Feature: (BB)QR file share of arbitrary files. -- New Feature: `Create Airgapped` now works with BBQRs. -- Change: Default brightness (on battery) adjusted from 80% to 95%. -- Bugfix: Properly clear LCD screen after BBQR is shown. -- Bugfix: Writing to empty slot B caused broken card reader. -- Bugfix: During Seed XOR import, display correct letter B if own seed already added to the mix. -- Bugfix: Stop re-wording UX stories using a regular expression. -- Bugfix: Fixed "easy exit" from quiz after split Seed XOR. +- New Feature: Verify Signed RFC messages via BBQr +- New Feature: Sign message from QR scan (format has to be JSON) +- Enhancement: Sign/Verify Address in Sparrow via QR +- Enhancement: Sign scanned Simple Text by pressing (0). Next screen query information + about which key to use. +- Enhancement: Add option to "Sort By Title" in Secure Notes and Passwords. Thanks to + [@MTRitchey](https://x.com/MTRitchey) for suggestion. +- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md index 001e221a..a2fbf21a 100644 --- a/releases/EdgeChangeLog.md +++ b/releases/EdgeChangeLog.md @@ -6,38 +6,28 @@ - This preview version of firmware has not yet been qualified - and tested to the same standard as normal Coinkite products. - It is recommended only for developers and early adopters -- for experimental use. DO NOT use for large Bitcoin amounts. +- for experimental use. ``` This lists the changes in the most recent EDGE firmware, for each hardware platform. # Shared Improvements - Both Mk4 and Q -- Bugfix: Complex miniscript wallets with keys in policy that are not in strictly ascending order were incorrectly filled - upon load from settings. All users on versions `6.2.2X`+ needs to update. -- Bugfix: Single key miniscript descriptor support -- Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. -- Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. -- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode -- Bugfix: Bless Firmware causes hanging progress bar -- Bugfix: Prevent yikes in ownership search -- Change: Do not allow to purge settings of current active tmp seed when deleting it from Seed Vault - +Change: Allow origin-less extended keys in multisig & miniscript descriptors +Change: Static internal keys disallowed - all keys need to be ranged extended keys # Mk4 Specific Changes -## 6.3.4X - 2024-07-04 +## 6.3.5X - 2024-07-04 -- all updates from `5.4.0` -- Enhancement: Export single sig descriptor with simple QR +- all updates from `5.4.1` # Q Specific Changes -## 6.3.4QX - 2024-07-04 +## 6.3.5QX - 2024-07-04 -- all updates from version `1.3.0Q` -- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. +- all updates from version `1.3.1Q` # Release History diff --git a/releases/History-Edge.md b/releases/History-Edge.md index 810f9c0f..e8e42ff8 100644 --- a/releases/History-Edge.md +++ b/releases/History-Edge.md @@ -7,6 +7,30 @@ - for experimental use. DO NOT use for large Bitcoin amounts. ``` + +# 6.3.4X & 6.3.4QX Shared Improvements - Both Mk4 and Q + +- Bugfix: Complex miniscript wallets with keys in policy that are not in strictly ascending order were incorrectly filled + upon load from settings. All users on versions `6.2.2X`+ needs to update. +- Bugfix: Single key miniscript descriptor support +- Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. +- Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. +- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode +- Bugfix: Bless Firmware causes hanging progress bar +- Bugfix: Prevent yikes in ownership search +- Change: Do not allow to purge settings of current active tmp seed when deleting it from Seed Vault + +# Mk4 Specific Changes + +- all updates from `5.4.0` +- Enhancement: Export single sig descriptor with simple QR + +# Q Specific Changes + +- all updates from version `1.3.0Q` +- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. + + ## 6.3.3X & 6.3.3QX Shared Improvements - Both Mk4 and Q (2024-07-04) - New Feature: Ranged provably unspendable keys and `unspend(` support for Taproot descriptors diff --git a/releases/History-Mk4.md b/releases/History-Mk4.md index e1a65346..dfd7e37c 100644 --- a/releases/History-Mk4.md +++ b/releases/History-Mk4.md @@ -1,5 +1,34 @@ *See ChangeLog.md for more recent changes, these are historic versions* +## 5.4.0 - 2024-09-12 + +- New Feature: Opt-in support for unsorted multisig, which ignores BIP-67 policy. Use + descriptor with `multi(...)`. Disabled by default, Enable in + `Settings > Multisig Wallets > Legacy Multisig`. Recommended for existing multisig + wallets, not new ones. +- New Feature: Named multisig descriptor imports. Wrap descriptor in json: + `{"name:"ms0", "desc":""}` to provide a name for the menu in `name`. + instead of the filename. Most useful for USB and NFC imports which have no filename, + (name is created from descriptor checksum in those cases). +- New Feature: XOR from Seed Vault (select other parts of the XOR from seeds in the vault). +- Enhancement: upgrade to latest + [libsecp256k1: 0.5.0](https://github.com/bitcoin-core/secp256k1/releases/tag/v0.5.0) +- Enhancement: Signature grinding optimizations. Now about 30% faster signing! +- Enhancement: Improve side-channel protection: libsecp256k1 context randomization now happens + before each signing session. +- Enhancement: Allow JSON files in `NFC File Share`. +- Change: Do not require descriptor checksum when importing multisig wallets. +- Bugfix: Do not allow import of multisig wallet when same keys are shuffled. +- Bugfix: Do not read whole PSBT into memory when writing finalized transaction (performance). +- Bugfix: Prevent user from restoring Seed XOR when number of parts is smaller than 2. +- Bugfix: Fix display alignment of Seed Vault menu. +- Bugfix: Properly handle null data in `OP_RETURN`. +- Bugfix: Do not allow lateral scroll in Address Explorer when showing single address + from custom path. +- Change: Remove Lamp Test from Debug Options (covered by selftest). +- Shared enhancements and fixes listed above. +- Bugfix: Correct intermittent card inserted/not inserted detection error. + ## 5.3.3 - 2024-07-05 diff --git a/releases/History-Q.md b/releases/History-Q.md index 6553be86..a90cea0b 100644 --- a/releases/History-Q.md +++ b/releases/History-Q.md @@ -1,6 +1,44 @@ *See ChangeLog.md for more recent changes, these are historic versions* +## 1.3.0Q - 2024-09-12 + +- New Feature: Opt-in support for unsorted multisig, which ignores BIP-67 policy. Use + descriptor with `multi(...)`. Disabled by default, Enable in + `Settings > Multisig Wallets > Legacy Multisig`. Recommended for existing multisig + wallets, not new ones. +- New Feature: Named multisig descriptor imports. Wrap descriptor in json: + `{"name:"ms0", "desc":""}` to provide a name for the menu in `name`. + instead of the filename. Most useful for USB and NFC imports which have no filename, + (name is created from descriptor checksum in those cases). +- New Feature: XOR from Seed Vault (select other parts of the XOR from seeds in the vault). +- Enhancement: upgrade to latest + [libsecp256k1: 0.5.0](https://github.com/bitcoin-core/secp256k1/releases/tag/v0.5.0) +- Enhancement: Signature grinding optimizations. Now about 30% faster signing! +- Enhancement: Improve side-channel protection: libsecp256k1 context randomization now happens + before each signing session. +- Enhancement: Allow JSON files in `NFC File Share`. +- Change: Do not require descriptor checksum when importing multisig wallets. +- Bugfix: Do not allow import of multisig wallet when same keys are shuffled. +- Bugfix: Do not read whole PSBT into memory when writing finalized transaction (performance). +- Bugfix: Prevent user from restoring Seed XOR when number of parts is smaller than 2. +- Bugfix: Fix display alignment of Seed Vault menu. +- Bugfix: Properly handle null data in `OP_RETURN`. +- Bugfix: Do not allow lateral scroll in Address Explorer when showing single address + from custom path. +- Change: Remove Lamp Test from Debug Options (covered by selftest). +- New Feature: Seed XOR can be imported by scanning SeedQR parts. +- New Feature: Input backup password from QR scan. +- New Feature: (BB)QR file share of arbitrary files. +- New Feature: `Create Airgapped` now works with BBQRs. +- Change: Default brightness (on battery) adjusted from 80% to 95%. +- Bugfix: Properly clear LCD screen after BBQR is shown. +- Bugfix: Writing to empty slot B caused broken card reader. +- Bugfix: During Seed XOR import, display correct letter B if own seed already added to the mix. +- Bugfix: Stop re-wording UX stories using a regular expression. +- Bugfix: Fixed "easy exit" from quiz after split Seed XOR. + + ## 1.2.3Q - 2024-07-05 - New Feature: PushTX: once enabled with a service provider's URL, you can tap the COLDCARD diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index d70720c1..b79bd3f0 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -4,25 +4,18 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q -- Enhancement: Hide Secure Notes & Passwords in Deltamode. Wipe seed if notes menu accessed. -- Enhancement: Hide Seed Vault in Deltamode. Wipe seed if Seed Vault menu accessed. -- Bugfix: Sometimes see a struck screen after _Verifying..._ in boot up sequence. - On Q, result is blank screen, on Mk4, result is three-dots screen. -- Bugfix: Do not allow to enable/disable Seed Vault feature when in temporary seed mode -- Bugfix: Bless Firmware causes hanging progress bar -- Bugfix: Prevent yikes in ownership search -- Change: Do not allow to purge settings of current active tmp seed when deleting it from Seed Vault +- tbd # Mk4 Specific Changes -## 5.4.1 - 2024-??-?? +## 5.4.2 - 2024-03-?? -- Enhancement: Export single sig descriptor with simple QR +- tbd # Q Specific Changes -## 1.3.1Q - 2024-??-?? +## 1.3.2Q - 2024-03-?? -- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. +- tbd diff --git a/releases/signatures.txt b/releases/signatures.txt index 6c697e42..14424b24 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -2,11 +2,19 @@ Hash: SHA256 95eff9e044cdb6b3d00961ae72d450684d5441c6a3661ab550a3c3aa0882e754 README.md -97107b5be1c8b65efa4bd36b7d1798e4ed15917861bd2d40784d66302a61d335 Next-ChangeLog.md -f6d8a1edf0993cdecea7cdc34f48ce344f249ec0fc2d28fbc4da9ebc163c6148 History-Q.md -3e98b0f292b30460e128c3d41e9dd33428524516ce433fe4a3b99132025ca64c History-Mk4.md +006898c20edc46bc642e3bd3b54ee72c22e858a564a31de6d1e90245fa86ff6d Next-ChangeLog.md +b6015f2f807bc78b6063ed6c12a12a47579a81a68f954cfc2e542e7ac6c02c0e History-Q.md +05228d2c59135c3fe251d877b519bec65f929ecf0aac8b727622359014236568 History-Mk4.md c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md -7c06aa1d5168e02d928da087f13c74b94e40f52e5eb281af21edcfdf6cabe5ce ChangeLog.md +45cd0478996bb9da77075846122b8ba732b9b34dbbae0d12cb85ad0d931d40fc ChangeLog.md +eb750a4f095eacc6133b2c8b38fe0738a22b2496a6cdf423ca865acde8c9bc4e 2025-02-13T1415-v5.4.1-mk4-coldcard.dfu +4236453fea241fe044a462a560d8b42df43e560683110306a2714a2ef561eac5 2025-02-13T1415-v5.4.1-mk4-coldcard-factory.dfu +2e1aad0a7a3ceb84db34322b54855a0c5496699e46e53606bfa443fcc992adec 2025-02-13T1413-v1.3.1Q-q1-coldcard.dfu +e43932d04bf782f7b9ba218b54f29b9cd361b83ac3aadff9722714bca1ab7ee9 2025-02-13T1413-v1.3.1Q-q1-coldcard-factory.dfu +681874256bcfca71a3908f1dd6c623804517fdba99a51ed04c73b96119650c13 2024-12-18T1413-v6.3.4X-mk4-coldcard.dfu +73f31fbcb064a6b763d50852aafcdff01d7ec72906b5cb0af6cf28328fd80a89 2024-12-18T1413-v6.3.4X-mk4-coldcard-factory.dfu +93ab7615bcedeeff123498c109e5859dae28e58885e29ed86b6f3fd6ba709cce 2024-12-18T1407-v6.3.4QX-q1-coldcard.dfu +7e284bcead1f9c2f468230a588ddf62064014682772a552d05f453d91d55b6ae 2024-12-18T1407-v6.3.4QX-q1-coldcard-factory.dfu 237cfcb3fdf9217550eae1d9ea6fc828c1c8d09470bd60c9f72f9b00a3bb2d11 2024-09-12T1734-v5.4.0-mk4-coldcard.dfu 6d1178f07d543e1777dbbdca41d872b00ca9c40e0c0c1ffb8ef96e19c51daa52 2024-09-12T1734-v5.4.0-mk4-coldcard-factory.dfu d840fa4e83ebc7b0f961f30f68d795bed61271e2314dda4ab0eb0b8bfe7192f4 2024-09-12T1733-v1.3.0Q-q1-coldcard.dfu @@ -94,12 +102,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmbjJicACgkQo6MbrVoq -WxAnMwf/e2kR1aK6AJiriRa1n3XDomw8ivaUQXUApmK0kawBhVBDLKw5aa3lvTcS -dg80wnenzNdE/QxctL+FkaZzKYsKbFpstkBEbZKcgbHVcinypKJJfICrhIBVVyZw -wdhJMGOLEyWMysqfaYMtYJQPkg5nIn0rRxn4yWXIeXAQLcFgdlWzVykqfGZW1xYr -CcVvxMqufXfc6c5aRFQzBO/YVHiRYzvK1NGDPztJEjXYU3zxnExAZFxk0vgpxvE3 -CahKfSSTNv54u4CTLxYCdHPRq9OM6yL/w3OUyUQFklCizk2PjrObsJQW4szbbjlx -r7+587Pc5cpJCZn73Q0Y5/SWgnqm4g== -=/h9F +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmet/nAACgkQo6MbrVoq +WxC0Pwf/Tguk32raFEq/k0Ai0XYJE8wUs89Wy7V9JRc2gmYPSF39Qv+SePE0Cajn +AkFDepAFrxjF8sanNqR1g0RmXltncmJCOlDf/CmOt0MxeL3r1jxTeXuCpOH5qHcF +QBsWMjA39kv5DtZ4g6j6qXEDfiHQVBSDujK6Xgk6Gj9STGglJZVmwnYWuhMw/7MC +qw5MQ3IsJEXBu9G2eTqH4SEdPdgbmv1Zo/9OKLe7uXKcUo1BWL7jCBONxW1fAAkd +8YMhOAhhv99/B015LZjz0V1aPo2eMQqAq9NMNzCCEwN+RvwckkvyO0l5iIfSozdN +FWZT4Wr6NZcI0F5kLKjnJTCBzHQvLQ== +=/XFv -----END PGP SIGNATURE----- diff --git a/shared/actions.py b/shared/actions.py index 17926788..bd0aef81 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -4,14 +4,14 @@ # # Every function here is called directly by a menu item. They should all be async. # -import ckcc, pyb, version, uasyncio, sys, uos +import ckcc, pyb, version, uasyncio, sys, uos, chains from uhashlib import sha256 from uasyncio import sleep_ms from ubinascii import hexlify as b2a_hex from utils import imported, problem_file_line, get_filesize, encode_seed_qr -from utils import xfp2str, B2A, addr_fmt_label, txid_from_fname +from utils import xfp2str, B2A, txid_from_fname from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted -from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X +from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words from export import make_json_wallet, make_summary_file, make_descriptor_wallet_export from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export from export import generate_unchained_export, generate_electrum_wallet @@ -579,7 +579,7 @@ async def clear_seed(*a): if not await ux_confirm('Wipe seed words and reset wallet. ' 'All funds will be lost. ' - 'You better have a backup of the seed words.' + 'You better have a backup of the seed words. ' 'All settings like multisig wallets are also wiped. ' 'Saved temporary seed settings and Seed Vault are lost.'): return await ux_aborted() @@ -603,7 +603,6 @@ consequences.''', escape='4') def render_master_secrets(mode, raw, node): # Render list of words, or XPRV / master secret to text. import stash, chains - from ux import ux_render_words c = chains.current_chain() qr_alnum = False @@ -874,12 +873,10 @@ async def start_login_sequence(): # Version warning before HSM is offered if version.is_edge and not ckcc.is_simulator(): - await ux_show_story( - "This firmware version is qualified for use with wallets (such as - AnchorWatch, Liana, etc) that keep redundant key schemas for recovery - independant of COLDCARD. We support the very latest Bitcoin innovations - in the Edge Version." - title="Edge Version") + await ux_show_story("This firmware version is qualified for use with wallets (such as" + " AnchorWatch) that keep redundant key schemas for recovery" + " independent of COLDCARD. We support the very latest Bitcoin innovations" + " in the Edge Version.", title="Edge Version") dis.draw_status(xfp=settings.get('xfp')) @@ -1018,6 +1015,7 @@ async def export_xpub(label, _2, item): chain = chains.current_chain() acct = 0 + slip132 = False # non-slip is default from Oct 2024 # decode menu code => standard derivation mode = item.arg @@ -1033,24 +1031,44 @@ async def export_xpub(label, _2, item): else: remap = {44:0, 49:1, 84:2,86:3}[mode] _, path, addr_fmt = chains.CommonDerivations[remap] - path = path.format(account='{acct}', coin_type=chain.b44_cointype, change=0, idx=0)[:-4] - - # always show SLIP-132 style, because defacto - show_slip132 = (addr_fmt != AF_CLASSIC) + path = path.format(account=acct, coin_type=chain.b44_cointype, + change=0, idx=0)[:-4] while 1: - msg = '''Show QR of the XPUB for path:\n\n%s\n\n''' % path + msg = 'Show QR of the XPUB for path:\n\n%s\n\n' % path + esc = "" + if path != "m": + esc += "1" + msg += "Press (1) to select account other than %s." % (acct or "zero") + if addr_fmt not in (AF_CLASSIC, AF_P2TR): + esc += "2" + slp_af = addr_fmt + if slip132: + slp_af = AF_CLASSIC - if '{acct}' in path: - msg += "Press (1) to select account other than zero. " + slp = chain.slip132[slp_af].hint + "pub" + msg += " Press (2) to show %s %s." % ( + slp, "(BIP-32)" if slip132 else "(SLIP-132)" + ) if glob.NFC: - msg += "Press %s to share via NFC. " % (KEY_NFC if version.has_qwerty else "(3)") + if version.has_qwerty: + esc += KEY_NFC + key_hint = KEY_NFC + else: + esc += "3" + key_hint = "(3)" + msg += " Press %s to share via NFC. " % key_hint - ch = await ux_show_story(msg, escape='13') + ch = await ux_show_story(msg, escape=esc) if ch == 'x': return + if ch == "2": + slip132 = not slip132 + continue if ch == '1': acct = await ux_enter_bip32_index('Account Number:') or 0 - path = path.format(acct=acct) + pth_split = path.split("/") + pth_split[-1] = ("%dh" % acct) + path = "/".join(pth_split) continue # assume zero account if not picked @@ -1062,7 +1080,7 @@ async def export_xpub(label, _2, item): # render xpub/ypub/zpub with stash.SensitiveValues() as sv: node = sv.derive_path(path) if path != 'm' else sv.node - xpub = chain.serialize_public(node, addr_fmt) + xpub = chain.serialize_public(node, addr_fmt if slip132 else AF_CLASSIC) from ownership import OWNERSHIP OWNERSHIP.note_wallet_used(addr_fmt, acct) @@ -1072,8 +1090,6 @@ async def export_xpub(label, _2, item): else: await show_qr_code(xpub, False) - break - def electrum_export_story(background=False): # saves memory being in a function @@ -1096,9 +1112,9 @@ async def electrum_skeleton(*a): return rv = [ - MenuItem(addr_fmt_label(af), f=electrum_skeleton_step2, + MenuItem(chains.addr_fmt_label(af), f=electrum_skeleton_step2, arg=(af, account_num)) - for af in [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH] + for af in chains.SINGLESIG_AF ] the_ux.push(MenuSystem(rv)) @@ -1112,7 +1128,7 @@ def ss_descriptor_export_story(addition="", background="", acct=True): async def ss_descriptor_skeleton(_0, _1, item): # Export of descriptor data (wallet) int_ext, addition, f_pattern = None, "", "descriptor.txt" - allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR] + allowed_af = chains.SINGLESIG_AF if item.arg: int_ext, allowed_af, ll, f_pattern = item.arg addition = " for " + ll @@ -1139,7 +1155,7 @@ async def ss_descriptor_skeleton(_0, _1, item): fname_pattern=f_pattern) else: rv = [ - MenuItem(addr_fmt_label(af), f=descriptor_skeleton_step2, + MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2, arg=(af, account_num, int_ext, f_pattern)) for af in allowed_af ] @@ -1405,6 +1421,63 @@ async def restore_everything_cleartext(*A): if prob: await ux_show_story(prob, title='FAILED') +async def bkpw_override(*A): + # allows user to: + # 1.) manually set bkpw + # 2.) remove existing bkpw setting + # 3.) view current active bkpw + # - some truncation of titles here on Mk4, + # which is okay because re-using strings to save space. + from backups import bkpw_min_len + + if pa.is_secret_blank(): + return + + if pa.is_deltamode(): + import callgate + callgate.fast_wipe() + + while True: + pwd = settings.get("bkpw", None) + + msg = ("Password used to encrypt COLDCARD backup files." + "\n\nPress (0) to change backup password") + esc = "0" + if pwd: + esc += "12" + msg += ", (1) to forget current password, (2) to show current active backup password." + else: + msg += "." + + ch = await ux_show_story(title="BKPW Override", msg=msg, escape=esc) + if ch == "x": return + elif ch == "1": + if await ux_confirm("Delete current stored password?"): + settings.remove_key("bkpw") + settings.save() + await ux_dramatic_pause("Deleted.", 2) + + elif ch == "2": + if await ux_confirm('The next screen will show current active backup password.' + '\n\nAnyone with knowledge of the password will ' + 'be able to decrypt your backups.'): + await ux_show_story(pwd, title="Your Backup Password") + + elif ch == "0": + if version.has_qwerty: + from notes import get_a_password + npwd = await get_a_password(pwd, min_len=bkpw_min_len) + else: + npwd = await ux_input_text(pwd, prompt="Your Backup Password", + min_len=bkpw_min_len, max_len=128) + + if (npwd is None) or (npwd == pwd): continue + + settings.set('bkpw', npwd) + settings.save() + await ux_dramatic_pause("Saved.", 2) + + async def wipe_filesystem(*A): if not await ux_confirm('''\ Erase internal filesystem and rebuild it. Resets contents of internal flash area \ @@ -1433,11 +1506,13 @@ Erases and reformats MicroSD card. This is not a secure erase but more of a quic wipe_microsd_card() -async def qr_share_file(*A): +async def qr_share_file(_1, _2, item): # Pick file from SD card and share as (BB)Qr from files import CardSlot, CardMissingError, needs_microsd from export import export_by_qr + force_bbqr = item.arg + def is_suitable(fname): f = fname.lower() return f.endswith('.psbt') or f.endswith('.txn') \ @@ -1482,7 +1557,7 @@ async def qr_share_file(*A): else: raise ValueError(ext) - await export_by_qr(data, txid, tc) + await export_by_qr(data, txid, tc, force_bbqr=force_bbqr) async def nfc_share_file(*A): @@ -1880,10 +1955,11 @@ async def sign_message_on_sd(*a): # min 1 line max 3 lines return 1 <= len(lines) <= 3 - fn = await file_picker(suffix='txt', min_size=2, max_size=500, taster=is_signable, - none_msg=('Must be one line of text, optionally ' + fn = await file_picker(suffix=['txt', "json"], min_size=2, max_size=500, taster=is_signable, + none_msg=('Must be txt file with one msg line, optionally ' 'followed by a subkey derivation path on a second line ' - 'and/or address format on third line.')) + 'and/or address format on third line. JSON msg signing ' + 'format also supported')) if not fn: return @@ -2191,14 +2267,11 @@ async def change_virtdisk_enable(enable): async def change_seed_vault(is_enabled): # user has changed seed vault enable/disable flag - from glob import settings - if (not is_enabled) and settings.master_get('seeds'): # problem: they still have some seeds... also this path blocks # disable from within a tmp seed settings.set('seedvault', 1) # restore it await ux_show_story("Please remove all seeds from the vault before disabling.") - return goto_top_menu() diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 2e765839..2739a375 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -8,17 +8,27 @@ import chains, stash, version from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem -from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR +from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH, AF_P2TR from multisig import MultisigWallet from miniscript import MiniScriptWallet from uasyncio import sleep_ms from uhashlib import sha256 from glob import settings from auth import write_sig_file -from utils import addr_fmt_label, truncate_address from charcodes import KEY_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT from charcodes import KEY_CANCEL +from utils import show_single_address, problem_file_line +def truncate_address(addr): + # Truncates address to width of screen, replacing middle chars + if not version.has_qwerty: + # - 16 chars screen width + # - but 2 lost at left (menu arrow, corner arrow) + # - want to show not truncated on right side + return addr[0:6] + '⋯' + addr[-6:] + else: + # tons of space on Q1 + return addr[0:12] + '⋯' + addr[-12:] class KeypathMenu(MenuSystem): def __init__(self, path=None, nl=0): @@ -103,8 +113,8 @@ class PickAddrFmtMenu(MenuSystem): def __init__(self, path, parent): self.parent = parent items = [ - MenuItem(addr_fmt_label(af), f=self.done, arg=(path, af)) - for af in [AF_CLASSIC, AF_P2WPKH, AF_P2TR, AF_P2WPKH_P2SH] + MenuItem(chains.addr_fmt_label(af), f=self.done, arg=(path, af)) + for af in chains.SINGLESIG_AF ] super().__init__(items) if path.startswith("m/84h"): @@ -169,8 +179,7 @@ class AddressListMenu(MenuSystem): # Create list of choices (address_index_0, path, addr_fmt) choices = [] for name, path, addr_fmt in chains.CommonDerivations: - if '{coin_type}' in path: - path = path.replace('{coin_type}', str(chain.b44_cointype)) + path = path.replace('{coin_type}', str(chain.b44_cointype)) if self.account_num != 0 and '{account}' not in path: # skip derivations that are not affected by account number @@ -179,7 +188,7 @@ class AddressListMenu(MenuSystem): deriv = path.format(account=self.account_num, change=0, idx=self.start) node = sv.derive_path(deriv, register=False) address = chain.address(node, addr_fmt) - choices.append( (truncate_address(address), path, addr_fmt) ) + choices.append((truncate_address(address), path, addr_fmt)) dis.progress_sofar(len(choices), len(chains.CommonDerivations)) @@ -189,7 +198,7 @@ class AddressListMenu(MenuSystem): indent = ' ↳ ' if version.has_qwerty else '↳' for i, (address, path, addr_fmt) in enumerate(choices): axi = address[-4:] # last 4 address characters - items.append(MenuItem(addr_fmt_label(addr_fmt), f=self.pick_single, + items.append(MenuItem(chains.addr_fmt_label(addr_fmt), f=self.pick_single, arg=(path, addr_fmt, axi))) items.append(MenuItem(indent+address, f=self.pick_single, arg=(path, addr_fmt, axi))) @@ -298,7 +307,7 @@ Press (3) if you really understand and accept these risks. for idx, addr, deriv in main.yield_addresses(start, n, change if allow_change else None): addrs.append(addr) - msg += "%s =>\n%s\n\n" % (deriv, addr) + msg += "%s =>\n%s\n\n" % (deriv, show_single_address(addr)) dis.progress_sofar(idx-start+1, n or 1) # export options @@ -317,6 +326,10 @@ Press (3) if you really understand and accept these risks. msg += '\n\n' if n: msg += "Press RIGHT to see next group, LEFT to go back. X to quit." + else: + if addr_fmt != AF_P2TR: + escape += "0" + msg += " Press (0) to sign message with this key." return msg, addrs, escape @@ -342,10 +355,10 @@ Press (3) if you really understand and accept these risks. # continue on same screen in case they want to write to multiple cards elif choice == KEY_QR: - # switch into a mode that shows them as QR codes from ux import show_qr_codes + addr_fmt = addr_fmt or ms_wallet.addr_fmt is_alnum = bool(addr_fmt & (AFC_BECH32 | AFC_BECH32M)) - await show_qr_codes(addrs, is_alnum, start) + await show_qr_codes(addrs, is_alnum, start, is_addrs=True) continue elif NFC and (choice == KEY_NFC): @@ -357,8 +370,15 @@ Press (3) if you really understand and accept these risks. continue - elif choice == '0' and allow_change: - change = 1 + elif choice == '0': + if allow_change: + change = 1 + else: + # only custom path sets allow_change to False + # msg sign + from auth import sign_with_own_address + await sign_with_own_address(path, addr_fmt) + elif n is None: # makes no sense to do any of below, showing just single address continue @@ -462,7 +482,6 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num, await needs_microsd() return except Exception as e: - from utils import problem_file_line await ux_show_story('Failed to write!\n\n\n%s\n%s' % (e, problem_file_line(e))) return diff --git a/shared/auth.py b/shared/auth.py index 721f721f..39d2f8dd 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -13,12 +13,12 @@ from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED from sffile import SFFile from ux import ux_aborted, ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys -from ux import show_qr_code, OK, X +from ux import show_qr_code, OK, X, ux_input_text, ux_enter_bip32_index from usb import CCBusyError from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path -from utils import B2A, parse_addr_fmt_str, to_ascii_printable +from utils import B2A, to_ascii_printable, show_single_address from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput -from files import CardSlot +from files import CardSlot, CardMissingError, needs_microsd from exceptions import HSMDenied from version import MAX_TXN_LEN from charcodes import KEY_QR, KEY_NFC, KEY_ENTER, KEY_CANCEL, KEY_LEFT, KEY_RIGHT @@ -282,14 +282,13 @@ def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_ dis.progress_bar_show(i / 6) return sig_nice -def validate_text_for_signing(text): +def validate_text_for_signing(text, only_printable=True): # 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 - MSG_MAX_SPACES = 4 # impt. compared to -=- positioning - - result = to_ascii_printable(text) + result = to_ascii_printable(text, only_printable=only_printable) length = len(result) assert length >= 2, "msg too short (min. 2)" @@ -302,12 +301,80 @@ def validate_text_for_signing(text): # looks ok return result +def addr_fmt_from_subpath(subpath): + if not subpath: + af = "p2pkh" + elif subpath[:4] == "m/84": + af = "p2wpkh" + elif subpath[:4] == "m/49": + af = "p2sh-p2wpkh" + else: + af = "p2pkh" + return af + +def parse_msg_sign_request(data): + subpath = "" + addr_fmt = None + is_json = False + + # sparrow compat + if "signmessage" in data: + try: + mark, subpath, *msg_line = data.split(" ", 2) + assert mark == "signmessage" + # subpath will be verified & cleaned later + assert msg_line[0][:6] == "ascii:" + text = msg_line[0][6:] + return text, subpath, addr_fmt_from_subpath(subpath), is_json + except:pass + # === + + try: + data_dict = ujson.loads(data.strip()) + text = data_dict.get("msg", None) + if text is None: + raise AssertionError("MSG required") + subpath = data_dict.get("subpath", subpath) + addr_fmt = data_dict.get("addr_fmt", addr_fmt) + is_json = True + except ValueError: + lines = data.split("\n") + assert len(lines) >= 1, "min 1 line" + assert len(lines) <= 3, "max 3 lines" + + if len(lines) == 1: + text = lines[0] + elif len(lines) == 2: + text, subpath = lines + else: + text, subpath, addr_fmt = lines + + if not addr_fmt: + 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 + ) + + return text, subpath, addr_fmt, is_json + + class ApproveMessageSign(UserAuthorizedAction): - def __init__(self, text, subpath, addr_fmt, approved_cb=None): + def __init__(self, text, subpath, addr_fmt, approved_cb=None, + msg_sign_request=None, only_printable=True): super().__init__() - self.text = validate_text_for_signing(text) + is_json = False + if msg_sign_request: + 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 + ) self.subpath = cleanup_deriv_path(subpath) - self.addr_fmt = parse_addr_fmt_str(addr_fmt) + self.addr_fmt = chains.parse_addr_fmt_str(addr_fmt) self.approved_cb = approved_cb # temporary - no p2tr support @@ -330,14 +397,14 @@ class ApproveMessageSign(UserAuthorizedAction): if hsm_active: ch = await hsm_active.approve_msg_sign(self.text, self.address, self.subpath) else: - story = MSG_SIG_TEMPLATE.format(msg=self.text, addr=self.address, subpath=self.subpath) + story = MSG_SIG_TEMPLATE.format(msg=self.text, addr=show_single_address(self.address), + subpath=self.subpath) ch = await ux_show_story(story) if ch != 'y': # they don't want to! self.refused = True else: - # perform signing (progress bar shown) digest = chains.current_chain().hash_message(self.text.encode()) self.result = sign_message_digest(digest, self.subpath, "Signing...", self.addr_fmt)[0] @@ -355,36 +422,166 @@ class ApproveMessageSign(UserAuthorizedAction): def sign_msg(text, subpath, addr_fmt): - subpath = cleanup_deriv_path(subpath) UserAuthorizedAction.check_busy() UserAuthorizedAction.active_request = ApproveMessageSign(text, subpath, addr_fmt) # kill any menu stack, and put our thing at the top abort_and_goto(UserAuthorizedAction.active_request) +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 + 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 + return "m/%dh/%dh/%dh/%d/%d" % (purpose, chain_n, acct, change, idx) + + +async def ux_sign_msg(txt, approved_cb=None, kill_menu=True): + from menu import MenuSystem, MenuItem + from ux import the_ux + + async def done(_1, _2, item): + from auth import approve_msg_sign, msg_sign_ux_get_subpath + + text, af = item.arg + subpath = await msg_sign_ux_get_subpath(af) + + await approve_msg_sign(text, subpath, af, approved_cb=approved_cb, + kill_menu=kill_menu, only_printable=False) + + # pick address format + rv = [ + MenuItem(chains.addr_fmt_label(af), f=done, arg=(txt, af)) + for af in (AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH) # cannot use SINGLE_AF here as it contains taproot + ] + the_ux.push(MenuSystem(rv)) + + +async def approve_msg_sign(text, subpath, addr_fmt, approved_cb=None, + msg_sign_request=None, kill_menu=False, + only_printable=True): + UserAuthorizedAction.cleanup() + UserAuthorizedAction.check_busy(ApproveMessageSign) + try: + UserAuthorizedAction.active_request = ApproveMessageSign( + text, subpath, addr_fmt, + approved_cb=approved_cb, + msg_sign_request=msg_sign_request, + only_printable=only_printable, + ) + if kill_menu: + abort_and_goto(UserAuthorizedAction.active_request) + else: + # do not kill the menu stack! just append + from ux import the_ux + the_ux.push(UserAuthorizedAction.active_request) + except (AssertionError, ValueError) as exc: + await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) + return + + +async def msg_signing_done(signature, address, text): + from ux import import_export_prompt + + ch = await import_export_prompt("Signed Msg", is_import=False, + no_qr=not version.has_qwerty) + if ch == KEY_CANCEL: + return + + if isinstance(ch, dict): + await sd_sign_msg_done(signature, address, text, "msg_sign", **ch) + elif version.has_qr and ch == KEY_QR: + from ux_q1 import qr_msg_sign_done + await qr_msg_sign_done(signature, address, text) + elif ch in KEY_NFC+"3": + from glob import NFC + if NFC: + await NFC.msg_sign_done(signature, address, text) + + +async def sign_with_own_address(subpath, addr_fmt): + # used for cases where we already have the key picked, but need the message: + # * address_explorer custom path + # * positive ownership test + from glob import dis + + to_sign = await ux_input_text("", scan_ok=True, prompt="Enter MSG") # max len is 100 only here + if not to_sign: return + + await approve_msg_sign(to_sign, subpath, addr_fmt, approved_cb=msg_signing_done, kill_menu=True) + + +async def sd_sign_msg_done(signature, address, text, base=None, orig_path=None, + slot_b=None, force_vdisk=False): + from glob import dis + dis.fullscreen('Generating...') + + out_fn = None + sig = b2a_base64(signature).decode('ascii').strip() + + while 1: + # try to put back into same spot + # add -signed to end. + target_fname = base + '-signed.txt' + lst = [orig_path] + if orig_path: + lst.append(None) + + for path in lst: + try: + with CardSlot(readonly=True, slot_b=slot_b, force_vdisk=force_vdisk) as card: + out_full, out_fn = card.pick_filename(target_fname, path) + out_path = path + if out_full: break + except CardMissingError: + prob = 'Missing card.\n\n' + out_fn = None + + if not out_fn: + # need them to insert a card + prob = '' + else: + # attempt write-out + try: + dis.fullscreen("Saving...") + with CardSlot(slot_b=slot_b, force_vdisk=force_vdisk) as card: + with card.open(out_full, 'wt') as fd: + # save in full RFC style + # gen length is 6 + gen = rfc_signature_template_gen(addr=address, msg=text, sig=sig) + for i, part in enumerate(gen): + fd.write(part) + dis.progress_bar_show(i / 6) + + # success and done! + break + + except OSError as exc: + prob = 'Failed to write!\n\n%s\n\n' % exc + sys.print_exception(exc) + # fall through to try again + + # prompt them to input another card? + ch = await ux_show_story(prob + "Please insert an SDCard to receive signed message, " + "and press %s." % OK, title="Need Card") + if ch == 'x': + await ux_aborted() + return + + # done. + msg = "Created new file:\n\n%s" % out_fn + await ux_show_story(msg, title='File Signed') + + async def sign_txt_file(filename): # sign a one-line text file found on a MicroSD card # - not yet clear how to do address types other than 'classic' - from files import CardSlot, CardMissingError - from ux import the_ux - UserAuthorizedAction.cleanup() - - # copy message into memory - with CardSlot() as card: - with card.open(filename, 'rt') as fd: - text = fd.readline().strip() - subpath = fd.readline().strip() - addr_fmt = fd.readline().strip() - - if not subpath: - # default: top of wallet. - subpath = 'm' - - if not addr_fmt: - addr_fmt = AF_CLASSIC - async def done(signature, address, text): # complete. write out result from glob import dis @@ -392,68 +589,19 @@ async def sign_txt_file(filename): orig_path, basename = filename.rsplit('/', 1) orig_path += '/' base = basename.rsplit('.', 1)[0] - out_fn = None - sig = b2a_base64(signature).decode('ascii').strip() - - while 1: - # try to put back into same spot - # add -signed to end. - target_fname = base+'-signed.txt' - - for path in [orig_path, None]: - try: - with CardSlot(readonly=True) as card: - out_full, out_fn = card.pick_filename(target_fname, path) - out_path = path - if out_full: break - except CardMissingError: - prob = 'Missing card.\n\n' - out_fn = None - - if not out_fn: - # need them to insert a card - prob = '' - else: - # attempt write-out - try: - dis.fullscreen("Saving...") - with CardSlot() as card: - with card.open(out_full, 'wt') as fd: - # save in full RFC style - # gen length is 6 - gen = rfc_signature_template_gen(addr=address, msg=text, sig=sig) - for i, part in enumerate(gen): - fd.write(part) - dis.progress_bar_show(i / 6) - - # success and done! - break - - except OSError as exc: - prob = 'Failed to write!\n\n%s\n\n' % exc - sys.print_exception(exc) - # fall through to try again - - # prompt them to input another card? - ch = await ux_show_story(prob+"Please insert an SDCard to receive signed message, " - "and press %s." % OK, title="Need Card") - if ch == 'x': - await ux_aborted() - return - - # done. - msg = "Created new file:\n\n%s" % out_fn - await ux_show_story(msg, title='File Signed') + await sd_sign_msg_done(signature, address, text, base, orig_path) + UserAuthorizedAction.cleanup() UserAuthorizedAction.check_busy() - try: - UserAuthorizedAction.active_request = ApproveMessageSign(text, subpath, addr_fmt, approved_cb=done) - # do not kill the menu stack! - the_ux.push(UserAuthorizedAction.active_request) - except AssertionError as exc: - await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) - return + + # copy message into memory + with CardSlot() as card: + with card.open(filename, 'rt') as fd: + res = fd.read() + + await approve_msg_sign(None, None, None, approved_cb=done, + msg_sign_request=res) def verify_signature(msg, addr, sig_str): warnings = "" @@ -541,7 +689,7 @@ async def verify_armored_signed_msg(contents, digest_check=True): title = "CORRECT" warn_msg = "" err_msg = "" - story = "Good signature by address:\n %s" % addr + story = "Good signature by address:\n%s" % show_single_address(addr) if digest_check: digest_prob = verify_signed_file_digest(msg) @@ -570,7 +718,6 @@ async def verify_armored_signed_msg(contents, digest_check=True): await ux_show_story('\n\n'.join(m for m in [err_msg, story, warn_msg] if m), title=title) async def verify_txt_sig_file(filename): - from files import CardSlot, CardMissingError, needs_microsd # copy message into memory try: with CardSlot() as card: @@ -625,7 +772,7 @@ class ApproveTransaction(UserAuthorizedAction): try: dest = self.chain.render_address(o.scriptPubKey) - return '%s\n - to address -\n%s\n' % (val, dest) + return '%s\n - to address -\n%s\n' % (val, show_single_address(dest)) except ValueError: pass @@ -927,8 +1074,10 @@ class ApproveTransaction(UserAuthorizedAction): msg = make_msg(start, n) async def save_visualization(self, msg, sign_text=False): - # write text into spi flash, maybe signing it as we go + # write story text out, maybe signing it as we go # - return length and checksum + from charcodes import OUT_CTRL_ADDRESS + txt_len = msg.seek(0, 2) msg.seek(0) @@ -936,7 +1085,8 @@ class ApproveTransaction(UserAuthorizedAction): with SFFile(TXN_OUTPUT_OFFSET, max_size=txt_len+300, message="Visualizing...") as fd: while 1: - blk = msg.read(256).encode('ascii') + # replace with empty space, to keep correct txt_len - already hashed + blk = msg.read(256).replace(OUT_CTRL_ADDRESS, ' ').encode('ascii') if not blk: break if chk: chk.update(blk) @@ -949,7 +1099,7 @@ class ApproveTransaction(UserAuthorizedAction): fd.write(b2a_base64(sig).decode('ascii').strip()) fd.write('\n') - return (fd.tell(), fd.checksum.digest()) + return fd.tell(), fd.checksum.digest() def output_summary_text(self, msg): # Produce text report of where their cash is going. This is what @@ -1027,13 +1177,13 @@ class ApproveTransaction(UserAuthorizedAction): visible_change_sum = 0 if len(largest_change) == 1: visible_change_sum += largest_change[0][0] - msg.write(' - to address -\n%s\n' % largest_change[0][1]) + msg.write(' - to address -\n%s\n' % show_single_address(largest_change[0][1])) else: msg.write(' - to addresses -\n') for val, addr in largest_change: visible_change_sum += val - msg.write(addr) - msg.write('\n') + msg.write(show_single_address(addr)) + msg.write('\n\n') left_c = self.psbt.num_change_outputs - len(largest_change) if left_c: @@ -1043,7 +1193,7 @@ class ApproveTransaction(UserAuthorizedAction): msg.write("\n") - # if we didn't already show all outputs, then give user a chance to + # if we didn't already show all outputs, then give user a chance to # view them individually return needs_txn_explorer @@ -1080,7 +1230,6 @@ def psbt_encoding_taster(taste, psbt_len): async def sign_psbt_file(filename, force_vdisk=False, slot_b=None): # sign a PSBT file found on a MicroSD card # - or from VirtualDisk (mk4) - from files import CardSlot, CardMissingError from glob import dis from ux import the_ux @@ -1389,7 +1538,7 @@ class ShowAddressBase(UserAuthorizedAction): hint_icons=KEY_QR+(KEY_NFC if NFC else '')) if ch in '4'+KEY_QR: - await show_qr_code(self.address, (self.addr_fmt & AFC_BECH32)) + await show_qr_code(self.address, (self.addr_fmt & AFC_BECH32), is_addrs=True) continue if NFC and (ch in '3'+KEY_NFC): @@ -1400,7 +1549,7 @@ class ShowAddressBase(UserAuthorizedAction): else: # finish the Wait... - dis.progress_bar_show(1) + dis.progress_bar_show(1) if self.restore_menu: self.pop_menu() @@ -1421,7 +1570,8 @@ class ShowPKHAddress(ShowAddressBase): self.address = sv.chain.address(node, addr_fmt) def get_msg(self): - return '''{addr}\n\n= {sp}''' .format(addr=self.address, sp=self.subpath) + return '''{addr}\n\n= {sp}''' .format(addr=show_single_address(self.address), + sp=self.subpath) class ShowP2SHAddress(ShowAddressBase): @@ -1448,8 +1598,8 @@ Wallet: Paths: -{sp}'''.format(addr=self.address, name=self.ms.name, - M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help)) +{sp}'''.format(addr=show_single_address(self.address), name=self.ms.name, + M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help)) class ShowMiniscriptAddress(ShowAddressBase): @@ -1466,6 +1616,7 @@ class ShowMiniscriptAddress(ShowAddressBase): def get_msg(self): return '''\ {addr} + Wallet: {name} @@ -1473,7 +1624,8 @@ Index: {idx} Change: - {change}'''.format(addr=self.address, name=self.msc.name, idx=self.idx, change=bool(self.change)) + {change}'''.format(addr=show_single_address(self.address), name=self.msc.name, + idx=self.idx, change=bool(self.change)) def start_show_miniscript_address(msc, change, index): diff --git a/shared/backups.py b/shared/backups.py index f61b1709..329ceb16 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -15,6 +15,7 @@ from pincodes import pa # we make passwords with this number of words num_pw_words = const(12) +bkpw_min_len = const(32) # max size we expect for a backup data file (encrypted or cleartext) # - limited by size of LFS area of flash, since all settings are held there @@ -309,7 +310,7 @@ async def restore_from_dict(vals): async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): from stash import bip39_passphrase - words = None + pwd = None skip_quiz = False bypass_tmp = False @@ -329,28 +330,40 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): "so backup will be of that seed."): return - stored_words = settings.get('bkpw', None) + # first check if bkpw already defined on tmp seed settings + stored_pwd = None + master_pwd = settings.master_get("bkpw", None) + if pa.tmp_value: + stored_pwd = settings.get('bkpw', None) - if stored_words: - stored_words = stored_words.split() - ch = await ux_show_story("Use same backup file password as last time?\n\n" - " 1: %s\n ...\n%d: %s" - % (stored_words[0], len(stored_words), stored_words[-1]), sensitive=True) + if not stored_pwd and master_pwd: + stored_pwd = master_pwd + + if stored_pwd: + # we can have words or other type of password here + split_pwd = stored_pwd.split() + if len(split_pwd) == num_pw_words: # weak + hint = " 1: %s\n ...\n%d: %s" % (split_pwd[0], len(split_pwd), split_pwd[-1]) + else: + hint = " %s...%s" % (stored_pwd[0], stored_pwd[-1]) + + ch = await ux_show_story("Use same backup file password as last time?\n\n" + hint, + sensitive=True) if ch == 'y': - words = stored_words + pwd = stored_pwd # string, not list skip_quiz = True - if not words: + if not pwd: # Pick a password: like bip39 but no checksum word # b = bytearray(32) while 1: ckcc.rng_bytes(b) - words = bip39.b2a_words(b).split(' ')[0:num_pw_words] + pwd = bip39.b2a_words(b).rsplit(' ', num_pw_words)[0] - ch = await seed.show_words(words, - prompt="Record this (%d word) backup file password:\n", escape='6') + ch = await seed.show_words(prompt="Record this (%d word) backup file password:\n", + words=pwd.split(" "), escape='6') if ch == '6' and not write_sflash: # Secret feature: plaintext mode @@ -367,43 +380,43 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): break - if words and not skip_quiz: + if pwd and not skip_quiz: # quiz them, but be nice and do a shorter test. - ch = await seed.word_quiz(words, limited=(num_pw_words//3)) + ch = await seed.word_quiz(pwd.split(" "), limited=(num_pw_words//3)) if ch == 'x': return - if words and words != stored_words: + if pwd and pwd != stored_pwd: ch = await ux_show_story("Would you like to use these same words next time you perform a backup?" " Press (1) to save them into this Coldcard for next time.", escape='1') if ch == '1': - settings.put('bkpw', ' '.join(words)) - settings.save() - elif stored_words: - settings.remove_key('bkpw') + settings.set('bkpw', pwd) # if on tmp save to tmp, do not update master settings.save() + # stop droping bkpw just because someone decided to use differrent password + # elif stored_words: + # settings.remove_key('bkpw') + # settings.save() - return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash, + return await write_complete_backup(pwd, fname_pattern, write_sflash=write_sflash, bypass_tmp=bypass_tmp) -async def write_complete_backup(words, fname_pattern, write_sflash=False, +async def write_complete_backup(pwd, fname_pattern, write_sflash=False, allow_copies=True, bypass_tmp=False): # Just do the writing from glob import dis from files import CardSlot # Show progress: - dis.fullscreen('Encrypting...' if words else 'Generating...') + dis.fullscreen('Encrypting...' if pwd else 'Generating...') body = render_backup_contents(bypass_tmp=bypass_tmp).encode() gc.collect() - if words: + if pwd: # NOTE: Takes a few seconds to do the key-streching, but little actual # time to do the encryption. - pw = ' '.join(words) - zz = compat7z.Builder(password=pw, progress_fcn=dis.progress_bar_show) + zz = compat7z.Builder(password=pwd, progress_fcn=dis.progress_bar_show) zz.add_data(body) # pick random filename, but ending in .txt @@ -742,11 +755,9 @@ async def clone_write_data(*a): my_pubkey = pair.pubkey().to_bytes(False) session_key = pair.ecdh_multiply(his_pubkey) - words = [b2a_hex(session_key).decode()] - fname = b2a_hex(my_pubkey).decode() + '-ccbk.7z' - await write_complete_backup(words, fname, allow_copies=False, bypass_tmp=True) + await write_complete_backup(b2a_hex(session_key).decode(), fname, allow_copies=False, bypass_tmp=True) await ux_show_story("Done.\n\nTake this MicroSD card back to other Coldcard and continue from there.") diff --git a/shared/chains.py b/shared/chains.py index b76de121..514481d2 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -13,6 +13,9 @@ from serializations import hash160, ser_compact_size, disassemble, ser_string from ucollections import namedtuple from opcodes import OP_RETURN, OP_1, OP_16 + +SINGLESIG_AF = (AF_P2WPKH, AF_CLASSIC, AF_P2TR, AF_P2WPKH_P2SH) + # See SLIP 132 # for background on these version bytes. Not to be confused with SLIP-32 which involves Bech32. Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint')) @@ -51,8 +54,6 @@ def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): class ChainsBase: curve = 'secp256k1' - menu_name = None # use 'name' if this isn't defined - core_name = None # name of chain's "core" p2p software # b44_cointype comes from # @@ -319,8 +320,7 @@ class ChainsBase: class BitcoinMain(ChainsBase): # see ctype = 'BTC' - name = 'Bitcoin' - core_name = 'Bitcoin Core' + name = 'Bitcoin Mainnet' slip132 = { AF_CLASSIC: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'), @@ -340,9 +340,9 @@ class BitcoinMain(ChainsBase): b44_cointype = 0 class BitcoinTestnet(BitcoinMain): + # testnet4 (was testnet3 up until 2025 but all parameters are the same) ctype = 'XTN' - name = 'Bitcoin Testnet' - menu_name = 'Testnet: BTC' + name = 'Bitcoin Testnet 4' slip132 = { AF_CLASSIC: Slip132Version(0x043587cf, 0x04358394, 't'), @@ -365,7 +365,6 @@ class BitcoinTestnet(BitcoinMain): class BitcoinRegtest(BitcoinMain): ctype = 'XRT' name = 'Bitcoin Regtest' - menu_name = 'Regtest: BTC' slip132 = { AF_CLASSIC: Slip132Version(0x043587cf, 0x04358394, 't'), @@ -446,6 +445,53 @@ CommonDerivations = [ AF_P2TR), # generates bc1p bech32m addresses ] +STD_DERIVATIONS = { + "p2pkh": CommonDerivations[0][1], + "p2sh-p2wpkh": CommonDerivations[1][1], + "p2wpkh-p2sh": CommonDerivations[1][1], + "p2wpkh": CommonDerivations[2][1], +} + +def parse_addr_fmt_str(addr_fmt): + # accepts strings and also integers if already parsed + try: + if isinstance(addr_fmt, int): + if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]: + return addr_fmt + else: + raise ValueError + + addr_fmt = addr_fmt.lower() + if addr_fmt in ("p2sh-p2wpkh", "p2wpkh-p2sh"): + return AF_P2WPKH_P2SH + elif addr_fmt == "p2pkh": + return AF_CLASSIC + elif addr_fmt == "p2wpkh": + return AF_P2WPKH + elif addr_fmt == "p2tr": + return AF_P2TR + else: + raise ValueError + except ValueError: + raise ValueError("Unsupported address format: '%s'" % addr_fmt) + + +def af_to_bip44_purpose(addr_fmt): + # single signature only + return {AF_CLASSIC: 44, + AF_P2WPKH_P2SH: 49, + AF_P2WPKH: 84, + AF_P2TR: 86}[addr_fmt] + +def addr_fmt_label(addr_fmt): + return { + AF_CLASSIC: "Classic P2PKH", + AF_P2WPKH_P2SH: "P2SH-Segwit", + AF_P2WPKH: "Segwit P2WPKH", + AF_P2TR: "Taproot P2TR", + AF_P2WSH: "Segwit P2WSH", + AF_P2WSH_P2SH: "P2SH-P2WSH" + }[addr_fmt] def verify_recover_pubkey(sig, digest): # verifies a message digest against a signature and recovers diff --git a/shared/charcodes.py b/shared/charcodes.py index 06d829c0..a7947475 100644 --- a/shared/charcodes.py +++ b/shared/charcodes.py @@ -107,4 +107,9 @@ if has_qwerty: assert DECODER[KEYNUM_SYMBOL] == KEY_SYMBOL assert DECODER[KEYNUM_LAMP] == KEY_LAMP +# These affect how 'ux stories' are rendered; they are control +# characters on the output side of things, not input. +OUT_CTRL_TITLE = '\x01' # must be first char in line: be a title line +OUT_CTRL_ADDRESS = '\x02' # must be first char in line: it's a payment address + # EOF diff --git a/shared/decoders.py b/shared/decoders.py index 6be9efaa..13f8b212 100644 --- a/shared/decoders.py +++ b/shared/decoders.py @@ -4,7 +4,7 @@ # # included in Q builds only, not Mk4 --> manifest_q1.py # -import ngu, bip39, ure, stash +import ngu, bip39, ure, stash, json from ubinascii import unhexlify as a2b_hex from exceptions import QRDecodeExplained from bbqr import TYPE_LABELS @@ -131,7 +131,11 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa pass elif ty == 'J': - return 'json', (got,) + what = "json" + if "msg" in got: + what = "smsg" + + return what, (got,) else: msg = TYPE_LABELS.get(ty, 'Unknown FileType') raise QRDecodeExplained("Sorry, %s not useful." % msg) @@ -159,6 +163,16 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa if expect_secret: raise QRDecodeExplained("Not a secret?") + try: + dct = json.loads(got) + if "msg" in dct: + return "smsg", (got,) + except: pass + + # Sparrow compat + if "signmessage" in got: + return "smsg", (got,) + # try to recognize various bitcoin-related text strings... return decode_short_text(got) @@ -178,6 +192,9 @@ def decode_short_text(got): # might be a PSBT? if len(got) > 100: + if got.lstrip().startswith("-----BEGIN BITCOIN SIGNED MESSAGE-----"): + return "vmsg", (got,) + from auth import psbt_encoding_taster try: decoder, _, psbt_len = psbt_encoding_taster(got[0:10].encode(), len(got)) diff --git a/shared/desc_utils.py b/shared/desc_utils.py index b0b1257b..28aaaa4e 100644 --- a/shared/desc_utils.py +++ b/shared/desc_utils.py @@ -146,8 +146,10 @@ class KeyOriginInfo: return cls(xfp, derivation) def __str__(self): - return "%s/%s" % (b2a_hex(self.fingerprint).decode(), - keypath_to_str(self.derivation, prefix='', skip=0).replace("'", "h")) + rv = "%s" % b2a_hex(self.fingerprint).decode() + if self.derivation: + rv += "/%s" % keypath_to_str(self.derivation, prefix='', skip=0).replace("'", "h") + return rv class KeyDerivationInfo: @@ -303,52 +305,27 @@ class Key: # parse key node, chain_type = cls.parse_key(k) der = KeyDerivationInfo.from_string(der.decode()) + if origin is None: + origin = KeyOriginInfo(ustruct.pack(' %s\n" % (hard_sub, chain.serialize_public(node))) - if show_slip132 and addr_fmt not in (AF_CLASSIC, AF_P2TR) and (addr_fmt in chain.slip132): + if addr_fmt not in (AF_CLASSIC, AF_P2TR) and (addr_fmt in chain.slip132): yield ("%s => %s ##SLIP-132##\n" % ( hard_sub, chain.serialize_public(node, addr_fmt))) @@ -449,14 +445,7 @@ def generate_electrum_wallet(addr_type, account_num): xfp = settings.get('xfp') # Must get the derivation path, and the SLIP32 version bytes right! - if addr_type == AF_CLASSIC: - mode = 44 - elif addr_type == AF_P2WPKH: - mode = 84 - elif addr_type == AF_P2WPKH_P2SH: - mode = 49 - else: - raise ValueError(addr_type) + mode = chains.af_to_bip44_purpose(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) @@ -552,16 +541,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int xfp = settings.get('xfp') dis.progress_bar_show(0.1) if mode is None: - if addr_type == AF_CLASSIC: - mode = 44 - elif addr_type == AF_P2WPKH: - mode = 84 - elif addr_type == AF_P2WPKH_P2SH: - mode = 49 - elif addr_type == AF_P2TR: - mode = 86 - else: - raise ValueError(addr_type) + mode = chains.af_to_bip44_purpose(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) diff --git a/shared/flow.py b/shared/flow.py index 4b1e9488..5ab306a9 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -179,8 +179,8 @@ XpubExportMenu = [ # xxxxxxxxxxxxxxxx MenuItem("Segwit (BIP-84)", f=export_xpub, arg=84), MenuItem("Classic (BIP-44)", f=export_xpub, arg=44), - MenuItem("Taproot/P2TR(86)", f=export_xpub, arg=86), - MenuItem("P2WPKH/P2SH (49)", f=export_xpub, arg=49), + MenuItem("Taproot/P2TR"+("(BIP-86)" if version.has_qwerty else "(86)"), f=export_xpub, arg=86), + MenuItem("P2WPKH/P2SH "+("(BIP-49)"if version.has_qwerty else "(49)"), f=export_xpub, arg=49), MenuItem("Master XPUB", f=export_xpub, arg=0), MenuItem("Current XFP", f=export_xpub, arg=-1), ] @@ -218,6 +218,7 @@ FileMgmtMenu = [ MenuItem('List Files', f=list_files), MenuItem('Verify Sig File', f=verify_sig_file), MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC), + MenuItem('BBQr File Share', predicate=version.has_qr, f=qr_share_file, arg=True), MenuItem('QR File Share', predicate=version.has_qr, f=qr_share_file, shortcut=KEY_QR), MenuItem('Clone Coldcard', predicate=has_secrets, f=clone_write_data), MenuItem('Format SD Card', f=wipe_sd_card), @@ -236,6 +237,7 @@ DevelopersMenu = [ MenuItem("Serial REPL", f=dev_enable_repl), MenuItem('Warm Reset', f=reset_self), MenuItem("Restore Txt Bkup", f=restore_everything_cleartext), + MenuItem("BKPW Override", menu=bkpw_override), ] AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh) @@ -308,7 +310,7 @@ If you disable sighash flag restrictions, and ignore the \ warnings, funds can be stolen by specially crafted PSBT or MitM. Keep blocked unless you intend to sign special transactions.'''), - ToggleMenuItem('Testnet Mode', 'chain', ['Bitcoin', 'Testnet3', 'Regtest'], + ToggleMenuItem('Testnet Mode', 'chain', ['Bitcoin', 'Testnet4', 'Regtest'], value_map=['BTC', 'XTN', 'XRT'], on_change=change_which_chain, story="Testnet must only be used by developers because \ diff --git a/shared/lcd_display.py b/shared/lcd_display.py index 0e3722a1..7ae67cb4 100644 --- a/shared/lcd_display.py +++ b/shared/lcd_display.py @@ -3,11 +3,11 @@ # lcd_display.py - LCD rendering for Q1's 320x240 pixel *colour* display! # import machine, uzlib, utime, array -from uasyncio import sleep_ms from graphics_q1 import Graphics from st7788 import ST7788 -from utils import xfp2str, word_wrap +from utils import xfp2str, word_wrap, chunk_address from ucollections import namedtuple +from charcodes import OUT_CTRL_TITLE, OUT_CTRL_ADDRESS # the one font: fixed-width (except for a few double-width chars) from font_iosevka import CELL_W, CELL_H, TEXT_PALETTES, COL_TEXT, COL_DARK_TEXT, COL_SCROLL_DARK @@ -612,25 +612,50 @@ class Display: self.clear() y=0 + prev_x = None for ln in lines: if ln == 'EOT': self.text(0, y, '┅'*CHARS_W, dark=True) continue - elif ln and ln[0] == '\x01': + + elif ln and ln[0] == OUT_CTRL_TITLE: # title ... but we have no special font? Inverse! self.text(0, y, ' '+ln[1:]+' ', invert=True) if hint_icons: # maybe show that [QR] can do something self.text(-1, y, hint_icons, dark=True) + + elif ln and ln[0] == OUT_CTRL_ADDRESS: + # we can assume this will be a single line for our display + # thanks to code in utils.word_wrap + prev_x = self._draw_addr(y, ln[1:], prev_x=prev_x) + else: self.text(0, y, ln) + prev_x = None y += 1 self.scroll_bar(top, num_lines, CHARS_H) self.show() - def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None): + def _draw_addr(self, y, addr, prev_x=None): + # Draw a single-line of an address + # - use prev_x=0 to start centered + if prev_x is None: + # left justify (for stories) + prev_x = x = 1 + elif prev_x == 0: + # center first line, following line(s) will be left-justified to match that + prev_x = x = max(((CHARS_W - (len(addr) * 5) // 4) // 2), 0) + else: + x = prev_x + + self.text(x, y, ' '+' '.join(chunk_address(addr))+' ', invert=True) + + return prev_x + + def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None, is_addr=False): # Show a QR code on screen w/ some text under it # - invert not supported on Q1 # - sidebar not supported here (see users.py) @@ -638,14 +663,24 @@ class Display: assert not sidebar # maybe show something other than QR contents under it - if msg: + if is_addr: + # With fancy display, no address, even classic can fit in single line, + # so always split nicely in middle and at mod4 + hh = len(msg) // 2 + if hh <= 20: + hh = (hh + 3) & ~0x3 + parts = [msg[0:hh], msg[hh:]] + num_lines = 2 + else: + # p2wsh address would need 3 lines to show, so we won't + num_lines = 0 + elif msg: if len(msg) <= CHARS_W: parts = [msg] elif ' ' not in msg and (len(msg) <= CHARS_W*2): - # fits in two lines, but has no spaces (ie. payment addr) - # so split nicely, and shift off center + # fits in two lines, but has no spaces hh = len(msg) // 2 - parts = [msg[0:hh] + ' ', ' '+msg[hh:]] + parts = [msg[0:hh], msg[hh:]] else: # do word wrap parts = list(word_wrap(msg, CHARS_W)) @@ -723,17 +758,21 @@ class Display: if num_lines: # centered text under that y = CHARS_H - num_lines + prev_x = 0 for line in parts: - self.text(None, y, line) + if not is_addr: + self.text(None, y, line) + else: + prev_x = self._draw_addr(y, line, prev_x=prev_x) y += 1 if idx_hint: lh = len(idx_hint) assert lh <= 10 - if lh > 6: + if lh > 5: # needs 2 lines - self.text(-1, 0, idx_hint[:6]) - self.text(-1, 1, idx_hint[6:]) + self.text(-1, 0, idx_hint[:5]) + self.text(-1, 1, idx_hint[5:]) else: self.text(-1, 0, idx_hint) diff --git a/shared/menu.py b/shared/menu.py index 4b14ee82..15ee14c2 100644 --- a/shared/menu.py +++ b/shared/menu.py @@ -362,12 +362,6 @@ class MenuSystem: self.cursor = 0 self.ypos = 0 - def goto_n(self, n): - # goto N from top of (current) screen - # change scroll only if needed to make it visible - self.cursor = max(min(n + self.ypos, self.count-1), 0) - self.ypos = max(self.cursor - n, 0) - def goto_idx(self, n): # skip to any item, force cusor near middle of screen n = self.count-1 if n >= self.count else n @@ -474,7 +468,7 @@ class MenuSystem: self.ypos = 0 elif '1' <= key <= '9': # jump down, based on screen postion - self.goto_n(ord(key)-ord('1')) + self.goto_idx(ord(key)-ord('1')) elif key in self.shortcuts: # run the function, if predicate allows m = self.shortcuts[key] @@ -489,7 +483,7 @@ class MenuSystem: return self.items[self.cursor] # search downwards for a menu item that starts with indicated letter - # if found, select it but dont drill down + # if found, select it but don't drill down lst = list(range(self.cursor+1, self.count)) + list(range(0, self.cursor)) for n in lst: if self.items[n].label[0].upper() == key.upper(): diff --git a/shared/miniscript.py b/shared/miniscript.py index e1f6595d..8dffa649 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -13,7 +13,7 @@ from wallet import BaseStorageWallet from menu import MenuSystem, MenuItem from ux import ux_show_story, ux_confirm, ux_dramatic_pause from files import CardSlot, CardMissingError, needs_microsd -from utils import problem_file_line, xfp2str, addr_fmt_label, truncate_address, to_ascii_printable, swab32 +from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_single_address from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER @@ -158,9 +158,8 @@ class MiniScriptWallet(BaseStorageWallet): ik = Key.from_string(self.key) if ik.origin: res.append(ik.origin.psbt_derivation()) - elif not isinstance(ik.node, bytes): - if ik.is_provably_unspendable: - res.append([swab32(ik.node.my_fp())]) + elif ik.is_provably_unspendable: + res.append([swab32(ik.node.my_fp())]) for k in self.keys: k = Key.from_string(k) @@ -241,7 +240,7 @@ class MiniScriptWallet(BaseStorageWallet): async def _detail(self, new_wallet=False, is_duplicate=False, short=False): - s = addr_fmt_label(self.addr_fmt) + "\n\n" + s = chains.addr_fmt_label(self.addr_fmt) + "\n\n" if self.taproot: s += self.taproot_internal_key_detail(short=short) @@ -281,14 +280,10 @@ class MiniScriptWallet(BaseStorageWallet): if short: s += note else: - if isinstance(key.node, bytes): - s += b2a_hex(key.node).decode() + s += self.key + if type(key) is Key: + # it is unspendable, BUT not unspend( s += "\n (%s)" % note - else: - s += self.key - if type(key) is Key: - # it is unspendable, BUT not unspend( - s += "\n (%s)" % note s += "\n\n" else: xfp, deriv, xpub = key.to_cc_data() @@ -421,7 +416,7 @@ class MiniScriptWallet(BaseStorageWallet): msg += '.../%d =>\n' % idx addrs.append(addr) - msg += truncate_address(addr) + '\n\n' + msg += show_single_address(addr) + '\n\n' dis.progress_sofar(idx - start + 1, n) return msg, addrs @@ -878,7 +873,6 @@ class Miniscript: keys = self.keys # provably unspendable taproot internal key is not covered here # all other keys (miniscript,tapscript) require key origin info - assert all(k.origin for k in keys), "Key origin info is required" assert len(keys) == len(set(keys)), "Insane" if taproot: forbiden = (Sortedmulti, Multi) diff --git a/shared/mk4.py b/shared/mk4.py index 6bffbd18..17409e82 100644 --- a/shared/mk4.py +++ b/shared/mk4.py @@ -11,7 +11,6 @@ def make_flash_fs(): os.VfsLfs2.mkfs(fl) os.mount(fl, '/flash') - os.mkdir('/flash/settings') def make_psram_fs(): diff --git a/shared/multisig.py b/shared/multisig.py index 7ee60b72..a5546601 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -5,7 +5,7 @@ import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version from ubinascii import hexlify as b2a_hex from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable -from utils import str_to_keypath, problem_file_line, check_xpub, truncate_address, get_filesize +from utils import str_to_keypath, problem_file_line, check_xpub, get_filesize, show_single_address from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code, ux_enter_number, OK, X from files import CardSlot, CardMissingError, needs_microsd @@ -13,7 +13,7 @@ from descriptor import Descriptor from miniscript import Key, Sortedmulti, Number, Multi from desc_utils import multisig_descriptor_template from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR -from menu import MenuSystem, MenuItem, NonDefaultMenuItem +from menu import MenuSystem, MenuItem, NonDefaultMenuItem, start_chooser, ToggleMenuItem from opcodes import OP_CHECKMULTISIG from exceptions import FatalPSBTIssue from glob import settings @@ -473,7 +473,7 @@ class MultisigWallet(BaseStorageWallet): msg += '.../%d/%d =>\n' % (change, idx) addrs.append(addr) - msg += truncate_address(addr) + '\n\n' + msg += show_single_address(addr) + '\n\n' dis.progress_sofar(idx - start + 1, n) return msg, addrs @@ -552,7 +552,7 @@ class MultisigWallet(BaseStorageWallet): # obscure case: xpub isn't deep enough to represent # indicated path... not wrong really. too_shallow = True - continue + dp = 0 for sp in path[dp:]: assert not (sp & 0x80000000), 'hard deriv' @@ -1172,7 +1172,6 @@ def disable_checks_chooser(): return int(MultisigWallet.disable_checks), ch, xset async def disable_checks_menu(*a): - from menu import start_chooser if not MultisigWallet.disable_checks: ch = await ux_show_story('''\ @@ -1205,7 +1204,6 @@ def psbt_xpubs_policy_chooser(): async def trust_psbt_menu(*a): # show a story then go into chooser - from menu import start_chooser ch = await ux_show_story('''\ This setting controls what the Coldcard does \ @@ -1230,25 +1228,23 @@ exists, otherwise 'Verify'.''') if ch == 'x': return start_chooser(psbt_xpubs_policy_chooser) -def unsorted_ms_chooser(): - ch = ['Do Not Allow', 'Allow'] - +def unsort_ms_chooser(): def xset(idx, text): - settings.set('unsort_ms', idx) - from actions import goto_top_menu - goto_top_menu() + if idx: + settings.set('unsort_ms', idx) + else: + settings.remove_key('unsort_ms') - return settings.get('unsort_ms', 0), ch, xset + return settings.get('unsort_ms', 0), ['Do Not Allow', 'Allow'], xset async def unsorted_ms_menu(*a): - from menu import start_chooser if not settings.get("unsort_ms", None): ch = await ux_show_story( - 'With this setting ON, it is allowed to import and operate' - ' "multi(...)" unsorted multisig wallets that do not follow BIP-67.' - ' It is of CRUCIAL importance for unsorted wallets, to backup multisig descriptor' - ' and preserve order of the keys in it.' + 'Enable this to allow import and operation with' + ' "multi(...)" unsorted multisig wallets that DO NOT follow BIP-67.' + ' It is of CRUCIAL importance to backup multisig descriptor for unsorted wallets' + ' in order to preserve key ordering.' ' Many popular wallets like Sparrow and Electrum do NOT support "multi(...)".' '\n\nUSE AT YOUR OWN RISK. Disabling BIP-67 is discouraged!' '\n\nPress (4) to confirm allowing "multi(...)"', escape='4') @@ -1269,7 +1265,7 @@ async def unsorted_ms_menu(*a): ) return - start_chooser(unsorted_ms_chooser) + start_chooser(unsort_ms_chooser) class MultisigMenu(MenuSystem): @@ -1298,9 +1294,9 @@ class MultisigMenu(MenuSystem): rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) - rv.append(NonDefaultMenuItem('Unsorted Multisig' if version.has_qwerty else "Unsorted Multi", - 'unsort_ms', - f=unsorted_ms_menu)) + rv.append(NonDefaultMenuItem( + 'Unsorted Multisig?' if version.has_qwerty else 'Unsorted Multi?', + 'unsort_ms', f=unsorted_ms_menu)) return rv diff --git a/shared/nfc.py b/shared/nfc.py index 9f57feb5..ee83ceb5 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -7,7 +7,7 @@ # - has GPIO signal "??" which is multipurpose on its own pin # - this chip chosen because it can disable RF interaction # -import utime, ngu, ndef, stash +import utime, ngu, ndef, stash, chains from uasyncio import sleep_ms import uasyncio as asyncio from ustruct import pack, unpack @@ -15,7 +15,7 @@ from ubinascii import unhexlify as a2b_hex from ubinascii import b2a_base64, a2b_base64 from ux import ux_show_story, ux_wait_keydown, OK, X -from utils import B2A, problem_file_line, parse_addr_fmt_str, txid_from_fname +from utils import B2A, problem_file_line, txid_from_fname from public_constants import AF_CLASSIC from charcodes import KEY_ENTER, KEY_CANCEL @@ -726,7 +726,7 @@ class NFCHandler: else: subpath, addr_fmt_str = winner try: - addr_fmt = parse_addr_fmt_str(addr_fmt_str) + addr_fmt = chains.parse_addr_fmt_str(addr_fmt_str) except AssertionError as e: await ux_show_story(str(e)) return @@ -737,42 +737,21 @@ class NFCHandler: await the_ux.interact() # need this otherwise NFC animation takes over async def start_msg_sign(self): - from auth import UserAuthorizedAction, ApproveMessageSign - from ux import the_ux - - UserAuthorizedAction.cleanup() + from auth import approve_msg_sign def f(m): m = m.decode() split_msg = m.split("\n") if 1 <= len(split_msg) <= 3: - return split_msg + return m winner = await self._nfc_reader(f, 'Unable to find correctly formated message to sign.') - if not winner: return - if len(winner) == 1: - text = winner[0] - subpath = "m" - addr_fmt = AF_CLASSIC - elif len(winner) == 2: - text, subpath = winner - addr_fmt = AF_CLASSIC # maybe default to native segwit? - else: - # len(winner) == 3 - text, subpath, addr_fmt = winner + await approve_msg_sign(None, None, None, approved_cb=self.msg_sign_done, + msg_sign_request=winner) - UserAuthorizedAction.check_busy(ApproveMessageSign) - try: - UserAuthorizedAction.active_request = ApproveMessageSign( - text, subpath, addr_fmt, approved_cb=self.msg_sign_done - ) - the_ux.push(UserAuthorizedAction.active_request) - except AssertionError as exc: - await ux_show_story("Problem: %s\n\nMessage to be signed must be a single line of ASCII text." % exc) - return async def msg_sign_done(self, signature, address, text): from auth import rfc_signature_template_gen diff --git a/shared/notes.py b/shared/notes.py index 14b9d2c6..cc109c8e 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -50,7 +50,7 @@ Press ENTER to enable and get started otherwise CANCEL.''', return NotesMenu(NotesMenu.construct()) -async def get_a_password(old_value): +async def get_a_password(old_value, min_len=0, max_len=128): # Get a (new) password as a string. # - does some fun generation as well. @@ -104,9 +104,9 @@ async def get_a_password(old_value): handlers = {KEY_F1: _pick_12, KEY_F2: _pick_24, KEY_F3: _pick_dense, KEY_F4: _do_dumb, KEY_F6: _toggle_case, KEY_F5: _bip85} - return await ux_input_text(old_value, confirm_exit=False, max_len=128, scan_ok=True, - b39_complete=True, prompt='Password', placeholder='(optional)', - funct_keys=(fmsg, handlers)) + return await ux_input_text(old_value, confirm_exit=False, max_len=max_len, min_len=min_len, + scan_ok=True, b39_complete=True, prompt='Password', + placeholder='(optional)', funct_keys=(fmsg, handlers)) class NotesMenu(MenuSystem): @@ -118,7 +118,8 @@ class NotesMenu(MenuSystem): MenuItem('New Password', f=cls.new_note, arg='p'), ShortcutItem(KEY_QR, f=cls.quick_create)] - if not NoteContent.count(): + cnt = NoteContent.count() + if not cnt: rv = news + [ MenuItem('Disable Feature', f=cls.disable_notes) ] else: rv = [] @@ -129,6 +130,9 @@ class NotesMenu(MenuSystem): rv.append(MenuItem('Export All', f=cls.export_all)) + if cnt >= 2: + rv.append(MenuItem('Sort By Title', f=cls.sort_titles)) + rv.append(MenuItem('Import', f=import_from_other)) return rv @@ -137,6 +141,14 @@ class NotesMenu(MenuSystem): async def export_all(cls, *a): await start_export(NoteContent.get_all()) + @classmethod + async def sort_titles(cls, menu, _, item): + # sort by title, one time and then reconstruct menu + NoteContent.sort_all() + + # force redraw + menu.update_contents() + @classmethod async def quick_create(cls, menu, _, item): # using QR, created a Note (never a password) with auto-generated title. @@ -232,6 +244,17 @@ class NoteContentBase: # how many do we have? return len(settings.get('notes', [])) + @classmethod + def sort_all(cls): + # sort and resave all notes based on title + # - careful: self.idx values will be wrong for any existing instances + # - 'title' is only common field to subclasses + notes = cls.get_all() + notes.sort(key=lambda j: j.title.lower()) + + settings.put('notes', [n.serialize() for n in notes]) + settings.save() + async def delete(self, *a): # Remove note ok = await ux_confirm("Everything about this note/password will be lost.") @@ -298,6 +321,15 @@ class NoteContentBase: # single export await start_export([self]) + async def sign_txt_msg(self, a, b, item): + from auth import ux_sign_msg, msg_signing_done + txt = item.arg + 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) + + class PasswordContent(NoteContentBase): # "Passwords" have a few more fields and are more structured flds = ['title', 'user', 'password', 'site', 'misc' ] @@ -317,6 +349,7 @@ class PasswordContent(NoteContentBase): MenuItem('Edit Metadata', f=self.edit), MenuItem('Delete', f=self.delete), MenuItem('Change Password', f=self.change_pw), + self.sign_misc_menu_item(), ShortcutItem(KEY_QR, f=self.view_qr), ShortcutItem(KEY_NFC, f=self.share_nfc, arg='password'), ] @@ -446,6 +479,7 @@ class NoteContent(NoteContentBase): MenuItem('Edit', f=self.edit), MenuItem('Delete', f=self.delete), MenuItem('Export', f=self.export), + self.sign_misc_menu_item(), ShortcutItem(KEY_QR, f=self.view_qr), ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'), ] diff --git a/shared/nvstore.py b/shared/nvstore.py index 4bb73994..0331fc99 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -8,13 +8,13 @@ # - recover from empty/blank/failed chips w/o user action # # Result: -# - up to 4k of values supported (after json encoding) -# - encrypted and stored in SPI flash, in last 128k area +# - up to a few k of values supported (after json encoding) +# - encrypted and stored in main flash, in a dedicated 512k area # - AES encryption key is derived from actual wallet secret # - if logged out, then use fixed key instead (ie. it's public) # - you cannot move data between slots because AES-CTR with CTR seed based on slot # # - SHA-256 check on decrypted data -# - (Mk4) each slot is a file on /flash/settings +# - each "slot" is a file in /flash/settings; in Mk1-3 was SPI flash block # - os.sync() not helpful because block device under filesystem doesnt implement it # import os, ujson, ustruct, ckcc, gc, ngu, aes256ctr, version @@ -84,10 +84,11 @@ from utils import call_later_ms # prelogin settings - do not need to be part of other saved settings # PRELOGIN_SETTINGS = ["_skip_pin", "nick", "rngk", "lgto", "kbtn", "terms_ok"] # keep these settings only if unspecified on the other end -KEEP_IF_BLANK_SETTINGS = ["bkpw", "wa", "sighshchk", "emu", "rz", "b39skip", - "axskip", "del", "pms", "idle_to", "batt_to", "bright"] +KEEP_IF_BLANK_SETTINGS = ["wa", "sighshchk", "emu", "rz", "b39skip", + "axskip", "del", "pms", "idle_to", "batt_to", + "bright"] -SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words'] +SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw"] NUM_SLOTS = const(100) SLOTS = range(NUM_SLOTS) @@ -177,6 +178,13 @@ class SettingsObject: return (blocks-bfree) / blocks def _open_file(self, pos, mode='rb'): + if 'w' in mode: + # make directory, when needed (recovery/robustness) + try: + os.stat(MK4_WORKDIR) + except OSError: # ENOENT + os.mkdir(MK4_WORKDIR[:-1]) + return open(MK4_FILENAME(pos), mode) def _slot_is_blank(self, pos, buf): @@ -193,13 +201,13 @@ class SettingsObject: fn = MK4_FILENAME(pos) try: os.remove(fn) - except Exception: - # Error (ENOENT) expected here when saving first time, because the + except: + # OSError (ENOENT) expected here when saving first time, because the # "old" slot was not in use pass def _read_slot(self, pos, decryptor): - # Mk4 is just reading a binary file and decrypt as we go. + # read a binary file and decrypt as we go. with self._open_file(pos) as fd: # missing ftell(), so emulate ln = fd.seek(0, 2) @@ -244,9 +252,12 @@ class SettingsObject: fd.write(aes(chk.digest())) def _used_slots(self): - # mk4: faster list of slots in use; doesn't open them - files = os.listdir(MK4_WORKDIR) - return [int(fn[0:-4], 16) for fn in files if fn.endswith('.aes')] + # list of slots in use; doesn't open them + try: + files = os.listdir(MK4_WORKDIR) + return [int(fn[0:-4], 16) for fn in files if fn.endswith('.aes')] + except: + return [] def _nonempty_slots(self, dis=None): # generate slots that are non-empty @@ -393,8 +404,9 @@ class SettingsObject: set = put def remove_key(self, kn): - self.current.pop(kn, None) - self.changed() + if kn in self.current: + self.current.pop(kn, None) + self.changed() def merge_previous_active(self, previous): import pyb @@ -452,11 +464,8 @@ class SettingsObject: call_later_ms(250, self.write_out) def find_spot(self, not_here=0): - # search for a blank sector to use - # - check randomly and pick first blank one (wear leveling, deniability) - # - we will write and then erase old slot + # search for a blank slot to use # - if "full", blow away a random one - # on mk4, use the filesystem to see what's already taken avail = set(SLOTS) - set(self._used_slots()) avail.discard(not_here) diff --git a/shared/ownership.py b/shared/ownership.py index b89738f3..e98dd10f 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -7,8 +7,8 @@ from glob import settings from ucollections import namedtuple from ubinascii import hexlify as b2a_hex from exceptions import UnknownAddressExplained +from utils import problem_file_line, show_single_address from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR -from utils import problem_file_line # Track many addresses, but in compressed form # - map from random Bech32/Base58 payment address to (wallet) + keypath @@ -217,8 +217,7 @@ class OwnershipCache: addr_fmt = ch.possible_address_fmt(addr) if not addr_fmt: # might be valid address over on testnet vs mainnet - nm = ch.name if ch.ctype != 'BTC' else 'Bitcoin Mainnet' - raise UnknownAddressExplained('That address is not valid on ' + nm) + raise UnknownAddressExplained('That address is not valid on ' + ch.name) possibles = [] @@ -256,7 +255,7 @@ class OwnershipCache: if af == addr_fmt and acct_num: w = MasterSingleSigWallet(addr_fmt, account_idx=acct_num) possibles.append(w) - except ValueError: pass # if not single sig address format + except (KeyError, ValueError): pass # if not single sig address format if not possibles: # can only happen w/ scripts; for single-signer we have things to check @@ -316,31 +315,52 @@ class OwnershipCache: # Provide a simple UX. Called functions do fullscreen, progress bar stuff. from ux import ux_show_story, show_qr_code from charcodes import KEY_QR + from multisig import MultisigWallet + from miniscript import MiniScriptWallet from public_constants import AFC_BECH32, AFC_BECH32M try: wallet, subpath = OWNERSHIP.search(addr) + is_complex = isinstance(wallet, MultisigWallet) or isinstance(wallet, MiniScriptWallet) - msg = addr + sp = None + msg = show_single_address(addr) msg += '\n\nFound in wallet:\n ' + wallet.name if hasattr(wallet, "render_path"): - msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) - if version.has_qwerty: - esc = KEY_QR + sp = wallet.render_path(*subpath) + msg += '\nDerivation path:\n ' + sp + + if is_complex: + esc = "" else: - msg += '\n\nPress (1) for QR' - esc = '1' + esc = "0" + msg += "\n\nPress (0) to sign message with this key." + + title = "Verified" + if version.has_qwerty: + esc += KEY_QR + title += " Address" + else: + msg += ' (1) for address QR' + esc += '1' + title += "!" while 1: - ch = await ux_show_story(msg, title="Verified Address", - escape=esc, hint_icons=KEY_QR) - if ch != esc: break - await show_qr_code(addr, - is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), - msg=addr) + ch = await ux_show_story(msg, title=title, escape=esc, hint_icons=KEY_QR) + if ch in ("1"+KEY_QR): + await show_qr_code( + addr, + is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), + msg=addr, is_addrs=True + ) + elif not is_complex and (ch == "0"): # only singlesig + from auth import sign_with_own_address + await sign_with_own_address(sp, wallet.addr_fmt) + else: + break except UnknownAddressExplained as exc: - await ux_show_story(addr + '\n\n' + str(exc), title="Unknown Address") + await ux_show_story(show_single_address(addr) + '\n\n' + str(exc), title="Unknown Address") except Exception as e: await ux_show_story('Ownership search failed.\n\n%s\n%s' % (e, problem_file_line(e))) @@ -377,8 +397,6 @@ class OwnershipCache: # - if they explore it (non-zero subaccount) # - if they sign those paths # - but ignore testnet vs. not - from glob import settings - if subaccount == 0: # only interested in non-zero subaccounts return diff --git a/shared/pincodes.py b/shared/pincodes.py index e4160187..a4a70a40 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -473,6 +473,7 @@ class PinAttempt: def tmp_secret(self, encoded, chain=None, bip39pw=''): # Use indicated secret and stop using the SE; operate like this until reboot from glob import settings + from utils import xfp2str from nvstore import SettingsObject val = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded))) @@ -483,7 +484,9 @@ class PinAttempt: target_nvram_key = None if encoded is not None: # disallow using master seed as temporary - master_err = "Cannot use master seed as temporary." + xfp = xfp2str(settings.master_get("xfp", 0)) + master_err = ("Cannot use master seed as temporary. BUT you have just successfully " + "tested recovery of your master seed [%s].") % xfp target_nvram_key = settings.hash_key(val) if SettingsObject.master_nvram_key: assert self.tmp_value diff --git a/shared/psbt.py b/shared/psbt.py index a5ddf8a1..80458cc1 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -2185,6 +2185,7 @@ class psbtObject(psbtProxy): # check the pubkey of this BIP-32 node if pubkey == node.pubkey(): good += 1 + OWNERSHIP.note_subpath_used(subpath) if oup.taproot_subpaths: for xonly_pk, val in oup.taproot_subpaths.items(): @@ -2201,7 +2202,6 @@ class psbtObject(psbtProxy): # check the pubkey of this BIP-32 node if xonly_pk == node.pubkey()[1:]: good += 1 - OWNERSHIP.note_subpath_used(subpath) if not good: @@ -2425,17 +2425,16 @@ class psbtObject(psbtProxy): stash.blank_object(node) del sk, node - # Could remove sighash from input object - it is not required, takes space, - # and is already in signature or is implicit by not being part of the - # signature (taproot SIGHASH_DEFAULT) - ## inp.sighash = None - success.add(in_idx) gc.collect() if self.is_v2: self.set_modifiable_flag(inp) + # drop sighash if default (SIGHASH_ALL) + if inp.sighash == SIGHASH_ALL: + inp.sighash = None + # done. dis.progress_bar_show(1) diff --git a/shared/pwsave.py b/shared/pwsave.py index ebc29797..81355fb4 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -7,6 +7,7 @@ from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story, OK, X from utils import xfp2str, problem_file_line, B2A from menu import MenuItem, MenuSystem +from glob import settings class PassphraseSaver: @@ -110,7 +111,6 @@ class PassphraseSaverMenu(MenuSystem): from ux import ux_show_story from seed import set_bip39_passphrase from pincodes import pa - from glob import settings bypass_tmp = True pw, expect_xfp = item.arg @@ -253,7 +253,6 @@ class MicroSD2FA(PassphraseSaver): @classmethod def get_nonces(cls): # this is the only setting: list of nonce values we have saved to various cards - from glob import settings return settings.get('sd2fa') or [] def read_card(self): @@ -288,7 +287,6 @@ class MicroSD2FA(PassphraseSaver): except: # die. wrong import callgate - from glob import settings settings.remove_key("sd2fa") settings.save() callgate.fast_wipe(silent=False) @@ -353,8 +351,6 @@ class MicroSD2FA(PassphraseSaver): async def remove(self, nonce): # remove indicated nonce from records # - doesn't delete file, since might not have card anymore and useless w/o nonce - from glob import settings - v = self.get_nonces() assert nonce in v, 'missing card nonce' v2 = [i for i in v if i != nonce] diff --git a/shared/qrs.py b/shared/qrs.py index 9f55d720..277da035 100644 --- a/shared/qrs.py +++ b/shared/qrs.py @@ -17,13 +17,14 @@ MAX_V11_CHAR_LIMIT = const(321) class QRDisplaySingle(UserInteraction): # Show a single QR code for (typically) a list of addresses, or a single value. - def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None): + def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None, is_addrs=False): self.is_alnum = is_alnum self.idx = 0 # start with first address self.invert = False # looks better, but neither mode is ideal self.addrs = addrs self.sidebar = sidebar self.start_n = start_n + self.is_addrs = is_addrs self.msg = msg self.qr_data = None @@ -67,13 +68,16 @@ class QRDisplaySingle(UserInteraction): # make the QR, if needed. if not self.qr_data: dis.busy_bar(True) - - self.calc_qr(body) + try: + self.calc_qr(body) + except Exception: + dis.busy_bar(False) + raise # draw display dis.busy_bar(False) dis.draw_qr_display(self.qr_data, self.msg or body, self.is_alnum, - self.sidebar, self.idx_hint(), self.invert) + self.sidebar, self.idx_hint(), self.invert, is_addr=self.is_addrs) async def interact_bare(self): from glob import NFC, dis diff --git a/shared/seed.py b/shared/seed.py index ec82a81e..2d95ea35 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -964,7 +964,6 @@ class SeedVaultMenu(MenuSystem): @classmethod def construct(cls): # Dynamic menu with user-defined names of seeds shown - from glob import settings from pincodes import pa if pa.is_deltamode(): @@ -1209,7 +1208,7 @@ class PassphraseMenu(MenuSystem): @classmethod async def add_numbers(cls, *a): # Mk4 only: add some digits (quick, easy) - pw = await ux_input_numbers(cls.pp_sofar, cls.check_length) + pw = await ux_input_numbers(cls.pp_sofar) if pw is not None: cls.pp_sofar = pw cls.check_length() diff --git a/shared/sim_display.py b/shared/sim_display.py index fe44b025..edc3b799 100644 --- a/shared/sim_display.py +++ b/shared/sim_display.py @@ -37,7 +37,17 @@ if not has_lcd: x, y, msg = a[0:3] global contents - contents[y] = msg + is_idx = False + if x == 0 and len(msg) == 1: + # something on index zero - is it index num in top right with QR display? + # msg will just single int without any dot or smthg + try: + int(msg) + is_idx = True + except: pass + + if not is_idx: + contents[y] = msg #print('text (%s, %s): %s' % (x,y, msg)) diff --git a/shared/utils.py b/shared/utils.py index ffe8f12b..2fbf8666 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -6,6 +6,7 @@ import gc, sys, ustruct, ngu, chains, ure, time, version, uos, uio, bip39 from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from ubinascii import a2b_base64, b2a_base64 +from charcodes import OUT_CTRL_ADDRESS from uhashlib import sha256 from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR, MAX_PATH_DEPTH from public_constants import AF_P2WSH, AF_P2WSH_P2SH @@ -13,6 +14,14 @@ from public_constants import AF_P2WSH, AF_P2WSH_P2SH B2A = lambda x: str(b2a_hex(x), 'ascii') +STD_DERIVATIONS = { + "p2pkh": "m/44h/{chain}h/0h/0/0", + "p2sh-p2wpkh": "m/49h/{chain}h/0h/0/0", + "p2wpkh-p2sh": "m/49h/{chain}h/0h/0/0", + "p2wpkh": "m/84h/{chain}h/0h/0/0", + "p2tr": "m/86h/{chain}h/0h/0/0", +} + try: from font_iosevka import FontIosevka DOUBLE_WIDE = FontIosevka.DOUBLE_WIDE @@ -206,16 +215,17 @@ def is_printable(s): return False return True -def to_ascii_printable(s, strip=False): +def to_ascii_printable(s, strip=False, only_printable=True): try: s = str(s, 'ascii') if strip: s = s.strip() assert is_ascii(s) - assert is_printable(s) + if only_printable: + assert is_printable(s) return s except: - raise AssertionError('must be ascii printable') + raise AssertionError("must be ascii" + (" printable" if only_printable else "")) def problem_file_line(exc): @@ -262,7 +272,7 @@ def cleanup_deriv_path(bin_path, allow_star=False): # regex for valid chars, m at start, maybe /*h or /* at end sometimes mat = ure.match(r"(m|m/|)[0-9/h]*" + ('' if not allow_star else r"(\*h|\*|)"), s) - assert mat.group(0) == s, "invalid characters" + assert mat.group(0) == s, "invalid characters in path" parts = s.split('/') @@ -421,7 +431,7 @@ def check_firmware_hdr(hdr, binary_size): ok = (hw_compat & MK_4_OK) elif hw_label == 'q1': ok = (hw_compat & MK_Q1_OK) - + if not ok: return "That firmware doesn't support this version of Coldcard hardware (%s)."%hw_label @@ -486,11 +496,23 @@ def word_wrap(ln, w): return while ln: - # find a space in (width) first part of remainder sp = ln.rfind(' ', 0, w-1) - if sp == -1: + if ln[0] == OUT_CTRL_ADDRESS: + # special handling for lines w/ payment address in them + # - add same marker to newly split lines + addr = ln[1:] + # - 3 4-char groups on Mk4 + # - 6 4-char groups on Q + aw = 24 if version.has_qwerty else 12 + + pos = 0 + while pos < len(addr): + yield OUT_CTRL_ADDRESS + addr[pos:pos+aw] + pos += aw + return + # bad-break the line sp = min(txtlen(ln), w) nsp = sp @@ -510,23 +532,6 @@ def word_wrap(ln, w): yield left -def parse_addr_fmt_str(addr_fmt): - # accepts strings and also integers if already parsed - if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC, AF_P2TR]: - return addr_fmt - - addr_fmt = addr_fmt.lower() - if addr_fmt in ("p2sh-p2wpkh", "p2wpkh-p2sh"): - return AF_P2WPKH_P2SH - elif addr_fmt == "p2pkh": - return AF_CLASSIC - elif addr_fmt == "p2wpkh": - return AF_P2WPKH - elif addr_fmt == "p2tr": - return AF_P2TR - else: - raise ValueError("Unsupported address format: '%s'" % addr_fmt) - def parse_extended_key(ln, private=False): # read an xpub/ypub/etc and return BIP-32 node and what chain it's on. # - can handle any garbage line @@ -563,17 +568,6 @@ def chunk_writer(fd, body): dis.progress_bar_show(1) -def addr_fmt_label(addr_fmt): - return { - AF_CLASSIC: "Classic P2PKH", - AF_P2WPKH_P2SH: "P2SH-Segwit", - AF_P2WPKH: "Segwit P2WPKH", - AF_P2TR: "Taproot P2TR", - AF_P2WSH: "Segwit P2WSH", - AF_P2WSH_P2SH: "P2SH-P2WSH" - }[addr_fmt] - - def pad_raw_secret(raw_sec_str): # Chip can hold 72-bytes as a secret # every secret has 0th byte as marker @@ -758,11 +752,18 @@ def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False): # path length of derivation given needs to match xpub's depth if not disable_checks: p_len = deriv.count('/') - assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( - p_len, depth, xfp2str(xfp)) + if p_len: + # only check this for keys that have origin derivation + # originless keys are expected to be blinded + assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( + p_len, depth, xfp2str(xfp) + ) + else: + # depth can be more than zero here - keys can be blinded + assert xfp == swab32(node.my_fp()), "xpub xfp wrong %s" % xfp2str(xfp) if xfp == my_xfp: - # its supposed to be my key, so I should be able to generate pubkey + # it's supposed to be my key, so I should be able to generate pubkey # - might indicate collision on xfp value between co-signers, # and that's not supported with stash.SensitiveValues() as sv: @@ -774,21 +775,16 @@ def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False): # - this has effect of stripping SLIP-132 confusion away return xfp == my_xfp, (xfp, deriv, chain.serialize_public(node, AF_P2SH)) - -def truncate_address(addr): - # Truncates address to width of screen, replacing middle chars - if not version.has_qwerty: - # - 16 chars screen width - # - but 2 lost at left (menu arrow, corner arrow) - # - want to show not truncated on right side - return addr[0:6] + '⋯' + addr[-6:] - else: - # tons of space on Q1 - return addr[0:12] + '⋯' + addr[-12:] - - def encode_seed_qr(words): return ''.join('%04d' % bip39.get_word_index(w) for w in words) +def show_single_address(addr): + # insert some metadata so display layer can do special rendering + # of addresses (based on hardware capabilities) + return OUT_CTRL_ADDRESS + addr + +def chunk_address(addr): + # useful to show payment addresses specially + return [addr[i:i+4] for i in range(0, len(addr), 4)] # EOF diff --git a/shared/ux.py b/shared/ux.py index 6259e445..fe1cd4dd 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -7,7 +7,8 @@ from queues import QueueEmpty import utime, gc, version from utils import word_wrap from charcodes import (KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_HOME, KEY_NFC, KEY_QR, - KEY_END, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_ENTER, KEY_CANCEL) + KEY_END, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_ENTER, KEY_CANCEL, OUT_CTRL_TITLE) + from exceptions import AbortInteraction DEFAULT_IDLE_TIMEOUT = const(4*3600) # (seconds) 4 hours @@ -181,8 +182,8 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, lines = [] if title: - # kinda weak rendering but it works. - lines.append('\x01' + title) + # render the title line specially, see display/lcd_display.py + lines.append(OUT_CTRL_TITLE + title) if version.has_qwerty: # big screen always needs blank after title @@ -321,14 +322,14 @@ def abort_and_push(m): the_ux.push(m) numpad.abort_ux() -async def show_qr_codes(addrs, is_alnum, start_n): +async def show_qr_codes(addrs, is_alnum, start_n, **kw): from qrs import QRDisplaySingle - o = QRDisplaySingle(addrs, is_alnum, start_n, sidebar=None) + o = QRDisplaySingle(addrs, is_alnum, start_n, **kw) await o.interact_bare() -async def show_qr_code(data, is_alnum=False, msg=None): +async def show_qr_code(data, is_alnum=False, msg=None, **kw): from qrs import QRDisplaySingle - o = QRDisplaySingle([data], is_alnum, msg=msg) + o = QRDisplaySingle([data], is_alnum, msg=msg, **kw) await o.interact_bare() async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False): diff --git a/shared/ux_mk4.py b/shared/ux_mk4.py index dac8bf51..5ebcd2ea 100644 --- a/shared/ux_mk4.py +++ b/shared/ux_mk4.py @@ -122,7 +122,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False): # cleanup leading zeros and such value = str(min(int(value), max_value)) -async def ux_input_numbers(val, validate_func): +async def ux_input_numbers(val): # collect a series of digits from glob import dis from display import FontTiny @@ -161,7 +161,6 @@ async def ux_input_numbers(val, validate_func): ch = await press.wait() if ch == 'y': val += here - validate_func() return val elif ch == 'x': if here: diff --git a/shared/ux_q1.py b/shared/ux_q1.py index e8a32e3f..eaac2011 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -2,7 +2,7 @@ # # ux_q1.py - UX/UI interactions that are Q1 specific and use big screen, keyboard. # -import utime, gc, ngu, sys +import utime, gc, ngu, sys, chains import uasyncio as asyncio from uasyncio import sleep_ms from charcodes import * @@ -12,7 +12,10 @@ import bip39 from decoders import decode_qr_result from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex -from utils import problem_file_line +from ubinascii import b2a_base64 + +from utils import problem_file_line, show_single_address +from public_constants import MSG_SIGNING_MAX_LENGTH from glob import numpad # may be None depending on import order, careful class PressRelease: @@ -138,7 +141,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False): # cleanup leading zeros and such value = str(min(int(value), max_value)) -async def ux_input_numbers(val, validate_func): +async def ux_input_numbers(val): # collect a series of digits # - not wanted on Q1; just get the digits mixed in w/ the text. pass @@ -159,7 +162,6 @@ async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100, # to make longer single-line value onto screen # - confirm_exit default False here, because so easy to re-enter w/ qwerty, True on mk4 from glob import dis - from ux import ux_show_story MAX_LINES = 7 # without scroll can_scroll = False @@ -952,6 +954,20 @@ class QRScannerInteraction: await ux_visualize_wif(wif_str, key_pair, compressed, testnet) return + if what == "vmsg": + data, = vals + from auth import verify_armored_signed_msg + await verify_armored_signed_msg(data) + return + + if what == "smsg": + data, = vals + from auth import approve_msg_sign, msg_signing_done + await approve_msg_sign(None, None, None, + msg_sign_request=data, kill_menu=True, + approved_cb=msg_signing_done) + return + if what == 'text' or what == 'xpub': # we couldn't really decode it. txt, = vals @@ -1069,7 +1085,7 @@ async def ux_visualize_bip21(proto, addr, args): # - validate address ownership on request from ux import ux_show_story - msg = addr + '\n\n' + msg = show_single_address(addr) + '\n\n' args = args or {} if 'amount' in args: @@ -1108,15 +1124,49 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet): msg += "public key sec:\n" + b2a_hex(kp.pubkey().to_bytes(not compressed)).decode() + "\n\n" await ux_show_story(msg, title="WIF") -async def ux_visualize_textqr(txt, maxlen=200): +async def qr_msg_sign_done(signature, address, text): + from ux import ux_show_story + from auth import rfc_signature_template_gen + from export import export_by_qr + + sig = b2a_base64(signature).decode('ascii').strip() + while True: + ch = await ux_show_story("Press ENTER to export signature QR only, " + "(0) to export full RFC template, " + "CANCEL if done.", escape="0") + if ch == "x": break + if ch == "y": + await export_by_qr(sig, "Signature", "U") + if ch == "0": + armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text, + sig=sig)) + await show_bbqr_codes("U", armored_str, "Armored MSG") + +async def qr_sign_msg(txt): + from auth import ux_sign_msg + await ux_sign_msg(txt, approved_cb=qr_msg_sign_done, kill_menu=True) + +async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH): # Show simple text. Don't crash on huge things, but be # able to show a full xpub. from ux import ux_show_story - if len(txt) > maxlen: + + txt_len = len(txt) + escape = "0" + if txt_len > maxlen: + escape = None txt = txt[0:maxlen] + '...' - await ux_show_story("%s\n\nAbove is text that was scanned. " - "We can't do any more with it." % txt, title="Simple Text") + msg = "%s\n\nAbove is text that was scanned. " % txt + if escape: + msg += " Press (0) to sign the text. " + else: + msg += "We can't do any more with it." + + ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape) + if escape and (ch == "0"): + await qr_sign_msg(txt) + async def show_bbqr_codes(type_code, data, msg, already_hex=False): # Compress, encode and split data, then show it animated... diff --git a/shared/version.py b/shared/version.py index 69d1b855..60f5f9f0 100644 --- a/shared/version.py +++ b/shared/version.py @@ -83,7 +83,6 @@ def probe_system(): hw_label = 'mk4' has_608 = True - nfc_presence_check() # hardware present; they might not be using it has_qr = False # QR scanner num_sd_slots = 1 # might have dual slots on Q1 mk_num = 4 @@ -91,7 +90,7 @@ def probe_system(): has_qwerty = False is_edge = False supports_hsm = True - has_nfc = True + has_nfc = nfc_presence_check() # hardware present; they might not use it. cpuid = ckcc.get_cpu_id() assert cpuid == 0x470 # STM32L4S5VI diff --git a/shared/wallet.py b/shared/wallet.py index 666a05d0..626dc9ad 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -4,7 +4,6 @@ # import chains from glob import settings -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from stash import SensitiveValues @@ -45,20 +44,10 @@ class MasterSingleSigWallet(WalletABC): # 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 - if addr_fmt == AF_P2TR: - n = 'Taproot P2TR' - prefix = path or 'm/86h/{coin_type}h/{account}h' - elif addr_fmt == AF_P2WPKH: - n = 'Segwit P2WPKH' - prefix = path or 'm/84h/{coin_type}h/{account}h' - elif addr_fmt == AF_CLASSIC: - n = 'Classic P2PKH' - prefix = path or 'm/44h/{coin_type}h/{account}h' - elif addr_fmt == AF_P2WPKH_P2SH: - n = 'P2WPKH-in-P2SH' - prefix = path or 'm/49h/{coin_type}h/{account}h' - else: - raise ValueError(addr_fmt) + + n = chains.addr_fmt_label(addr_fmt) + purpose = chains.af_to_bip44_purpose(addr_fmt) + prefix = path or 'm/%dh/{coin_type}h/{account}h' % purpose if chain_name: self.chain = chains.get_chain(chain_name) diff --git a/shared/xor_seed.py b/shared/xor_seed.py index deb7ab82..1a4a9842 100644 --- a/shared/xor_seed.py +++ b/shared/xor_seed.py @@ -70,6 +70,11 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2') raw_secret = bytes(32) try: with stash.SensitiveValues() as sv: + if sv.deltamode: + # die rather than give up our secrets + import callgate + callgate.fast_wipe() + words = None if sv.mode == 'words': words = bip39.b2a_words(sv.raw).split(' ') @@ -290,6 +295,11 @@ or press (2) for 18 words XOR.''' % OK, escape="12") if ch == '1': dis.fullscreen("Wait...") with stash.SensitiveValues() as sv: + if sv.deltamode: + # die rather than give up our secrets + import callgate + callgate.fast_wipe() + if sv.mode == 'words': # needs copy here [:] otherwise rewritten with zeros in __exit__ import_xor_parts.append(sv.raw[:]) @@ -297,17 +307,20 @@ or press (2) for 18 words XOR.''' % OK, escape="12") # Add from Seed Vault? # filter only those that are correct length and type from seed vault opt = [] - for i, (xfp_str, hex_str, _, _) in enumerate(settings.master_get("seeds", [])): + seeds = [] if pa.is_deltamode() else settings.master_get("seeds", []) + for i, (xfp_str, hex_str, _, _) in enumerate(seeds): raw = pad_raw_secret(hex_str) if raw[0] & 0x80: # seed phrase sk = raw[1:1 + stash.len_from_marker(raw[0])] if stash.len_to_numwords(len(sk)) == desired_num_words: opt.append((i, xfp_str, sk)) + del seeds if opt: escape = "2" msg = ("Seed Vault is enabled. %d stored seeds have suitable type and length." - "\n\nPress (2) to add from Seed Vault, press %s to continue normally.") % (len(opt), OK) + "\n\nPress (2) to add from Seed Vault and then (1) to select seeds," + " press %s to continue normally.") % (len(opt), OK) ch = await ux_show_story(msg, escape=escape) if ch == 'x': return if ch == "2": diff --git a/shared/zevvpeep.py b/shared/zevvpeep.py index 624bd5ee..be67f32d 100644 --- a/shared/zevvpeep.py +++ b/shared/zevvpeep.py @@ -36,8 +36,8 @@ class FontSmall(FontBase): _bboxes = [None, (0, -3, 7, 14, 0), (0, -3, 7, 14, 4), (0, -3, 7, 14, 5), (0, -3, 7, 14, 7), (0, -3, 7, 14, 9), (0, -3, 7, 14, 10), (0, -3, 7, 14, 11), (0, -3, 7, 14, 12), (0, -3, 7, 14, 13), (0, -3, - 7, 14, 14), (0, 0, 8, 8, 8), (0, 0, 11, 8, 16), (0, 0, 11, 9, 18), - (0, 0, 14, 10, 20)] + 7, 14, 14), (0, 0, 5, 2, 2), (0, 0, 8, 8, 8), (0, 0, 11, 8, 16), (0, + 0, 11, 9, 18), (0, 0, 14, 10, 20)] _code_points = [ (range(32, 127), [1, 2, 14, 20, 31, 43, 55, 67, 73, 87, 101, 111, 122, @@ -48,11 +48,11 @@ class FontSmall(FontBase): 755, 767, 779, 791, 803, 815, 827, 842, 854, 866, 880, 892, 904, 916, 928, 940, 954, 968, 980, 992, 1004, 1016, 1028, 1040, 1052, 1067, 1079, 1093, 1106, 1120]), -(range(8226, 8227), [1126]), # • -(range(8592, 8593), [1145]), # ← -(range(8594, 8595), [1166]), # → -(range(8627, 8628), [1187]), # ↳ -(range(8943, 8944), [1208]), # ⋯ +(range(8201, 8202), [1126]), # +(range(8226, 8227), [1129]), # • +(range(8592, 8595), [1148, 0, 1169]), # ← → +(range(8627, 8628), [1190]), # ↳ +(range(8943, 8944), [1211]), # ⋯ ] _bitmaps = b"""\ @@ -63,7 +63,7 @@ class FontSmall(FontBase): \x10\x09\x04\x08\x10\x10\x20\x20\x20\x20\x20\x10\x10\x08\x04\x09\x20\x10\ \x08\x08\x04\x04\x04\x04\x04\x08\x08\x10\x20\x05\x00\x00\x00\x00\x24\x18\ \x7e\x18\x24\x06\x00\x00\x00\x08\x08\x08\x3e\x08\x08\x08\x09\x00\x00\x00\ -\x00\x00\x00\x00\x00\x00\x18\x30\x20\x40\x0b\x00\x00\x00\x00\x00\x00\x3e\ +\x00\x00\x00\x00\x00\x00\x18\x30\x20\x40\x0c\x00\x00\x00\x00\x00\x00\x3e\ \x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x38\x10\x08\x02\x02\x04\ \x04\x08\x08\x10\x10\x20\x20\x40\x40\x07\x00\x18\x24\x42\x42\x4a\x52\x42\ \x42\x24\x18\x07\x00\x08\x18\x28\x48\x08\x08\x08\x08\x08\x08\x07\x00\x3c\ @@ -118,13 +118,13 @@ class FontSmall(FontBase): \x3a\x02\x02\x42\x3c\x07\x00\x00\x00\x00\x7e\x02\x04\x08\x10\x20\x7e\x09\ \x06\x08\x08\x08\x08\x08\x30\x08\x08\x08\x08\x08\x06\x08\x10\x10\x10\x10\ \x10\x10\x10\x10\x10\x10\x10\x10\x09\x60\x10\x10\x10\x10\x10\x0c\x10\x10\ -\x10\x10\x10\x60\x03\x00\x00\x32\x4a\x44\x0d\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x03\x80\x03\x80\x03\x80\x00\x00\x0e\x00\x00\x00\x00\x00\x00\ -\x00\x00\x08\x00\x18\x00\x3f\xf8\x18\x00\x08\x00\x00\x00\x0e\x00\x00\x00\ -\x00\x00\x00\x00\x00\x00\x20\x00\x30\x3f\xf8\x00\x30\x00\x20\x00\x00\x0e\ -\x00\x00\x10\x00\x10\x00\x10\x00\x10\x20\x10\x30\x1f\xf8\x00\x30\x00\x20\ -\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2a\xa0\x00\ -\x00\ +\x10\x10\x10\x60\x03\x00\x00\x32\x4a\x44\x0b\x00\x00\x0e\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x03\x80\x03\x80\x03\x80\x00\x00\x0f\x00\x00\x00\ +\x00\x00\x00\x00\x00\x08\x00\x18\x00\x3f\xf8\x18\x00\x08\x00\x00\x00\x0f\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\x00\x30\x3f\xf8\x00\x30\x00\x20\ +\x00\x00\x0f\x00\x00\x10\x00\x10\x00\x10\x00\x10\x20\x10\x30\x1f\xf8\x00\ +\x30\x00\x20\x00\x00\x0d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x2a\xa0\x00\x00\ """ diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c index 6526886c..d76b8f0a 100644 --- a/stm32/COLDCARD_MK4/file_time.c +++ b/stm32/COLDCARD_MK4/file_time.c @@ -1,13 +1,13 @@ -// (c) Copyright 2020-2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. +// (c) Copyright 2020-2025 by Coinkite Inc. This file is covered by license found in COPYING-CC. // // AUTO-generated. // -// built: 2024-07-04 -// version: 6.3.3X +// built: 2025-01-17 +// version: 5.4.1 // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x58e43060UL; + return 0x5a312880UL; } diff --git a/stm32/COLDCARD_MK4/psramdisk.c b/stm32/COLDCARD_MK4/psramdisk.c index b1fc0d69..bcfa9ffc 100644 --- a/stm32/COLDCARD_MK4/psramdisk.c +++ b/stm32/COLDCARD_MK4/psramdisk.c @@ -501,28 +501,31 @@ static void psram_init_vfs(fs_user_mount_t *vfs, bool readonly) { // psram_memset4() // - static inline void -psram_memset4(void *dest_addr, uint32_t value, uint32_t byte_len) + static void +psram_memset4(void *dest_addr, uint32_t byte_len) { // Fast, aligned, and bug-fixing memset // - PSRAM can starve the internal bus with too many writes, too fast // - leads to a weird crash where SRAM bus (at least) is locked up, but flash works + // - and/or just call w/ interrupts off for reliable non-crashing behaviour uint32_t *dest = (uint32_t *)dest_addr; for(; byte_len; byte_len-=4, dest++) { - *dest = value; - - asm("nop; nop; nop;"); // tested value, do not reduce + *dest = 0x12345678; } } +// mp_obj_t psram_wipe_and_setup() +// mp_obj_t psram_wipe_and_setup(mp_obj_t unused_self) { // Erase and reformat filesystem // - you probably should unmount it, before calling this // Wipe contents for security. - psram_memset4(PSRAM_TOP_BASE, 0x12345678, BLOCK_SIZE * BLOCK_COUNT); + mp_uint_t before = disable_irq(); + psram_memset4(PSRAM_TOP_BASE, BLOCK_SIZE * BLOCK_COUNT); + enable_irq(before); // Build obj to handle blockdev protocol fs_user_mount_t vfs = {0}; diff --git a/stm32/COLDCARD_Q1/file_time.c b/stm32/COLDCARD_Q1/file_time.c index c4f85d15..513e7c16 100644 --- a/stm32/COLDCARD_Q1/file_time.c +++ b/stm32/COLDCARD_Q1/file_time.c @@ -1,13 +1,13 @@ -// (c) Copyright 2020-2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. +// (c) Copyright 2020-2025 by Coinkite Inc. This file is covered by license found in COPYING-CC. // // AUTO-generated. // -// built: 2024-09-12 -// version: 1.3.0Q +// built: 2025-02-07 +// version: 1.3.1Q // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x592c0860UL; + return 0x5a470860UL; } diff --git a/stm32/MK4-Makefile b/stm32/MK4-Makefile index a346daba..72c3cf55 100644 --- a/stm32/MK4-Makefile +++ b/stm32/MK4-Makefile @@ -19,7 +19,7 @@ LATEST_RELEASE = $(shell ls -t1 ../releases/*-mk4-*.dfu | head -1) # Our version for this release. # - caution, the bootrom will not accept version < 3.0.0 -VERSION_STRING = 6.3.4X +VERSION_STRING = 6.3.5X # keep near top, because defined default target (all) include shared.mk diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile index 9dce75d1..0e669dac 100644 --- a/stm32/Q1-Makefile +++ b/stm32/Q1-Makefile @@ -16,7 +16,7 @@ BOOTLOADER_DIR = q1-bootloader LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1) # Our version for this release. -VERSION_STRING = 6.3.4QX +VERSION_STRING = 6.3.5QX # Remove this closer to shipping. #$(warning "Forcing debug build") diff --git a/testing/conftest.py b/testing/conftest.py index 1acfd28e..980bd869 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -3,7 +3,7 @@ import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, bech32, pdb from subprocess import check_output from ckcc.protocol import CCProtocolPacker -from helpers import B2A, U2SAT, hash160, taptweak +from helpers import B2A, U2SAT, hash160, taptweak, addr_from_display_format from base58 import decode_base58_checksum from bip32 import BIP32Node from msg import verify_message @@ -560,7 +560,7 @@ def cap_screen_qr(cap_image): if orig_img.width == 128: # Mk3/4 - pull out just the QR, blow it up 16x - x, w = 2, 64 + x, w = 2, 66 img = orig_img.crop( (x, 0, x+w, w) ) img = ImageOps.expand(img, 16, 0) # add border img = img.resize( (256, 256)) @@ -595,6 +595,42 @@ def cap_screen_qr(cap_image): return doit +@pytest.fixture +def verify_qr_address(cap_screen_qr, cap_screen, is_q1): + # check we can read QR and that it has exact value expected + # plus text version of address, if any, is right. + from ckcc_protocol.constants import AFC_BECH32 + + def doit(addr_fmt, expect_addr=None): + qr = cap_screen_qr().decode('ascii') + + if (addr_fmt & AFC_BECH32) or (addr_fmt & AFC_BECH32M): + qr = qr.lower() + + # check text --if any-- matches QR contents + # - remove spaces and newlines + # - ok if no text, which happens when QR is productively using screen space + # - skips first line, which on Q shows the index number sometimes + # - insists on some spaces + full = cap_screen() + if is_q1: + txt = ''.join(full.split()[2:]).replace('~', '') + else: + txt = ''.join(full.split()) + + if txt: + assert txt == qr + if is_q1: + # addr is not spaced out on Mk4, but check it was on Q + assert (qr[0:4] + ' ' + qr[4:8]) in full, 'was not spaced out' + + if expect_addr is not None: + assert qr == expect_addr + + return qr + + return doit + @pytest.fixture(scope='module') def get_pp_sofar(sim_exec): # get entry value for bip39 passphrase @@ -945,8 +981,9 @@ def reset_seed_words(sim_exec, sim_execfile, simulator): @pytest.fixture() def settings_set(sim_exec): - def doit(key, val): - x = sim_exec("settings.set('%s', %r)" % (key, val)) + def doit(key, val, prelogin=False): + source = "from nvstore import SettingsObject;SettingsObject.prelogin()" if prelogin else "settings" + x = sim_exec("%s.set('%s', %r)" % (source, key, val)) assert x == '' return doit @@ -954,8 +991,9 @@ def settings_set(sim_exec): @pytest.fixture() def settings_get(sim_exec): - def doit(key, def_val=None): - cmd = f"RV.write(repr(settings.get('{key}', {def_val!r})))" + 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) assert 'Traceback' not in resp, resp return eval(resp) @@ -1829,6 +1867,7 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_ need_keypress(key_map["qr"]) time.sleep(0.3) try: + assert is_q1 file_type, data = readback_bbqr() if file_type == "J": return json.loads(data) @@ -1837,7 +1876,6 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_ else: raise NotImplementedError except: - raise res = cap_screen_qr().decode('ascii') try: return json.loads(res) @@ -1979,6 +2017,7 @@ def check_and_decrypt_backup(microsd_path): with open(xfn_path, "r") as f: res = f.read() + os.remove(xfn_path) return res return doit @@ -2156,6 +2195,7 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1): assert f"Output {i}:" == sa txt_amount, _, addr = sb.split("\n") + addr = addr_from_display_format(addr) assert txt_amount == f'{amount / 100000000:.8f} {chain}' if af == "p2pkh": if chain == "BTC": @@ -2298,8 +2338,9 @@ 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 from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable +from test_msg import verify_msg_sign_story, sign_msg_from_text, msg_sign_export, sign_msg_from_address from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn -from test_miniscript import offer_minsc_import +from test_miniscript import offer_minsc_import, get_cc_key, bitcoin_core_signer from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed from test_seed_xor import restore_seed_xor diff --git a/testing/constants.py b/testing/constants.py index 490eef6c..517e6774 100644 --- a/testing/constants.py +++ b/testing/constants.py @@ -44,6 +44,7 @@ addr_fmt_names = { AF_P2WSH: 'p2wsh', AF_P2WPKH_P2SH: 'p2wpkh-p2sh', AF_P2WSH_P2SH: 'p2wsh-p2sh', + AF_P2TR: "p2tr", } diff --git a/testing/devtest/check_decode.py b/testing/devtest/check_decode.py index 6ef46deb..91b3ece8 100644 --- a/testing/devtest/check_decode.py +++ b/testing/devtest/check_decode.py @@ -35,7 +35,10 @@ if 'destinations' in expect: for (val, addr), (idx, txo) in zip(expect['destinations'], p.output_iter()): assert val == txo.nValue txt = active_request.render_output(txo) - assert addr in txt + # normalize from display format + address = txt.split("\n")[-2] + assert address[0] == "\x02" + assert addr == address[1:] assert '%.8f'%(val/1E8) in txt if 'sw_inputs' in expect: diff --git a/testing/helpers.py b/testing/helpers.py index 8e17f4bd..eaf5443d 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -103,8 +103,11 @@ def xfp2str(xfp): from struct import pack return b2a_hex(pack(' 3 @@ -113,8 +116,12 @@ def parse_change_back(story): assert 'address' in lines[s+2] addrs = [] for y in range(s+3, len(lines)): - if not lines[y]: break - addrs.append(lines[y]) + line = lines[y].strip() + if line: + if line[0] == "\x02": + addrs.append(addr_from_display_format(line)) + if line.startswith(("3","2","1","m","n","tb1","bc1","bcrt")): + addrs.append(line) if len(addrs) >= 2: assert 'to addresses' in lines[s+2] diff --git a/testing/msg.py b/testing/msg.py index 442052e7..e9594502 100644 --- a/testing/msg.py +++ b/testing/msg.py @@ -22,11 +22,12 @@ RFC_SIGNATURE_TEMPLATE = '''\ def parse_signed_message(msg): - msplit = msg.strip().split("\n") - assert msplit[0] == "-----BEGIN BITCOIN SIGNED MESSAGE-----" - assert msplit[2] == "-----BEGIN BITCOIN SIGNATURE-----" - assert msplit[5] == "-----END BITCOIN SIGNATURE-----" - return msplit[1], msplit[3], msplit[4] + msplit = msg.strip().rsplit("\n", 4) + assert msplit[0].startswith("-----BEGIN BITCOIN SIGNED MESSAGE-----\n") + msg = msplit[0].replace("-----BEGIN BITCOIN SIGNED MESSAGE-----\n", "") + assert msplit[1] == "-----BEGIN BITCOIN SIGNATURE-----" + assert msplit[4] == "-----END BITCOIN SIGNATURE-----" + return msg, msplit[2], msplit[3] def sig_hdr_base(addr_fmt): diff --git a/testing/psbt.py b/testing/psbt.py index 6d39ca33..3c70e066 100644 --- a/testing/psbt.py +++ b/testing/psbt.py @@ -138,7 +138,8 @@ class BasicPSBTInput(PSBTSection): def __eq__(a, b): if a.sighash != b.sighash: - if a.sighash is not None and b.sighash is not None: + # no sighash == SIGHASH_ALL + if {a.sighash, b.sighash} != {None, 1}: return False rv = a.utxo == b.utxo and \ diff --git a/testing/test_addr.py b/testing/test_addr.py index 95256dc7..fc245c54 100644 --- a/testing/test_addr.py +++ b/testing/test_addr.py @@ -10,6 +10,7 @@ from ckcc_protocol.protocol import CCProtocolPacker from ckcc_protocol.constants import * from charcodes import KEY_QR from constants import msg_sign_unmap_addr_fmt +from helpers import addr_from_display_format @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'"]) @pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) @@ -29,7 +30,7 @@ def test_show_addr_usb(dev, press_select, addr_vs_path, path, addr_fmt, is_simul @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'", "m/0h/500h"]) @pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, - cap_story, cap_screen_qr, qr_quality_check, + cap_story, verify_qr_address, qr_quality_check, press_cancel, is_q1): time.sleep(0.1) @@ -45,8 +46,7 @@ def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, else: assert path in story - assert addr in story - assert addr in story.split('\n') + assert addr in addr_from_display_format(story.split("\n\n")[0]) # check expected addr was used addr_vs_path(addr, path, addr_fmt) @@ -55,9 +55,8 @@ def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, need_keypress(KEY_QR if is_q1 else '4') time.sleep(0.1) - qr = cap_screen_qr().decode('ascii') - assert qr == addr or qr == addr.upper() + verify_qr_address(addr_fmt, addr) @pytest.mark.bitcoind @pytest.mark.parametrize("addr_fmt", [ @@ -137,7 +136,7 @@ def test_show_addr_nfc(path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_m _, story = cap_story() split_story = story.split("\n\n") - story_addr = split_story[0] + story_addr = addr_from_display_format(split_story[0]) story_path = split_story[1][2:] # remove "= " if not is_q1: assert "Press (3) to share via NFC" in story diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py index b23e1823..5fb66dd6 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -2,13 +2,13 @@ # # Test the address explorer. # -# Only single-sig here. Multisig cases are elsewhere. +# Only single-sig here. Multisig cases are in test_multisig.py. # import pytest, time, io, csv, bech32 from ckcc_protocol.constants import * from bip32 import BIP32Node from base58 import decode_base58_checksum -from helpers import detruncate_address, hash160 +from helpers import detruncate_address, hash160, addr_from_display_format from charcodes import KEY_QR, KEY_LEFT, KEY_RIGHT from constants import MAX_BIP32_IDX @@ -53,7 +53,7 @@ def parse_display_screen(cap_story, is_mark3): d = dict() for path_raw, addr, empty in zip(*[iter(raw_addrs)]*3): path = path_raw.split(" =>")[0] - d[path] = addr + d[path] = addr_from_display_format(addr) assert len(d) == n return d return doit @@ -374,9 +374,10 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, @pytest.mark.parametrize('which_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR]) def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_address_explorer, need_keypress, cap_menu, parse_display_screen, validate_address, - cap_screen_qr, qr_quality_check, nfc_read_text, get_setting, + verify_qr_address, qr_quality_check, nfc_read_text, get_setting, press_select, press_cancel, is_q1, press_nfc, cap_story, - generate_addresses_file, settings_set, set_addr_exp_start_idx): + generate_addresses_file, settings_set, set_addr_exp_start_idx, + sign_msg_from_address): path, start_idx = path_sidx settings_set('aei', True if start_idx else False) @@ -436,8 +437,8 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad time.sleep(.5) # .2 not enuf m = cap_menu() - assert m[0] == 'Classic P2PKH' - assert m[1] == 'Segwit P2WPKH' + assert m[1] == 'Classic P2PKH' + assert m[0] == 'Segwit P2WPKH' assert m[2] == 'Taproot P2TR' assert m[3] == 'P2SH-Segwit' @@ -468,16 +469,12 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad assert 'Showing single addr' in body assert path in body - addr = body.split("\n")[3] + addr = addr_from_display_format(body.split("\n")[3]) addr_vs_path(addr, path, addr_fmt=which_fmt) need_keypress(KEY_QR if is_q1 else '4') - qr = cap_screen_qr().decode('ascii') - if which_fmt in (AF_P2WPKH, AF_P2TR): - assert qr == addr.upper() - else: - assert qr == addr + verify_qr_address(which_fmt, addr) if get_setting('nfc', 0): # this is actually testing NFC export in qr code menu @@ -500,6 +497,19 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad f_path, f_addr = next(addr_gen) assert f_path == path assert f_addr == addr + press_select() # file written + + # msg sign + time.sleep(.1) + title, body = cap_story() + if which_fmt == AF_P2TR: + assert "Press (0) to sign message with this key" not in body + else: + assert "Press (0) to sign message with this key" in body + need_keypress('0') + msg = "COLDCARD the rock solid HWW" + sign_msg_from_address(msg, addr, path, which_fmt, "sd", True) + press_cancel() else: n = 10 if (start_idx + n) > MAX_BIP32_IDX: @@ -523,9 +533,7 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad qr_addr_list = [] need_keypress(KEY_QR if is_q1 else '4') for i in range(n): - qr = cap_screen_qr().decode('ascii') - if which_fmt in (AF_P2WPKH, AF_P2TR): - qr = qr.lower() + qr = verify_qr_address(which_fmt) qr_addr_list.append(qr) need_keypress(KEY_RIGHT if is_q1 else "9") time.sleep(.5) diff --git a/testing/test_backup.py b/testing/test_backup.py index cf22468a..e293dc6e 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -2,23 +2,104 @@ # # Testing backups. # -import pytest, time, json, os, shutil +import pytest, time, json, os, shutil, re from constants import simulator_fixed_words, simulator_fixed_tprv from charcodes import KEY_QR from bip32 import BIP32Node from mnemonic import Mnemonic +@pytest.fixture +def override_bkpw(goto_home, pick_menu_item, cap_story, need_keypress, seed_story_to_words, + cap_menu, press_select, press_cancel, enter_complex, is_q1): + + def purge_current(exit=False): + time.sleep(.1) + title, story = cap_story() + if "(1) to forget current" in story: + need_keypress("1") + time.sleep(.1) + title, story = cap_story() + assert "Delete current stored password?" in story + press_select() + time.sleep(.1) + title, story = cap_story() + assert "(1) to forget current" not in story + if exit: + press_cancel() + + def doit(password=None, old_password=None): + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Danger Zone") + pick_menu_item("I Am Developer.") + pick_menu_item("BKPW Override") + time.sleep(.1) + title, story = cap_story() + current_bkpw = None + if "(2) to show current active backup password" in story: + need_keypress("2") + time.sleep(.1) + title, story = cap_story() + assert 'Anyone with knowledge of the password will be able to decrypt your backups.' in story + press_select() + time.sleep(.1) + title, current_bkpw = cap_story() + current_bkpw = current_bkpw.strip() + press_select() + + if old_password: + assert current_bkpw == old_password, "old_password mismatch" + + if password is None: + # purge current bkpw + purge_current(exit=True) + return + + # purge what was there from before + purge_current() + + need_keypress("0") + enter_complex(password, apply=False, b39pass=False) + + time.sleep(.1) + title, story = cap_story() + assert "(2) to show current active backup password" in story + need_keypress("2") + press_select() # are you sure? + time.sleep(.1) + title, story = cap_story() + new_current_bkpw = story.strip() + press_select() + + time.sleep(.1) + title, story = cap_story() + if ((3*" ") in password) and not is_q1: + assert password.replace(" ", " ") == new_current_bkpw + else: + assert new_current_bkpw == password + + assert "(1) to forget current password" in story + assert "(0) to change" in story + + return doit + @pytest.fixture def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, cap_story, need_keypress, cap_screen_qr, pass_word_quiz, get_setting, seed_story_to_words, press_cancel, is_q1, press_select, is_headless): - def doit(reuse_pw=False, save_pw=False, st=None, ct=False): + def doit(reuse_pw=None, save_pw=False, st=None, ct=False): # st -> seed type # ct -> cleartext backup if reuse_pw: - settings_set('bkpw', ' '.join('zoo' for _ in range(12))) + if isinstance(reuse_pw, list): + assert len(reuse_pw) == 12 + else: + assert reuse_pw is True # default + reuse_pw = ['zoo' for _ in range(12)] + + settings_set('bkpw', ' '.join(reuse_pw)) else: settings_remove('bkpw') @@ -55,13 +136,10 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, return # nothing more to be done if reuse_pw: - assert ' 1: zoo' in body - assert '12: zoo' in body + assert (' 1: %s' % reuse_pw[0]) in body + assert ('12: %s' % reuse_pw[-1]) in body press_select() words = ['zoo'] * 12 - - time.sleep(0.1) - title, body = cap_story() else: assert title == 'NO-TITLE' assert 'Record this' in body @@ -102,7 +180,7 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, @pytest.mark.qrcode @pytest.mark.parametrize('multisig', [False, 'multisig']) @pytest.mark.parametrize('st', ["b39pass", "eph", None]) -@pytest.mark.parametrize('reuse_pw', [False, True]) +@pytest.mark.parametrize('reuse_pw', [True, False]) @pytest.mark.parametrize('save_pw', [False, True]) @pytest.mark.parametrize('seedvault', [False, True]) @pytest.mark.parametrize('pass_way', ["qr", None]) @@ -113,7 +191,7 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre generate_ephemeral_words, set_bip39_pw, verify_backup_file, check_and_decrypt_backup, restore_backup_cs, clear_ms, seedvault, restore_main_seed, import_ephemeral_xprv, backup_system, - press_cancel, sim_exec, pass_way): + press_cancel, sim_exec, pass_way, garbage_collector): # Make an encrypted 7z backup, verify it, and even restore it! clear_ms() reset_seed_words() @@ -147,6 +225,10 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre time.sleep(.1) assert len(get_setting('multisig')) == 1 + if not reuse_pw: + # drop saved bkpw before we get to ephemeral settings + settings_remove("bkpw") + if st == "b39pass": xfp_pass = set_bip39_pw("coinkite", reset=False, seed_vault=seedvault) assert not get_setting('multisig', None) @@ -193,9 +275,12 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre print("filename %d: %s" % (copy, fn)) files.append(fn) + garbage_collector.append(microsd_path(fn)) # write extra copy. - need_keypress('2') + if not copy: + need_keypress('2') + time.sleep(.01) bk_a = open_microsd(files[0]).read() @@ -438,7 +523,6 @@ def test_seed_vault_backup(settings_set, reset_seed_words, generate_ephemeral_wo assert "Press (1) to save" in body press_cancel() time.sleep(.01) - assert get_setting('bkpw', 'xxx') == 'xxx' title, story = cap_story() assert "Backup file written:" in story fn = story.split("\n\n")[1] @@ -513,4 +597,40 @@ def test_clone_start(reset_seed_words, pick_menu_item, cap_story, goto_home): # TODO check file made is a good backup, with correct password +def test_bkpw_override(reset_seed_words, override_bkpw, goto_home, pick_menu_item, + cap_story, press_select, garbage_collector, microsd_path): + reset_seed_words() # clean slate + old_pw = None + test_cases = [ + " ".join(12 * ["elevator"]), + " ".join(12 * ["fever"]), + 32 * "a", + (16 * "0") + " " + (16 *"1"), + 64 * "Q", + (26 * "?") + "!@#$%^&*()", + ] + for pw in test_cases: + override_bkpw(pw, old_pw) + + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Backup") + pick_menu_item("Backup System") + time.sleep(1) + title, story = cap_story() + split_pw = pw.split(" ") + if len(split_pw) == 12: + assert (' 1: %s' % split_pw[0]) in story + assert ('12: %s' % split_pw[-1]) in story + else: + # not words of len 12 + assert ("%s...%s" % (pw[0], pw[-1])) in story + + press_select() + time.sleep(1) + title, story = cap_story() + assert "Backup file written" in story + garbage_collector.append(microsd_path(story.split("\n\n")[1])) + press_select() + # EOF diff --git a/testing/test_bbqr.py b/testing/test_bbqr.py index a4010b49..08ccc713 100644 --- a/testing/test_bbqr.py +++ b/testing/test_bbqr.py @@ -373,4 +373,26 @@ def test_psbt_static(file, goto_home, cap_story, scan_a_qr, press_select, assert res["complete"] is True assert rb.hex() == res["hex"] + +def test_verify_signed_msg(goto_home, need_keypress, scan_a_qr, cap_story): + goto_home() + need_keypress(KEY_QR) + + data = """\n\n\n \t \n-----BEGIN BITCOIN SIGNED MESSAGE----- +5b9e372262952ed399dcdd4f5f08458a6d2811f120cddcb4267099f68f60207c addresses.csv +-----BEGIN BITCOIN SIGNATURE----- +tb1qupyd58ndsh7lut0et0vtrq432jvu9jtdyws9n9 +KDOloGMDU3fv+Y3NRSe17SoO4uSKo9IUU2+baJ/pqaHZBuvmW6j5nnv/N4M5BCVawiUig/qzExZpFsA7ZKzlUmU= +-----END BITCOIN SIGNATURE-----\n\n\n\n""" + + actual_vers, parts = split_qrs(data, 'U', max_version=20) + + for p in parts: + scan_a_qr(p) + time.sleep(4.0 / len(parts)) # just so we can watch + + title, story = cap_story() + assert "Good signature by address" in story + + # EOF diff --git a/testing/test_decoders.py b/testing/test_decoders.py index 61bb0627..1081a5aa 100644 --- a/testing/test_decoders.py +++ b/testing/test_decoders.py @@ -194,4 +194,25 @@ def test_wif(data, try_decode): assert compressed == tcompressed assert testnet == ttestnet +@pytest.mark.parametrize('data', [ + '{"msg": "coinkite"}', + '{"msg": "coink\n\n\tite", "subpath": "m/99h"}', + '{"msg": "coinkite", "subpath": "m/96420h", "addr_fmt": "p2wpkh"}', +]) +def test_json_msg_sign(data, try_decode): + ft, vals = try_decode(data) + assert ft == "smsg" + assert vals[0] == data + + +@pytest.mark.parametrize('data', [ + "-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", + "\n\n-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", + "\n\n\t-----BEGIN BITCOIN SIGNED MESSAGE-----\ncoinkite\n-----BEGIN BITCOIN SIGNATURE-----\nmtHSVByP9EYZmB26jASDdPVm19gvpecb5R\nH3c6imctVKRRYC1zOBAitdb/PuoQ9j0xaR6qKXH5dQECZH5OuvvE7aoL6j/WOaR/CFq/+SvIZPAzIhvQYBizBUc=\n-----END BITCOIN SIGNATURE-----", +]) +def test_json_msg_verify(data, try_decode): + ft, vals = try_decode(data) + assert ft == "vmsg" + assert vals[0] == data + # EOF diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index c57c26f9..30a6817c 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -1385,10 +1385,13 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story, need_keypress, word_menu_entry, settings_set, confirm_tmp_seed, cap_menu, microsd_path, restore_main_seed, get_identity_story, press_select, - press_cancel): + press_cancel, settings_remove): reset_seed_words() + # disable seed vault + settings_remove("seedvault") + settings_remove("seeds") goto_eph_seed_menu() ephemeral_seed_disabled() @@ -1405,6 +1408,7 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story, title, story = cap_story() assert "FAILED" == title assert 'Cannot use master seed as temporary.' in story + assert 'tested recovery of your master seed' in story press_cancel() # go to ephemeral seed and then try to create new ephemeral seed from master @@ -1433,6 +1437,7 @@ def test_import_master_as_tmp(reset_seed_words, goto_eph_seed_menu, cap_story, title, story = cap_story() assert "FAILED" == title assert 'Cannot use master seed as temporary.' in story + assert 'tested recovery of your master seed' in story press_cancel() # now import same seed but represented as master extended key @@ -1482,8 +1487,13 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m time.sleep(0.1) need_keypress("6") # skip words press_select() - press_select() - time.sleep(.3) + time.sleep(.1) + _, story = cap_story() + if "Press (1) to store temporary seed" in story: + # seed vault enabled + press_select() # do not save + press_select() # new tmp seed + time.sleep(.2) m = cap_menu() assert m[1] == "Ready To Sign" assert m[0] == "[" + xfp2str(settings_get("xfp")) + "]" @@ -1509,9 +1519,11 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m def test_seed_vault_enable_on_tmp(generate_ephemeral_words, reset_seed_words, goto_eph_seed_menu, ephemeral_seed_disabled, verify_ephemeral_secret_ui, goto_home, cap_menu, - restore_main_seed, pick_menu_item, settings_set): - settings_set("seedvault", None) # disable seed vault + restore_main_seed, pick_menu_item, settings_remove): reset_seed_words() + # disable seed vault + settings_remove("seedvault") + settings_remove("seeds") goto_eph_seed_menu() ephemeral_seed_disabled() e_seed_words = generate_ephemeral_words(num_words=12, dice=False, diff --git a/testing/test_export.py b/testing/test_export.py index 15d801e0..46a7c8f7 100644 --- a/testing/test_export.py +++ b/testing/test_export.py @@ -547,15 +547,15 @@ def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, mi @pytest.mark.qrcode +@pytest.mark.parametrize('chain', ["BTC", "XTN"]) @pytest.mark.parametrize('acct_num', [ None, 0, 99, 8989]) -@pytest.mark.parametrize('use_nfc', [False, True]) -def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home, +def test_export_xpub(chain, acct_num, dev, cap_menu, pick_menu_item, goto_home, cap_story, need_keypress, enter_number, cap_screen_qr, - use_mainnet, nfc_read_text, is_q1, press_select, press_cancel, + settings_set, nfc_read_text, is_q1, press_select, press_cancel, press_nfc, expect_acctnum_captured): # XPUB's via QR - use_mainnet() - + settings_set("chain", chain) + chain_num = 0 if chain == "BTC" else 1 goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('Export Wallet') @@ -565,13 +565,13 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home for m in top_items: is_xfp = False if '-84' in m: - expect = "m/84h/0h/{acct}h" - elif '86' in m and 'P2TR' in m: - expect = "m/86h/0h/{acct}h" + expect = f"m/84h/{chain_num}h/{{acct}}h" + elif '86' in m: + expect = f"m/86h/{chain_num}h/{{acct}}h" elif '-44' in m: - expect = "m/44h/0h/{acct}h" + expect = f"m/44h/{chain_num}h/{{acct}}h" elif '49' in m: - expect = "m/49h/0h/{acct}h" + expect = f"m/49h/{chain_num}h/{{acct}}h" elif 'Master' in m: expect = "m" elif 'XFP' in m: @@ -581,17 +581,21 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home time.sleep(0.3) if is_xfp: got = cap_screen_qr().decode('ascii') - if use_nfc: - press_nfc() - assert got == xfp2str(simulator_fixed_xfp).upper() - press_cancel() + time.sleep(.1) + press_nfc() + time.sleep(.2) + nfc_got = nfc_read_text() + time.sleep(.2) + assert nfc_got == got == xfp2str(simulator_fixed_xfp).upper() + press_cancel() # cancel animation + press_cancel() # cancel QR continue time.sleep(0.3) title, story = cap_story() - assert expect in story + assert expect.format(acct=0) in story - if 'acct' in expect: + if expect != "m": assert "Press (1) to select account" in story if acct_num is not None: need_keypress('1') @@ -601,24 +605,52 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home expect = expect.format(acct=acct_num) title, story = cap_story() assert expect in story - assert "Press (1) to select account" not in story + assert "Press (1) to select account" in story - expect = expect.format(acct=0) - if not use_nfc: - press_select() - got_pub = cap_screen_qr().decode('ascii') - else: - if f'Press {KEY_NFC if is_q1 else "(3)"}' not in story: - raise pytest.skip("NFC disabled") + expect = expect.format(acct=0) + + press_select() + got_pub = cap_screen_qr().decode('ascii') + + if f'Press {KEY_NFC if is_q1 else "(3)"}' in story: assert 'NFC' in story press_nfc() time.sleep(0.2) - got_pub = nfc_read_text() + got_nfc_pub = nfc_read_text() time.sleep(0.1) - #press_select() + press_cancel() # cancel animation + press_cancel() # cancel QR + assert got_nfc_pub == got_pub - if got_pub[0] not in 'xt': - got_pub,*_ = slip132undo(got_pub) + time.sleep(.1) + _, story = cap_story() + assert got_pub[0] in 'xt' + if "Press (2)" in story: + if chain == "BTC": + assert f"{'z' if expect[:5] == 'm/84h' else 'y'}pub (SLIP-132)" in story + else: + assert f"{'v' if expect[:5] == 'm/84h' else 'u'}pub (SLIP-132)" in story + need_keypress("2") + time.sleep(.1) + _, story = cap_story() + assert ("%spub (BIP-32)" % ("x" if chain == "BTC" else "t")) in story + assert "Press (2)" in story + + press_select() + got_slip_pub = cap_screen_qr().decode('ascii') + got_unslip, *_ = slip132undo(got_slip_pub) + assert got_unslip == got_pub + + if f'Press {KEY_NFC if is_q1 else "(3)"}' in story: + assert 'NFC' in story + press_nfc() + time.sleep(0.2) + got_nfc_slip_pub = nfc_read_text() + time.sleep(0.1) + press_cancel() # cancel animation + assert got_slip_pub == got_nfc_slip_pub + + press_cancel() # cancel QR expect_acctnum_captured(acct_num) @@ -628,7 +660,6 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home if expect != 'm': wallet = wallet.subkey_for_path(expect[2:].replace('h', "'")) assert got.sec() == wallet.sec() - press_cancel() @pytest.mark.parametrize("chain", ["BTC", "XTN", "XRT"]) diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py index 6433af28..0af70548 100644 --- a/testing/test_miniscript.py +++ b/testing/test_miniscript.py @@ -769,7 +769,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, use_regtest, get_cc_key, import_miniscript, bitcoin_core_signer, import_duplicate, press_select, virtdisk_path, garbage_collector): - def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None, + def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, tapscript_threshold=False, add_own_pk=False, same_account=False, way="sd"): use_regtest() @@ -786,29 +786,14 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, wallet_name=f"watch_only_{script_type}_{M}of{N}", disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True ) - goto_home() - pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - pick_menu_item('Export XPUB') - time.sleep(0.5) - title, story = cap_story() - assert "extended public keys (XPUB) you would need to join a multisig wallet" in story - press_select() - need_keypress(str(cc_account)) # account - press_select() - xpub_obj = load_export(way, label="Multisig XPUB", is_json=True, sig_check=False) - template = xpub_obj[script_type +"_desc"] - acct_deriv = xpub_obj[script_type + '_deriv'] + me_pth = f"m/48h/1h/{cc_account}h/3h" + me = get_cc_key(me_pth) + ik = internal_key or ranged_unspendable_internal_key() if tapscript_threshold: - me = f"[{xpub_obj['xfp']}/{acct_deriv.replace('m/','')}]{xpub_obj[script_type]}/<0;1>/*" signers_xp = [me] + bitcoind_signers_xpubs assert len(signers_xp) == N - desc = f"tr({H},%s)" - if internal_key: - desc = desc.replace(H, internal_key) - elif r: - desc = desc.replace(H, f"r={r}") + desc = f"tr({ik},%s)" scripts = [] for c in itertools.combinations(signers_xp, M): @@ -826,7 +811,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, if add_own_pk: if len(scripts) < 8: if same_account: - cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<2;3>/*") + cc_key = get_cc_key(me_pth, subderiv="/<2;3>/*") else: cc_key = get_cc_key("m/86h/1h/1000h") cc_pk_leaf = f"pk({cc_key})" @@ -842,22 +827,17 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, else: if add_own_pk: if same_account: - ss = [get_cc_key("m/86h/1h/0h", subderiv="/<4;5>/*")] + bitcoind_signers_xpubs - cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<6;7>/*") + ss = [get_cc_key(me_pth, subderiv="/<4;5>/*")] + bitcoind_signers_xpubs + cc_key = get_cc_key(me_pth, subderiv="/<6;7>/*") else: ss = [get_cc_key("m/86h/1h/0h")] + bitcoind_signers_xpubs cc_key = get_cc_key("m/86h/1h/1000h") tmplt = f"sortedmulti_a({M},{','.join(ss)})" cc_pk_leaf = f"pk({cc_key})" - desc = f"tr({H},{{{tmplt},{cc_pk_leaf}}})" + desc = f"tr({ik},{{{tmplt},{cc_pk_leaf}}})" else: - desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) - - if internal_key: - desc = desc.replace(H, internal_key) - elif r: - desc = desc.replace(H, f"r={r}") + desc = f"tr({ik},sortedmulti_a({M},{me},{','.join(bitcoind_signers_xpubs)}))" name = "minisc" fname = None @@ -889,13 +869,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, assert "P2SH-P2WSH" in story # assert "Derivation:\n Varies (2)" in story press_select() # approve multisig import - if r == "@": - # unspendable key is generated randomly - # descriptors will differ - with pytest.raises(AssertionError): - import_duplicate(fname, way=way, data=data) - else: - import_duplicate(fname, way=way, data=data) + import_duplicate(fname, way=way, data=data) goto_home() pick_menu_item('Settings') pick_menu_item('Miniscript') @@ -915,20 +889,6 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, assert res[0]["success"] assert res[1]["success"] - if r and r != "@": - from pysecp256k1.extrakeys import keypair_create, keypair_xonly_pub, xonly_pubkey_parse - from pysecp256k1.extrakeys import xonly_pubkey_tweak_add, xonly_pubkey_serialize, xonly_pubkey_from_pubkey - H_xo = xonly_pubkey_parse(bytes.fromhex(H)) - r_bytes = bytes.fromhex(r) - kp = keypair_create(r_bytes) - kp_xo, kp_parity = keypair_xonly_pub(kp) - pk = xonly_pubkey_tweak_add(H_xo, xonly_pubkey_serialize(kp_xo)) - xo, xo_parity = xonly_pubkey_from_pubkey(pk) - internal_key_bytes = xonly_pubkey_serialize(xo) - internal_key_hex = internal_key_bytes.hex() - assert internal_key_hex in core_desc_object[0]["desc"] - assert internal_key_hex in core_desc_object[1]["desc"] - if funded: if script_type == "p2wsh": addr_type = "bech32" @@ -961,7 +921,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, @pytest.mark.parametrize("add_pk", [True, False]) @pytest.mark.parametrize("same_acct", [None, True, False]) @pytest.mark.parametrize("way", ["qr", "sd"]) -@pytest.mark.parametrize("M_N", [(3,4),(4,5),(5,6)]) +@pytest.mark.parametrize("M_N", [(3,4),(5,6)]) def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, load_export, bitcoind_miniscript, add_pk, same_acct, get_cc_key, @@ -1032,7 +992,7 @@ def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, @pytest.mark.parametrize("add_pk", [True, False]) @pytest.mark.parametrize('M_N', [(3, 15), (2, 2), (3, 5)]) @pytest.mark.parametrize('way', ["qr", "sd", "vdisk", "nfc"]) -@pytest.mark.parametrize('internal_type', ["unspend(", "xpub", "static"]) +@pytest.mark.parametrize('internal_type', ["unspend(", "xpub"]) def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, use_regtest, way, csa, address_explorer_check, add_pk, internal_type, skip_if_useless_way): @@ -1054,13 +1014,11 @@ def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, @pytest.mark.bitcoind @pytest.mark.parametrize("cc_first", [True, False]) -@pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) +@pytest.mark.parametrize("m_n", [(2,3), (32, 32)]) @pytest.mark.parametrize("way", ["qr", "sd"]) @pytest.mark.parametrize("internal_key_spendable", [ True, False, - "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", - "@", "tpubD6NzVbkrYhZ4WhUnV3cPSoRWGf9AUdG2dvNpsXPiYzuTnxzAxemnbajrATDBWhaAVreZSzoGSe3YbbkY2K267tK3TrRmNiLH2pRBpo8yaWm/<2;3>/*", "unspend(c72231504cf8c1bbefa55974db4e0cdac781049a9a81a87e7ff5beeb45b34d3d)/<0;1>/*" ]) @@ -1073,19 +1031,14 @@ def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, clear_miniscript() microsd_wipe() internal_key = None - r = None if internal_key_spendable is True: internal_key = get_cc_key("86h/0h/3h") - elif internal_key_spendable == "@": - r = "@" + elif isinstance(internal_key_spendable, str): - if len(internal_key_spendable) == 64: - r = internal_key_spendable - else: - internal_key = internal_key_spendable + internal_key = internal_key_spendable tapscript_wo, bitcoind_signers = bitcoind_miniscript( - M, N, "p2tr", internal_key=internal_key, r=r, + M, N, "p2tr", internal_key=internal_key, way=way ) @@ -1274,11 +1227,11 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi @pytest.mark.parametrize("desc", [ - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})#tpm3afjn", - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", + "tr(unspend(61350cde0f20e0268d0f33c22967863d9ebcbc3f448b78c9e83810d2152692e0)/<0;1>/*,{{sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})", + "tr(unspend(af042dea4fdb855b7b66732ce8512829d95bbf4963a7b28279d5a0b5b48e5bea)/<0;1>/*,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", "tr(tpubD6NzVbkrYhZ4XB7hZjurMYsPsgNY32QYGZ8YFVU7cy1VBRNoYpKAVuUfqfUFss6BooXRrCeYAdK9av2yFnqWXZaUMJuZdpE9Kuh6gubCVHu/<0;1>/*,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)})", - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", + "tr(unspend(f19573a10866ee9881769e24464f9a0e989c2cb8e585db385934130462abed90)/<0;1>/*,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)})", + "tr(unspend(dfed64ff493dca2ab09eadefaa0c88be8404908fa6eff869ff71c0d359d086b9)/<2;3>/*,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", "tr(unspend(b320077905d0954b01a8a328ea08c0ac3b4b066d1240f47a1b2c58651dcda4eb)/<0;1>/*,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", ]) def test_tapscript_import_export(clear_miniscript, pick_menu_item, cap_story, @@ -1543,7 +1496,7 @@ CHANGE_BASED_DESCS = [ ")" "))#a4nfkskx" ), - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#z5x7409w", + "tr(tpubD6NzVbkrYhZ4WhUnV3cPSoRWGf9AUdG2dvNpsXPiYzuTnxzAxemnbajrATDBWhaAVreZSzoGSe3YbbkY2K267tK3TrRmNiLH2pRBpo8yaWm/<2;3>/*,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})", "tr([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<66;67>/*,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#qqcy9jlr", ] @@ -1708,9 +1661,9 @@ def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, @pytest.mark.parametrize("desc", [ "wsh(or_d(pk(@A),and_v(v:pkh(@A),older(5))))", - "tr(%s,multi_a(2,@A,@A))" % H, - "tr(%s,{sortedmulti_a(2,@A,@A),pk(@A)})" % H, - "tr(%s,or_d(pk(@A),and_v(v:pkh(@A),older(5))))" % H, + "tr(@ik,multi_a(2,@A,@A))", + "tr(@ik,{sortedmulti_a(2,@A,@A),pk(@A)})", + "tr(@ik,or_d(pk(@A),and_v(v:pkh(@A),older(5))))", ]) def test_insane_miniscript(get_cc_key, pick_menu_item, cap_story, microsd_path, desc, import_miniscript, @@ -1718,6 +1671,7 @@ def test_insane_miniscript(get_cc_key, pick_menu_item, cap_story, cc_key = get_cc_key("84h/0h/0h") desc = desc.replace("@A", cc_key) + desc = desc.replace("@ik", ranged_unspendable_internal_key()) fname = "insane.txt" fpath = microsd_path(fname) with open(fpath, "w") as f: @@ -1737,7 +1691,7 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, scripts.append(f"pk({k})") tree = TREE[leaf_num] % tuple(scripts) - desc = f"tr({H},{tree})" + desc = f"tr({ranged_unspendable_internal_key()},{tree})" fname = "9leafs.txt" fpath = microsd_path(fname) with open(fpath, "w") as f: @@ -1752,7 +1706,7 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, @pytest.mark.parametrize("same_acct", [True, False]) @pytest.mark.parametrize("recovery", [True, False]) @pytest.mark.parametrize("leaf2_mine", [True, False]) -@pytest.mark.parametrize("internal_type", ["unspend(", "xpub", "static"]) +@pytest.mark.parametrize("internal_type", ["unspend(", "xpub"]) @pytest.mark.parametrize("minisc", [ "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", @@ -1816,10 +1770,10 @@ def test_minitapscript(leaf2_mine, recovery, minisc, clear_miniscript, goto_home if "@C" in minisc: minisc = minisc.replace("@C", core_keys[1]) - ik = H if internal_type == "unspend(": ik = f"unspend({os.urandom(32).hex()})/<2;3>/*" - elif internal_type == "xpub": + else: + assert internal_type == "xpub" ik = ranged_unspendable_internal_key(os.urandom(32)) if leaf2_mine: @@ -1932,7 +1886,7 @@ def test_minitapscript(leaf2_mine, recovery, minisc, clear_miniscript, goto_home address_explorer_check("sd", "bech32m", wo, "minitapscript") @pytest.mark.parametrize("desc", [ - "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})", + "tr(tpubD6NzVbkrYhZ4WhUnV3cPSoRWGf9AUdG2dvNpsXPiYzuTnxzAxemnbajrATDBWhaAVreZSzoGSe3YbbkY2K267tK3TrRmNiLH2pRBpo8yaWm/<2;3>/*,{{sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})", "wsh(sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*))", "sh(wsh(or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:multi_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),older(500)))))", ]) @@ -1982,7 +1936,7 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca if addr_fmt == "bech32": desc = f"wsh({minsc})" else: - desc = f"tr({H},{minsc})" + desc = f"tr({ranged_unspendable_internal_key()},{minsc})" name = "d_wrapper" fname = f"{name}.txt" @@ -2109,7 +2063,7 @@ def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" z = "wsh(or_d(pk([0f056943/48'/0'/0'/3']xpub6FQgdFZAHcAeDMVe9KxWoLMxziCjscCExzuKJhRSjM71CA9dUDZEGNgPe4S2SsRumCBXeaTBZ5nKz2cMDiK4UEbGkFXNipHLkm46inpjE9D/0/*),and_v(v:pkh([0f056943/48'/0'/0'/2']xpub6FQgdFZAHcAeAhQX2VvQ42CW2fDdKDhgwzhzXuUhWb4yfArmaZXkLbGS9W1UcgHwNxVESCS1b8BK8tgNYEF8cgmc9zkmsE45QSEvbwdp6Kr/0/*),older(100))))" - y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + y = f"tr({ranged_unspendable_internal_key()},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" fname_btc = "BTC.txt" fname_xtn = "XTN.txt" @@ -2201,7 +2155,7 @@ def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, ik = get_cc_key("84h/1h/100h", subderiv="/0/*") desc = f"tr({ik},{minisc})" else: - desc = f"tr({H},{minisc})" + desc = f"tr({ranged_unspendable_internal_key()},{minisc})" else: desc = f"wsh({minisc})" @@ -2251,7 +2205,7 @@ def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, virtdisk_path, import_miniscript, goto_home, press_select): name = "my_minisc" - minsc = f"tr({H},or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),after(100))))" + minsc = f"tr({ranged_unspendable_internal_key()},or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),after(100))))" use_regtest() clear_miniscript() @@ -2332,7 +2286,7 @@ def test_unique_name(clear_miniscript, use_regtest, offer_minsc_import, name = "my_name" x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" - y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + y = f"tr({ranged_unspendable_internal_key()},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" xd = json.dumps({"name": name, "desc": x}) title, story = offer_minsc_import(xd) @@ -2769,11 +2723,13 @@ def test_expanding_multisig(tmplt, clear_miniscript, goto_home, pick_menu_item, address_explorer_check("sd", af, wo, wname) +@pytest.mark.parametrize("blinded", [True, False]) def test_big_boy(use_regtest, clear_miniscript, bitcoin_core_signer, get_cc_key, microsd_path, garbage_collector, pick_menu_item, bitcoind, import_miniscript, press_select, - cap_story, cap_menu, load_export, start_sign, end_sign): + cap_story, cap_menu, load_export, start_sign, end_sign, blinded): # keys (@0,@4,@5) are more important (primary) than keys (@1,@2,@3) (secondary) # currently requires to tweak MAX_TR_SIGNERS = 33 + # with blinded=True, all co-signer keys are blinded (have no key origin info) tmplt = ( "tr(" "tpubD6NzVbkrYhZ4XgXS51CV3bhoP5dJeQqPhEyhKPDXBgEs64VdSyAfku99gtDXQzY6HEXY5Dqdw8Qud1fYiyewDmYjKe9gGJeDx7x936ur4Ju/<0;1>/*," # unspendable @@ -2797,6 +2753,10 @@ def test_big_boy(use_regtest, clear_miniscript, bitcoin_core_signer, get_cc_key, for i in range(1, 6): csigner, ckey = bitcoin_core_signer(f"co-signer-{i}") ckey = ckey.replace("/0/*", "") + + if blinded: + ckey = ckey.split("]")[-1] + csigner.keypoolrefill(20) cosigners.append(csigner) desc = desc.replace(f"@{i}", ckey) @@ -3009,3 +2969,138 @@ def test_single_key_miniscript(af, settings_set, clear_miniscript, goto_home, ge unspent = wo.listunspent() assert len(unspent) == 1 + + +@pytest.mark.parametrize("tmplt", [ + "wsh(or_d(pk(@0),and_v(v:pkh(@1),older(100))))", + f"tr({ranged_unspendable_internal_key()},or_d(pk(@0),and_v(v:pk(@1),older(100))))" +]) +@pytest.mark.parametrize("cc_sign", [False, True]) +@pytest.mark.parametrize("has_orig", [False, True]) +def test_originless_keys(tmplt, offer_minsc_import, get_cc_key, bitcoin_core_signer, bitcoind, + pick_menu_item, load_export, goto_home, cap_menu, clear_miniscript, + use_regtest, press_select, start_sign, end_sign, cap_story, cc_sign, + has_orig, address_explorer_check): + # can be both: + # a.) just ranged xpub without origin info -> xpub1/<0;1>/* + # b.) ranged xpub with its fp -> [xpub1_fp]xpub1/<0;1>/* + sequence = 100 + use_regtest() + clear_miniscript() + af = "bech32m" if "tr(" in tmplt else "bech32" + name = "originless" + + cc_key = get_cc_key("m/84h/1h/0h") + cs, ck = bitcoin_core_signer(name+"_signer") + originless_ck = ck.split("]")[-1] + + n = BIP32Node.from_hwif(originless_ck.split("/")[0]) # just extended key + fp_str = "[" + n.fingerprint().hex() + "]" + if has_orig: + originless_ck = fp_str + originless_ck + + desc = tmplt.replace("@0", cc_key) + desc = desc.replace("@1", originless_ck) + to_import = {"desc": desc, "name": name} + offer_minsc_import(json.dumps(to_import)) + press_select() + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + menu = cap_menu() + assert menu[0] == name + pick_menu_item(menu[0]) # pick imported descriptor miniscript wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + # fund wallet + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + unspent = wo.listunspent() + assert len(unspent) == 1 + + if cc_sign: + inputs = [] + else: + inputs = [{"txid": unspent[0]["txid"], "vout": unspent[0]["vout"], "sequence": sequence}] + + # split to 10 utxos + dest_addrs = [wo.getnewaddress(f"a{i}", af) for i in range(10)] + psbt_resp = wo.walletcreatefundedpsbt( + inputs, + [{a: 4} for a in dest_addrs] + [{bitcoind.supply_wallet.getnewaddress(): 5}], + 0, + {"fee_rate": 3, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if cc_sign: + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + final_psbt = end_sign(True) + final_psbt = base64.b64encode(final_psbt).decode() + else: + final_psbt_o = cs.walletprocesspsbt(psbt, True, "DEFAULT" if af == "bech32m" else "ALL") + final_psbt = final_psbt_o["psbt"] + assert psbt != final_psbt + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + if not cc_sign: + # timelocked + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + + # mines some blocks to release the lock + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", af, wo, name) + + +@pytest.mark.parametrize("internal_key", [ + H, + "r=@", + "r=dfed64ff493dca2ab09eadefaa0c88be8404908fa6eff869ff71c0d359d086b9", + "f19573a10866ee9881769e24464f9a0e989c2cb8e585db385934130462abed90" +]) +def test_static_internal_key(internal_key, clear_miniscript, microsd_path, pick_menu_item, + cap_story, import_miniscript, garbage_collector): + clear_miniscript() + desc = "tr(@ik,{{sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})" + desc = desc.replace("@ik", internal_key) + fname = "imdesc.txt" + fpath = microsd_path(fname) + with open(microsd_path(fname), "w") as f: + f.write(desc) + garbage_collector.append(fpath) + + title, story = import_miniscript(fname) + assert "Failed to import" in story + assert "only extended keys allowed" in story \ No newline at end of file diff --git a/testing/test_msg.py b/testing/test_msg.py index 8540f6ad..6c2cb17b 100644 --- a/testing/test_msg.py +++ b/testing/test_msg.py @@ -2,13 +2,40 @@ # # Message signing. # -import pytest, time, os, itertools, hashlib +import pytest, time, os, itertools, hashlib, json from bip32 import BIP32Node from msg import verify_message, RFC_SIGNATURE_TEMPLATE, sign_message, parse_signed_message from base64 import b64encode, b64decode from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, CCUserRefused 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 + + +def addr_fmt_from_subpath(subpath): + if not subpath: + af = AF_CLASSIC + elif subpath[:4] == "m/84": + af = AF_P2WPKH + elif subpath[:4] == "m/49": + af = AF_P2WPKH_P2SH + else: + af = AF_CLASSIC + return af + +def default_derivation_by_af(addr_fmt, testnet=True): + b44ct = "1" if testnet else "0" + if addr_fmt == AF_CLASSIC: + path = "m/44h/{chain}h/0h/0/0" + elif addr_fmt == AF_P2WPKH_P2SH: + path = "m/49h/{chain}h/0h/0/0" + elif addr_fmt == AF_P2WPKH: + path = "m/84h/{chain}h/0h/0/0" + else: + assert False, "unsupported address format" + + return path.format(chain=b44ct) @pytest.mark.parametrize('msg', [ 'aZ', 'hello', 'abc def eght', "x"*140, 'a'*240]) @@ -54,6 +81,200 @@ def test_sign_msg_refused(dev, press_cancel): done = dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) +@pytest.fixture +def verify_msg_sign_story(): + def doit(story, msg, subpath=None, addr_fmt=None, testnet=True, addr=None): + assert story.startswith('Ok to sign this?') + assert msg in story + assert 'Using the key associated' in story + + if addr: + assert addr == addr_from_display_format(story.split("\n\n")[2].split("\n")[-1]) + + if not subpath: + assert 'm =>' not in story + subpath = default_derivation_by_af(addr_fmt or AF_CLASSIC, testnet) + else: + subpath = subpath.lower().replace("'", "h") + + assert ('%s =>' % subpath) in story + return subpath + + return doit + + +@pytest.fixture +def msg_sign_export(cap_story, press_nfc, nfc_read_text, press_select, press_cancel, + readback_bbqr, cap_screen_qr, need_keypress, microsd_path, + virtdisk_path, is_q1, OK): + def doit(way, qr_only=False): + time.sleep(.1) + title, story = cap_story() + + if way == "sd": + if "Press (1) to save Signed Msg" in story: + need_keypress("1") + + elif way == "nfc": + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.xfail("NFC disabled") + else: + press_nfc() + time.sleep(0.2) + signed_msg = nfc_read_text() + time.sleep(0.3) + press_cancel() + time.sleep(.1) + title, story = cap_story() + assert f"Press {OK} to share again" in story + press_cancel() + + elif way == "qr": + if not is_q1: + pytest.xfail("QR disabled") + + if not qr_only: + need_keypress(KEY_QR) + + time.sleep(.1) + title, story = cap_story() + assert "Press ENTER to export signature QR only" in story + assert "(0) to export full RFC template" in story + press_select() + time.sleep(.1) + sig_only = cap_screen_qr().decode('ascii') + press_select() + time.sleep(.1) + need_keypress("0") + time.sleep(.1) + file_type, signed_msg = readback_bbqr() + signed_msg = signed_msg.decode() + assert file_type == "U" + assert sig_only in signed_msg + press_select() + press_cancel() + + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.xfail("Vdisk disabled") + else: + need_keypress("2") + + if way in ("sd", "vdisk"): + path_f = microsd_path if way == "sd" else virtdisk_path + time.sleep(.1) + title, story = cap_story() + fname = story.split("\n\n")[-1] + with open(path_f(fname), "r") as f: + signed_msg = f.read() + + return signed_msg + + return doit + + +@pytest.fixture +def sign_msg_from_text(pick_menu_item, enter_number, press_select, + cap_story, need_keypress, settings_set, is_q1, + addr_vs_path, bitcoind, msg_sign_export, + verify_msg_sign_story, OK): + # used when signing note/passwords misc content + # used after simple text QR scan + # expects to start at menu which offers different single sig address formats + + def doit(msg, addr_fmt, acct, change, idx, way, chain="XTN", qr_only=False): + settings_set("chain", chain) + path = "m" + # pick address format from menu + if addr_fmt == AF_CLASSIC: + path += "/44h" + af_label = "Classic P2PKH" + elif addr_fmt == AF_P2WPKH: + path += "/84h" + af_label = "Segwit P2WPKH" + else: + path += "/49h" + af_label = "P2SH-Segwit" + + pick_menu_item(af_label) + + # chain - no user input - depends on current active settings + if chain == "BTC": + path += "/0h" + else: + path += "/1h" + + # pick account + if acct is None: + path += "/0h" + press_select() + else: + path += ("/%dh" % acct) + enter_number(acct) + + time.sleep(.1) + title, story = cap_story() + assert title == "Change?" + assert "Press (0) to use internal/change address" in story + assert f"{OK} to use external/receive address" in story + if change: + path += "/1" + need_keypress("0") + else: + path += "/0" + press_select() + + # index num + if idx is None: + path += "/0" + press_select() + else: + path += ("/%d" % idx) + enter_number(idx) + + time.sleep(.1) + title, story = cap_story() + path = verify_msg_sign_story(story, msg, path, addr_fmt, testnet=True if chain == "XTN" else False) + press_select() + + signed_msg = msg_sign_export(way, qr_only) + + ret_msg, addr, sig = parse_signed_message(signed_msg) + addr_vs_path(addr, path, addr_fmt, chain=chain) + assert verify_message(addr, sig, ret_msg) is True + if addr_fmt == AF_CLASSIC and chain == "XTN": + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res is True + + return doit + + +@pytest.fixture +def sign_msg_from_address(need_keypress, scan_a_qr, press_select, enter_complex, cap_story, + addr_vs_path, verify_msg_sign_story, msg_sign_export): + def doit(msg, addr, subpath, addr_fmt, way=None, testnet=True): + if way == 'qr': + # scan text via QR + need_keypress(KEY_QR) + scan_a_qr(msg) + time.sleep(1) + press_select() + else: + enter_complex(msg, b39pass=False) + + time.sleep(.1) + title, story = cap_story() + verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet, addr) + press_select() + time.sleep(.1) + signed_msg = msg_sign_export(way) + ret_msg, addr, sig = parse_signed_message(signed_msg) + addr_vs_path(addr, subpath, addr_fmt, chain="XTN" if testnet else "BTC") + + return doit + + @pytest.mark.parametrize('path,expect', [ ('1/1hard/2', 'invalid characters'), ('m/m/m/1/1hard/2', 'invalid characters'), @@ -78,24 +299,36 @@ def test_bad_paths(dev, path, expect): @pytest.fixture def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, - press_select, microsd_path): + press_select, microsd_path, verify_msg_sign_story): # sign a file on the microSD card - def doit(msg, subpath=None, addr_fmt=None, expect_fail=False): - fname = 't-msgsign.txt' + def doit(msg, subpath="", addr_fmt=None, expect_fail=False, testnet=True, + use_json=False): + + suffix = "json" if use_json else "txt" + fname = f't-msgsign.{suffix}' result_fname = 't-msgsign-signed.txt' # cleanup try: os.unlink(microsd_path(result_fname)) except OSError: pass + with open_microsd(fname, 'wt') as sd: - sd.write(msg + '\n') - if subpath is not None: - sd.write(subpath + '\n') - if addr_fmt is not None: - sd.write(addr_fmt_names[addr_fmt] + '\n') + if use_json: + res = {"msg": msg} + if subpath: + res["subpath"] = subpath + if addr_fmt is not None: + res["addr_fmt"] = addr_fmt_names[addr_fmt] + sd.write(json.dumps(res)) + else: + sd.write(msg + '\n') + if subpath or addr_fmt: + sd.write((subpath or "") + '\n') + if addr_fmt is not None: + sd.write(addr_fmt_names[addr_fmt]) goto_home() pick_menu_item('Advanced/Tools') @@ -115,17 +348,8 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, assert not story.startswith('Ok to sign this?') return story - assert story.startswith('Ok to sign this?') - - assert msg in story - assert 'Using the key associated' in story - if not subpath: - assert 'm =>' in story - else: - x_subpath = subpath.lower().replace("'", "h") - assert ('%s =>' % x_subpath) in story - - press_select() + verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet) + press_select() # confirm msg sign # wait for it to finish for r in range(10): @@ -135,26 +359,23 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, else: assert False, 'timed out' - lines = [i.strip() for i in open_microsd(result_fname, 'rt').readlines()] + with open_microsd(result_fname, 'rt') as f: + res = f.read() - assert lines[0] == '-----BEGIN BITCOIN SIGNED MESSAGE-----' - assert lines[1:-4] == [msg] - assert lines[-4] == '-----BEGIN BITCOIN SIGNATURE-----' - addr = lines[-3] - sig = lines[-2] - assert lines[-1] == '-----END BITCOIN SIGNATURE-----' - - return sig, addr + ret_msg, addr, sig = parse_signed_message(res) + assert ret_msg == msg + return sig, addr, msg return doit -@pytest.mark.parametrize('msg', [ 'ab', 'hello', 'abc def eght', "x"*140, 'a'*240]) +@pytest.mark.bitcoind # only for testnet and p2pkh +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize('msg', [ 'ab', 'abc def eght', 'a'*240]) @pytest.mark.parametrize('path', [ "m/84'/0'/22'", None, 'm', "m/1/2", - "m/1'/100'", 'm/23h/22h', ]) @pytest.mark.parametrize('addr_fmt', [ @@ -163,29 +384,53 @@ def sign_on_microsd(open_microsd, cap_story, pick_menu_item, goto_home, AF_CLASSIC, AF_P2WPKH_P2SH, ]) -def test_sign_msg_microsd_good(sign_on_microsd, msg, path, addr_vs_path, addr_fmt): - - if (path is None) and (addr_fmt is not None): - # must give path if addr fmt is to be specified - return +@pytest.mark.parametrize("testnet", [True, False]) +def test_sign_msg_microsd_good(sign_on_microsd, msg, path, addr_vs_path, + addr_fmt, testnet, settings_set, bitcoind, + use_json): + settings_set("chain", "XTN" if testnet else "BTC") # cases we expect to work - sig, addr = sign_on_microsd(msg, path, addr_fmt) + sig, addr, ret_msg = sign_on_microsd(msg, path, addr_fmt, testnet=testnet, + use_json=use_json) + assert msg == ret_msg raw = b64decode(sig) assert 40 <= len(raw) <= 65 - if path is None: - path = 'm' + if addr_fmt is None: + addr_fmt = addr_fmt_from_subpath(path) + + if not path: + path = default_derivation_by_af(addr_fmt, testnet=testnet) # check expected addr was used - addr_vs_path(addr, path, addr_fmt) + addr_vs_path(addr, path, addr_fmt, chain="XTN" if testnet else "BTC") assert verify_message(addr, sig, msg) is True + if addr_fmt == AF_CLASSIC and testnet: + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res is True @pytest.fixture -def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story): - def doit(body, expect_fail=True): +def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story, press_select, + nfc_read_text, addr_vs_path, press_cancel, OK, verify_msg_sign_story): + def doit(msg, subpath=None, addr_fmt=None, expect_fail=False, use_json=False, + testnet=True): + if use_json: + res = {"msg": msg} + if subpath: + res["subpath"] = subpath + if addr_fmt is not None: + res["addr_fmt"] = addr_fmt_names[addr_fmt] + body = json.dumps(res) + else: + body = msg + "\n" + if subpath or addr_fmt: + body += ((subpath or "") + '\n') + if addr_fmt is not None: + body += addr_fmt_names[addr_fmt] + goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('NFC Tools') @@ -194,48 +439,115 @@ def sign_using_nfc(goto_home, pick_menu_item, nfc_write_text, cap_story): time.sleep(0.5) if expect_fail: return cap_story() - raise NotImplementedError + + if not addr_fmt: + addr_fmt = addr_fmt_from_subpath(subpath) + + if not subpath: + subpath = default_derivation_by_af(addr_fmt, testnet=testnet) + + _, story = cap_story() + subpath = verify_msg_sign_story(story, msg, subpath, addr_fmt, testnet) + press_select() + signed_msg = nfc_read_text() + if "BITCOIN SIGNED MESSAGE" not in signed_msg: + # missed it? again + signed_msg = nfc_read_text() + press_select() # exit NFC animation + pmsg, addr, sig = parse_signed_message(signed_msg) + assert pmsg == msg + addr_vs_path(addr, subpath, addr_fmt, chain="XTN" if testnet else "BTC") + assert verify_message(addr, sig, msg) is True + time.sleep(0.5) + _, story = cap_story() + assert f"Press {OK} to share again" in story + press_select() + signed_msg_again = nfc_read_text() + assert signed_msg == signed_msg_again + press_cancel() # exit NFC animation + press_cancel() # do not want to share again + + return sig, addr, msg return doit -@pytest.mark.parametrize('msg,concern,no_file', [ - ('', 'too short', 0), # zero length not supported - ('a'*1000, 'too long', 1), # too big, won't even be offered as a file - ('a'*300, 'too long', 0), # too big - ('a'*241, 'too long', 0), # too big - ('hello%20sworld'%'', 'many spaces', 0), # spaces - ('hello%10sworld'%'', 'many spaces', 0), # spaces - ('hello%5sworld'%'', 'many spaces', 0), # spaces - ('test\ttest', "must be ascii printable", 0), - ('testêtest', "must be ascii printable", 0), -]) -@pytest.mark.parametrize('transport', ['sd', 'usb', 'nfc']) -def test_sign_msg_fails(dev, sign_on_microsd, msg, concern, no_file, transport, sign_using_nfc, path='m/12/34'): +@pytest.mark.bitcoind +@pytest.mark.parametrize("way", ["nfc", "sd"]) +@pytest.mark.parametrize("msg", ['test\ttest', "\n\n\tmsg\n\n\tsigning"]) +def test_sign_msg_with_ascii_non_printable_chars(msg, way, sign_on_microsd, addr_vs_path, + settings_set, bitcoind, sign_using_nfc): + # only works with the JSON format + settings_set("chain", "XTN") + if way == "sd": + sig, addr, ret_msg = sign_on_microsd(msg, "", None, use_json=True) + else: + sig, addr, ret_msg = sign_using_nfc(msg, "", None, use_json=True) + + assert ret_msg == msg + raw = b64decode(sig) + assert 40 <= len(raw) <= 65 + + addr_fmt = AF_CLASSIC + path = default_derivation_by_af(addr_fmt, testnet=True) + + # check expected addr was used + addr_vs_path(addr, path, addr_fmt) + assert verify_message(addr, sig, msg) is True + res = bitcoind.rpc.verifymessage(addr, sig, msg) + assert res is True + + +@pytest.mark.parametrize('msg,subpath,addr_fmt,concern,no_file,no_json', [ + ('', "m", AF_CLASSIC, 'too short', 0, 0), # zero length not supported + ('a'*1000, "m/1", AF_P2WPKH,'too long', 1, 0), # too big, won't even be offered as a file + ('a'*241, "m/400", AF_P2WPKH_P2SH, 'too long', 0, 0), # too big + ('hello%20sworld'%'', "m", AF_CLASSIC, 'many spaces', 0, 0), # spaces + ('hello%10sworld'%'', "m/1h/3h", AF_P2WPKH_P2SH, 'many spaces', 0, 0), # spaces + ('hello%5sworld'%'', "m", AF_CLASSIC, 'many spaces', 0, 0), # spaces + ("coinkite", "m", AF_P2WSH, "Unsupported address format", 0, 0), # invalid address format + ("coinkite", "m", AF_P2WSH_P2SH, "Unsupported address format", 0, 0), # invalid address format + ("coinkite", " m", AF_P2TR, "Unsupported address format", 0, 0), # invalid address format + ("coinkite", "m/0/0/0/0/0/0/0/0/0/0/0/0/0", AF_CLASSIC, "too deep", 0, 0), # invalid path + ("coinkite", "m/0/0/0/0/0/q/0/0/0", AF_P2WPKH, "invalid characters in path", 0, 0), # invalid path + ("coinkite ", "m", AF_CLASSIC, "trailing space(s)", 0, 0), # invalid msg - trailing space + (" coinkite", "m", AF_P2WPKH_P2SH, "leading space(s)", 0, 0), # invalid msg - leading space + ('testêtest', "m", AF_P2WPKH, "must be ascii", 0, 0), + # below works only with the JSON format + ('test\ttest', "m", AF_CLASSIC, "must be ascii printable", 0, 1), +]) +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize('transport', ['sd', 'usb', 'nfc']) +def test_sign_msg_fails(dev, sign_on_microsd, msg, subpath, addr_fmt, concern, + no_file, no_json, transport, sign_using_nfc, use_json): + if use_json and no_json: + # special cases with ascii non printable characters - can be present in json + raise pytest.skip("json can contain ASCII non-printable in msg") if transport == 'usb': with pytest.raises(CCProtoError) as ee: try: encoded_msg = msg.encode('ascii') except UnicodeEncodeError: encoded_msg = msg.encode() - dev.send_recv(CCProtocolPacker.sign_message(encoded_msg, path), timeout=None) + dev.send_recv(CCProtocolPacker.sign_message(encoded_msg, subpath, addr_fmt), timeout=None) story = ee.value.args[0] elif transport == 'sd': try: - story = sign_on_microsd(msg, path, expect_fail=True) + story = sign_on_microsd(msg, subpath, addr_fmt, expect_fail=True, use_json=use_json) assert story.startswith('Problem: ') except AssertionError as e: if no_file: assert ("No suitable files found" in str(e)) or story == 'NO-FILE' return elif transport == 'nfc': - title, story = sign_using_nfc(msg, expect_fail=True) + title, story = sign_using_nfc(msg, subpath, addr_fmt, expect_fail=True, use_json=use_json) assert title == 'ERROR' or "Problem" in story else: raise ValueError(transport) assert concern in story + @pytest.mark.parametrize('msg,num_iter,expect', [ ('Test2', 1, 'IHra0jSywF1TjIJ5uf7IDECae438cr4o3VmG6Ri7hYlDL+pUEXyUfwLwpiAfUQVqQFLgs6OaX0KsoydpuwRI71o='), ('Test', 2, 'IDgMx1ljPhLHlKUOwnO/jBIgK+K8n8mvDUDROzTgU8gOaPDMs+eYXJpNXXINUx5WpeV605p5uO6B3TzBVcvs478='), @@ -280,32 +592,16 @@ def test_low_R_cases(msg, num_iter, expect, dev, set_seed_words, use_mainnet, assert sig == expect -@pytest.mark.parametrize("body", [ - "coinkite\nm\np2wsh", # invalid address format - "coinkite\nm\np2sh-p2wsh", # invalid address format - "coinkite\nm\np2tr", # invalid address format - "coinkite\nm/0/0/0/0/0/0/0/0/0/0/0/0/0\np2pkh", # invalid path - "coinkite\nm/0/0/0/0/0/q/0/0/0\np2pkh", # invalid path - "coinkite yes!\nm\np2pkh", # invalid msg - too many spaces - "c\nm\np2pkh", # invalid msg - too short - "coinkite \nm\np2pkh", # invalid msg - trailing space - " coinkite\nm\np2pkh", # invalid msg - leading space -]) -def test_nfc_msg_signing_invalid(body, goto_home, pick_menu_item, nfc_write_text, cap_story): - goto_home() - pick_menu_item('Advanced/Tools') - pick_menu_item('NFC Tools') - pick_menu_item('Sign Message') - nfc_write_text(body) - time.sleep(0.5) - title, story = cap_story() - assert title == 'ERROR' or "Problem" in story -@pytest.mark.parametrize("msg", ["coinkite", "Coldcard Signing Device!", 200 * "a"]) -@pytest.mark.parametrize("path", ["", "m/84'/0'/0'/300/0", "m/800h/0h", "m/0/0/0/0/1/1/1"]) -@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh"]) -def test_nfc_msg_signing(msg, path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_menu_item, - goto_home, cap_story, press_select, press_cancel, addr_vs_path, OK): +@pytest.mark.bitcoind # only for testnet and p2pkh +@pytest.mark.parametrize("testnet", [True, False]) +@pytest.mark.parametrize("use_json", [True, False]) +@pytest.mark.parametrize("msg", ["Coldcard Signing Device!", 200 * "a"]) +@pytest.mark.parametrize("path", ["", "m/84h/0h/0h/300/0", "m/0/0/0/0/1/1/1"]) +@pytest.mark.parametrize("addr_fmt", [AF_CLASSIC, None, AF_P2WPKH, AF_P2WPKH_P2SH]) +def test_nfc_msg_signing(msg, path, addr_fmt, testnet, settings_set, bitcoind, use_json, + sign_using_nfc, goto_home): + settings_set("chain", "XTN" if testnet else "BTC") for _ in range(5): # need to wait for ApproveMessageSign to be popped from ux stack @@ -315,43 +611,14 @@ def test_nfc_msg_signing(msg, path, str_addr_fmt, nfc_write_text, nfc_read_text, except: time.sleep(0.5) - pick_menu_item('Advanced/Tools') - pick_menu_item('NFC Tools') - pick_menu_item('Sign Message') - if str_addr_fmt != "": - addr_fmt = msg_sign_unmap_addr_fmt[str_addr_fmt] - body = "\n".join([msg, path, str_addr_fmt]) - else: - addr_fmt = AF_CLASSIC - body = "\n".join([msg, path]) - - nfc_write_text(body) - time.sleep(0.5) - _, story = cap_story() - assert "Ok to sign this?" in story - assert msg in story - assert path.replace("'", "h") in story - press_select() - signed_msg = nfc_read_text() - if "BITCOIN SIGNED MESSAGE" not in signed_msg: - # missed it? again - signed_msg = nfc_read_text() - press_select() # exit NFC animation - pmsg, addr, sig = parse_signed_message(signed_msg) - assert pmsg == msg - addr_vs_path(addr, path, addr_fmt) - assert verify_message(addr, sig, msg) is True - time.sleep(0.5) - _, story = cap_story() - assert f"Press {OK} to share again" in story - press_select() - signed_msg_again = nfc_read_text() - assert signed_msg == signed_msg_again - press_cancel() # exit NFC animation - press_cancel() # do not want to share again + addr, sig, ret_msg = sign_using_nfc(msg, path, addr_fmt, testnet=testnet, use_json=use_json) + assert msg == ret_msg + if addr_fmt == AF_CLASSIC and testnet: + res = bitcoind.rpc.verifymessage(sig, addr, ret_msg) + assert res is True @pytest.fixture -def verify_armored_signature(pick_menu_item, nfc_write_text, press_select, +def verify_armored_signature(pick_menu_item, nfc_write_text, cap_story, goto_home): def doit(way, fname=None, signed_msg=None): goto_home() @@ -383,7 +650,8 @@ def test_verify_signature_file(way, addr_fmt, path, msg, sign_on_microsd, goto_h cap_story, bitcoind, microsd_path, nfc_write_text, verify_armored_signature, chain, settings_set): settings_set("chain", chain) - sig, addr = sign_on_microsd(msg, path, msg_sign_unmap_addr_fmt[addr_fmt]) + sig, addr, ret_msg = sign_on_microsd(msg, path, msg_sign_unmap_addr_fmt[addr_fmt]) + assert ret_msg == msg fname = 't-msgsign-signed.txt' should = RFC_SIGNATURE_TEMPLATE.format(addr=addr, sig=sig, msg=msg) with open(microsd_path(fname), "r") as f: @@ -392,7 +660,7 @@ def test_verify_signature_file(way, addr_fmt, path, msg, sign_on_microsd, goto_h title, story = verify_armored_signature(way, fname, should) assert title == "CORRECT" assert "Good signature" in story - assert addr in story + assert addr == addr_from_display_format(story.split("\n")[-1]) if (addr_fmt == "p2pkh") and (chain != "BTC"): res = bitcoind.rpc.verifymessage(addr, sig, msg) assert res is True @@ -671,4 +939,111 @@ def test_verify_signature_file_truncated(way, microsd_path, cap_story, verify_ar assert "Armor text MUST be surrounded by exactly five (5) dashes" in story assert "auth.py" in story + +@pytest.mark.parametrize("msg", ["this is the message to sign", "this is meessage to sign\n with newline", "a"*200]) +@pytest.mark.parametrize("addr_fmt", [AF_CLASSIC, AF_P2WPKH]) +@pytest.mark.parametrize("acct", [None, 5555]) +def test_sign_scanned_text(msg, addr_fmt, acct, goto_home, need_keypress, scan_a_qr, + sign_msg_from_text, cap_story, skip_if_useless_way): + skip_if_useless_way("qr") + goto_home() + need_keypress(KEY_QR) + scan_a_qr(msg) + time.sleep(1) + title, story = cap_story() + assert title == "Simple Text" + assert "Press (0) to sign the text" in story + need_keypress("0") + sign_msg_from_text(msg, addr_fmt, acct, False, 999, "qr", "XTN", True) + + +@pytest.mark.parametrize("data", [ + {"msg": "msg to be signed via QR"}, + {"msg": "msg with some\n\t\n control characters", "addr_fmt": "p2sh-p2wpkh"}, + {"msg": 100*"CC", "addr_fmt": "p2wpkh", "subpath": "m/900h/0"}, + {"msg": "This is my address! @twiiter_nick", "subpath": "m/84h/1h/0h/0/0"}, + {"msg": "This is my address! @twiiter_nick", "subpath": "m/49'/0'/5'/1/100"}, +]) +@pytest.mark.parametrize("way", ["sd", "nfc", "qr"]) +def test_sign_scanned_json(data, way, goto_home, need_keypress, scan_a_qr, + cap_story, msg_sign_export, press_select, + addr_vs_path, bitcoind, skip_if_useless_way, + verify_msg_sign_story): + skip_if_useless_way(way) + goto_home() + af = data.get("addr_fmt", None) + if not af: + addr_fmt = addr_fmt_from_subpath(data.get("subpath", None)) + else: + addr_fmt = msg_sign_unmap_addr_fmt[af] + + need_keypress(KEY_QR) + scan_a_qr(json.dumps(data)) + time.sleep(1) + title, story = cap_story() + + subpath = verify_msg_sign_story(story, data["msg"], data.get("subpath", None), addr_fmt) + press_select() + + signed_msg = msg_sign_export(way) + ret_msg, addr, sig = parse_signed_message(signed_msg) + assert ret_msg == data["msg"] + # check expected addr was used + addr_vs_path(addr, subpath, addr_fmt) + assert verify_message(addr, sig, ret_msg) is True + if addr_fmt == AF_CLASSIC: + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res is True + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("msg", ["an an an an an an an an", 240*"a"]) +@pytest.mark.parametrize("path", ["m/84h/0", "m/44h/0", "m/49h/0", "m"]) +def test_sparrow_qr_sign_msg(msg, path, skip_if_useless_way, need_keypress, scan_a_qr, cap_story, + verify_msg_sign_story, press_select, msg_sign_export, addr_vs_path, + bitcoind): + skip_if_useless_way("qr") + + tmplt = "signmessage %s ascii:%s" + data = tmplt % (path, msg) + + addr_fmt = addr_fmt_from_subpath(path) + + need_keypress(KEY_QR) + scan_a_qr(data) + 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 + # check expected addr was used + addr_vs_path(addr, subpath, addr_fmt) + assert verify_message(addr, sig, ret_msg) is True + if addr_fmt == AF_CLASSIC: + res = bitcoind.rpc.verifymessage(addr, sig, ret_msg) + assert res 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): + skip_if_useless_way("qr") + wallet = BIP32Node.from_master_secret(os.urandom(32)) + addr = wallet.address() + sk = bytes(wallet.node.private_key) + sig = sign_message(sk, msg.encode()) + armored = RFC_SIGNATURE_TEMPLATE.format(addr=addr, sig=sig, msg=msg) + + goto_home() + need_keypress(KEY_QR) + scan_a_qr(armored) + time.sleep(1) + title, story = cap_story() + assert title == "CORRECT" + assert "Good signature by address" in story + assert addr == addr_from_display_format(story.split("\n")[-1]) + # EOF diff --git a/testing/test_multisig.py b/testing/test_multisig.py index 5a1f56cb..f434a785 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -12,7 +12,7 @@ from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN from pprint import pprint from base64 import b64encode, b64decode from base58 import encode_base58_checksum -from helpers import B2A, fake_dest_addr, xfp2str, detruncate_address +from helpers import B2A, fake_dest_addr, xfp2str, addr_from_display_format from helpers import path_to_str, str_to_path, slip132undo, swab32, hash160 from struct import unpack, pack from constants import * @@ -99,6 +99,19 @@ def make_multisig(dev, sim_execfile): # - but can provide str format for deriviation, use {idx} for cosigner idx def doit(M, N, unique=0, deriv=None, dev_key=False, chain="XTN"): + + def _derive(master, origin_der, idx): + if origin_der == "m": + return master + + d = origin_der.format(idx=idx) if origin_der else "m/45h" + try: + child = master.subkey_for_path(d) + except IndexError: + # some test cases are using bogus paths + child = master + return child + keys = [] for i in range(N-1): @@ -106,16 +119,7 @@ def make_multisig(dev, sim_execfile): xfp = unpack("' not in ln: continue - path,chk,addr = ln.split() + path,chk,addr = ln.split(" ", 2) assert chk == '=>' assert '/' in path path = path.replace("[", "").replace("]", "") @@ -2195,6 +2200,19 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, else: assert len(maps) == (MAX_BIP32_IDX - start_idx) + 1 + need_keypress(KEY_QR) + qr_addrs = [] + for i in range(10): + addr_qr = cap_screen_qr().decode() + if addr_fmt == AF_P2WSH: + # segwit addresses are case insensitive + addr_qr = addr_qr.lower() + qr_addrs.append(addr_qr) + press_right() + time.sleep(.2) + press_cancel() + + c = 0 for idx, (subpath, addr) in enumerate(maps, start=start_idx): chng_idx = 1 if change else 0 path_mapper = lambda co_idx: str_to_path(derivs[co_idx]) + [chng_idx, idx] @@ -2206,9 +2224,9 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, assert int(subpath.split('/')[-2]) == chng_idx #print('../0/%s => \n %s' % (idx, B2A(script))) - start, end = detruncate_address(addr) - assert expect.startswith(start) - assert expect.endswith(end) + addr = addr_from_display_format(addr) + assert addr == expect == qr_addrs[c] + c += 1 def test_dup_ms_wallet_bug(goto_home, pick_menu_item, press_select, import_ms_wallet, @@ -2288,11 +2306,12 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke pick_menu_item, cap_menu, cap_story, make_multisig, import_ms_wallet, microsd_path, bitcoind_d_wallet_w_sk, use_regtest, load_export, way, is_q1, press_select, start_idx, settings_set, set_addr_exp_start_idx, - desc): + desc, garbage_collector, virtdisk_path): use_regtest() clear_ms() bitcoind = bitcoind_d_wallet_w_sk M, N = M_N + path_f = microsd_path if way == "sd" else virtdisk_path # whether to import as descriptor or old school to CC descriptor = random.choice([True, False]) bip67 = True @@ -2343,7 +2362,12 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke assert "change addresses." not in story assert "(0)" not in story - contents = load_export(way, label="Address summary", is_json=False, sig_check=False) + if way != "nfc": + contents, exp_fname = load_export(way, label="Address summary", is_json=False, + sig_check=False, ret_fname=True) + garbage_collector.append(path_f(exp_fname)) + else: + contents = load_export(way, label="Address summary", is_json=False, sig_check=False) addr_cont = contents.strip() goto_home() pick_menu_item('Settings') @@ -2351,7 +2375,12 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke press_select() # only one enrolled multisig - choose it pick_menu_item('Descriptors') pick_menu_item("Bitcoin Core") - contents = load_export(way, label="Bitcoin Core multisig setup", is_json=False, sig_check=False) + if way != "nfc": + contents, exp_fname = load_export(way, label="Bitcoin Core multisig setup", is_json=False, + sig_check=False, ret_fname=True) + garbage_collector.append(path_f(exp_fname)) + else: + contents = load_export(way, label="Bitcoin Core multisig setup", is_json=False, sig_check=False) text = contents.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") @@ -2521,7 +2550,6 @@ def bitcoind_multisig(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, need_keypress, pick_menu_item, cap_story, load_export, microsd_path, cap_menu, try_sign, is_q1, press_select): - use_regtest() clear_ms() microsd_wipe() @@ -2896,8 +2924,9 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), ("All keys must be ranged", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#9h02aqg5"), ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), - ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"), - ("xpub depth", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), + # ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"), + ("xpub xfp wrong 0F056943", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), + ("xpub depth", "wsh(sortedmulti(2,[0f056943/0h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), ("Cannot use hardened sub derivation path", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0'/*))#3w6hpha3"), ("M must be <= N", "wsh(sortedmulti(3,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#uueddtsy"), @@ -3426,7 +3455,7 @@ def test_unsort_multisig_setting(settings_set, import_ms_wallet, goto_home, pick_menu_item, cap_story, need_keypress, settings_get, clear_ms, press_select, is_q1): clear_ms() - mi = "Unsorted Multisig" if is_q1 else "Unsorted Multi" + mi = "Unsorted Multisig?" if is_q1 else "Unsorted Multi?" settings_set("unsort_ms", 0) # OFF by default with pytest.raises(Exception) as e: import_ms_wallet(2, 3, "p2wsh", descriptor=True, bip67=False, @@ -3439,8 +3468,9 @@ def test_unsort_multisig_setting(settings_set, import_ms_wallet, goto_home, pick_menu_item(mi) time.sleep(.1) title, story = cap_story() - assert '"multi(...)" unsorted multisig wallets that do not follow BIP-67.' in story - assert 'preserve order of the keys' in story + assert '"multi(...)" unsorted multisig wallets that DO NOT follow BIP-67.' in story + assert ("CRUCIAL importance to backup multisig descriptor" + " for unsorted wallets in order to preserve key ordering") in story assert 'USE AT YOUR OWN RISK' in story assert 'Press (4)' in story need_keypress("4") @@ -3547,4 +3577,195 @@ def test_json_import_failures(err, config, offer_ms_import): offer_ms_import(json.dumps(config)) assert err in e.value.args[0] + +@pytest.mark.parametrize("desc", [True, False]) +def test_root_keys_import(desc, import_ms_wallet, clear_ms, fake_ms_txn, try_sign): + clear_ms() + M, N = 2, 3 + import_ms_wallet(M, N, "p2wsh", accept=True, name="root", + common="m", descriptor=desc) + + +@pytest.mark.bitcoind +def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, + pick_menu_item, cap_story, press_select, need_keypress, offer_ms_import, + cap_menu, load_export, try_sign, goto_address_explorer, settings_set): + # only CC has root key here, not practical to attempt get xpub from core, if possible + use_regtest() + clear_ms() + microsd_wipe() + M, N = 2, 2 + cosigner = bitcoind.create_wallet(wallet_name=f"bds", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + ms = bitcoind.create_wallet( + wallet_name=f"watch_only_roots", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + goto_home() + + # get key from bitcoind cosigner + target_desc = "" + bitcoind_descriptors = cosigner.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + desc = f"wsh(sortedmulti(2,{core_key},[{xfp2str(simulator_fixed_xfp).lower()}]{simulator_fixed_tpub}/0/*))" + desc_info = ms.getdescriptorinfo(desc) + desc_w_checksum = desc_info["descriptor"] # with checksum + + title, story = offer_ms_import(desc_w_checksum) + + assert "Create new multisig wallet?" in story + assert f"All {N} co-signers must approve spends" in story + assert "P2WSH" in story + press_select() # approve multisig import + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + menu = cap_menu() + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # import descriptors to watch only wallet + res = ms.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"], obj + + addr_type = "bech32" + multi_addr = ms.getnewaddress("", addr_type) + bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mining + dest_addr = ms.getnewaddress("", addr_type) + # create funded PSBT + psbt_resp = ms.walletcreatefundedpsbt( + [], [{dest_addr: 5}], 0, {"fee_rate": 2, "change_type": addr_type} + ) + + _, updated = try_sign(base64.b64decode(psbt_resp.get("psbt"))) + + done = cosigner.walletprocesspsbt(base64.b64encode(updated).decode(), True)["psbt"] + + rr = ms.finalizepsbt(done) + + assert rr['complete'] + tx_hex = rr["hex"] + res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(rr['hex']) + assert len(txn_id) == 64 + + bitcoind_addrs = ms.deriveaddresses(desc_w_checksum, [0,250]) + + goto_address_explorer() + pick_menu_item("2-of-2") + need_keypress('1') # SD + contents = load_export("sd", label="Address summary", is_json=False, sig_check=False) + cc_addrs = contents.strip().split("\n")[1:] + + # Generate the addresses file and get each line in a list + for i, line in enumerate(cc_addrs): + addr = line.split(",")[1][1:-1] + assert addr == bitcoind_addrs[i] + + +@pytest.mark.parametrize("has_orig", [False, True]) +def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_ms_import, + pick_menu_item, load_export, goto_home, cap_menu, clear_ms, + use_regtest, press_select, start_sign, end_sign, cap_story, + has_orig, need_keypress): + # can be both: + # a.) just ranged xpub without origin info -> xpub1/<0;1>/* + # b.) ranged xpub with its fp -> [xpub1_fp]xpub1/<0;1>/* + + use_regtest() + clear_ms() + af = "bech32" + name = "originless_multlisig" + + cc_key = get_cc_key("m/84h/1h/0h") + cs, ck = bitcoin_core_signer(name+"_signer") + originless_ck = ck.split("]")[-1] + + n = BIP32Node.from_hwif(originless_ck.split("/")[0]) # just extended key + fp_str = "[" + n.fingerprint().hex() + "]" + if has_orig: + originless_ck = fp_str + originless_ck + + tmplt = "wsh(sortedmulti(2,@0,@1))" + desc = tmplt.replace("@0", cc_key) + desc = desc.replace("@1", originless_ck) + to_import = {"desc": desc, "name": name} + offer_ms_import(json.dumps(to_import)) + press_select() + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + menu = cap_menu() + assert menu[0] == f"2/2: {name}" + pick_menu_item(menu[0]) # pick imported descriptor miniscript wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + # fund wallet + addr = wo.getnewaddress("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + unspent = wo.listunspent() + assert len(unspent) == 1 + + # split to 10 utxos + dest_addrs = [wo.getnewaddress(f"a{i}", af) for i in range(10)] + psbt_resp = wo.walletcreatefundedpsbt( + [], + [{a: 4} for a in dest_addrs] + [{bitcoind.supply_wallet.getnewaddress(): 5}], + 0, + {"fee_rate": 3, "change_type": af, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + cc_signed = end_sign(True) + cc_signed = base64.b64encode(cc_signed).decode() + + final_psbt_o = cs.walletprocesspsbt(cc_signed, True, "ALL") + final_psbt = final_psbt_o["psbt"] + assert psbt != final_psbt + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + # EOF diff --git a/testing/test_notes.py b/testing/test_notes.py index 659ece1d..56dd6238 100644 --- a/testing/test_notes.py +++ b/testing/test_notes.py @@ -5,7 +5,7 @@ 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 test_bbqr import readback_bbqr from bbqr import split_qrs @@ -43,10 +43,10 @@ def goto_notes(cap_story, cap_menu, press_select, goto_home, pick_menu_item): @pytest.fixture def need_some_notes(settings_get, settings_set): # create a note or use what's there, provide as obj - def doit(): + def doit(title='Title Here', body='Body'): notes = settings_get('notes', []) if not notes: - settings_set('notes', [dict(misc='Body', title='Title Here')]) + settings_set('notes', [dict(misc=body, title=title)]) return notes return doit @@ -93,7 +93,7 @@ def delete_note(press_select, goto_notes, cap_menu, pick_menu_item, @pytest.fixture 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): + press_select, press_cancel, is_headless, nfc_disabled): def doit(n_title, n_body): # we don't try to preserve leading/trailing spaces on note bodies @@ -142,12 +142,13 @@ def build_note(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story, # hidden NFC button on menu feature m = cap_menu() assert m[1] == 'View Note' - need_keypress(KEY_NFC) - time.sleep(.1) - nfc_rb = nfc_read_text() - time.sleep(.1) - assert nfc_rb == n_body - press_cancel() + if not nfc_disabled: + need_keypress(KEY_NFC) + time.sleep(.1) + nfc_rb = nfc_read_text() + time.sleep(.1) + assert nfc_rb == n_body + press_cancel() # export pick_menu_item('Export') @@ -181,7 +182,7 @@ 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=None, 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): goto_notes('New Password') enter_text(n_title) if n_user: @@ -384,7 +385,7 @@ def test_huge_notes(size, encoding, goto_notes, enter_text, cap_menu, need_keypr time.sleep(.5) # decompression time in some cases m = cap_menu() - assert m[-1] == 'Export' + assert m[-2] == 'Export' notes = settings_get('notes') assert len(notes) == 1 @@ -448,6 +449,33 @@ def test_top_export(goto_notes, pick_menu_item, cap_story, need_keypress, settin assert obj['coldcard_notes'] == notes need_keypress(KEY_ENTER) +def test_sort_by_title(goto_notes, pick_menu_item, cap_story, need_keypress, settings_get, + settings_set, build_note, cap_menu, build_password): + + settings_set('notes', []) + + build_note('ZZZ', 'b1') + + goto_notes() + assert 'Sort By Title' not in cap_menu() + + build_note('MMM', 'b2') + build_note('AAA', 'b3') + build_note('mmm', 'b2') + build_note('Aaa', 'b3') + build_password('Bbb') + + notes = settings_get('notes') + + goto_notes() + pick_menu_item('Sort By Title') + + # effect is immedate + after = settings_get('notes', []) + + assert sorted((i['title'] for i in after), key=lambda i:i.lower()) \ + == [i['title'] for i in after] + def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get, settings_set, scan_a_qr, need_some_notes): # make some @@ -624,4 +652,41 @@ def test_tmp_notes_separation(goto_notes, pick_menu_item, generate_ephemeral_wor assert 'pwd-tmp' not in mm assert 'note-tmp2' not in mm + +@pytest.mark.parametrize("msg", ["COLDCARD rocks!", "cc\nCC"]) +@pytest.mark.parametrize("addr_fmt", [AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH]) +@pytest.mark.parametrize("acct", [None, 0, 9999]) +@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk"]) +def test_sign_note_body(msg, addr_fmt, acct, need_some_notes, + pick_menu_item, sign_msg_from_text, way, + goto_notes, settings_set): + settings_set("notes", []) + title = "aaa" + need_some_notes(title, msg) + goto_notes() + pick_menu_item(f"1: {title}") + pick_menu_item("Sign Note Text") + sign_msg_from_text(msg, addr_fmt, acct, False, 0, way) + + +@pytest.mark.parametrize("chain", ["BTC", "XTN"]) +@pytest.mark.parametrize("change", [True, False]) +@pytest.mark.parametrize("idx", [None, 0, 9999]) +def test_sign_password_free_form(chain, change, idx, need_some_passwords, settings_set, + goto_notes, pick_menu_item, sign_msg_from_text): + settings_set('notes', []) # clear + title = "A" + msg = 'More Notes AAAA' + settings_set('notes', [ + {'misc': msg, + 'password': 'fds65fd5f1sd51s', + 'site': 'https://a.com', + 'title': title, + 'user': 'AAA'} + ]) + goto_notes() + pick_menu_item(f"1: {title}") + pick_menu_item("Sign Note Text") + sign_msg_from_text(msg, AF_P2WPKH, None, change, idx, "qr", chain) + # EOF diff --git a/testing/test_ownership.py b/testing/test_ownership.py index d7bb05a5..51936d50 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -5,10 +5,11 @@ import pytest, time, io, csv, json from txn import fake_address from base58 import encode_base58_checksum -from helpers import hash160, taptweak +from helpers import hash160, taptweak, addr_from_display_format from bip32 import BIP32Node from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from constants import simulator_fixed_xprv, simulator_fixed_tprv, addr_fmt_names +from charcodes import KEY_QR @pytest.fixture def wipe_cache(sim_exec): @@ -100,8 +101,7 @@ def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, menu_item = expect_name = 'Classic P2PKH' path = "m/44h/{ct}h/{acc}h" elif addr_fmt == AF_P2WPKH_P2SH: - expect_name = 'P2WPKH-in-P2SH' - menu_item = 'P2SH-Segwit' + menu_item = expect_name = 'P2SH-Segwit' path = "m/49h/{ct}h/{acc}h" clear_ms() elif addr_fmt == AF_P2WPKH: @@ -169,25 +169,40 @@ def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, @pytest.mark.parametrize('valid', [ True, False] ) @pytest.mark.parametrize('testnet', [ True, False] ) @pytest.mark.parametrize('method', [ 'qr', 'nfc'] ) -def test_ux(valid, testnet, method, +@pytest.mark.parametrize('multisig', [ True, False] ) +def test_ux(valid, testnet, method, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, press_cancel, press_select, settings_set, is_q1, nfc_write, need_keypress, - cap_screen, cap_story, load_shared_mod, scan_a_qr + cap_screen, cap_story, load_shared_mod, scan_a_qr, skip_if_useless_way, + sign_msg_from_address, multisig, import_ms_wallet, clear_ms, verify_qr_address ): - + skip_if_useless_way(method) addr_fmt = AF_CLASSIC if valid: - mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) - path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) - sk = mk.subkey_for_path(path) - addr = sk.address(chain="XTN" if testnet else "BTC") + if multisig: + from test_multisig import make_ms_address, HARD + M, N = 2, 3 + + expect_name = f'own_ux_test' + clear_ms() + keys = import_ms_wallet(M, N, AF_P2WSH, name=expect_name, accept=1) + + # iffy: no cosigner index in this wallet, so indicated that w/ path_mapper + addr, scriptPubKey, script, details = make_ms_address( + M, keys, is_change=0, idx=50, addr_fmt=AF_P2WSH, + testnet=int(testnet), path_mapper=lambda cosigner: [HARD(45), 0, 50] + ) + addr_fmt = AF_P2WSH + else: + mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) + path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) + sk = mk.subkey_for_path(path) + addr = sk.address(chain="XTN" if testnet else "BTC") else: - addr = fake_address(addr_fmt, testnet) + addr = fake_address(addr_fmt, testnet) if method == 'qr': - if not is_q1: - raise pytest.skip('no QR on Mk4') goto_home() pick_menu_item('Scan Any QR Code') scan_a_qr(addr) @@ -195,7 +210,7 @@ def test_ux(valid, testnet, method, title, story = cap_story() - assert addr in story + assert addr == addr_from_display_format(story.split("\n\n")[0]) assert '(1) to verify ownership' in story need_keypress('1') @@ -220,16 +235,31 @@ def test_ux(valid, testnet, method, time.sleep(1) title, story = cap_story() - - assert addr in story + assert addr == addr_from_display_format(story.split("\n\n")[0]) if title == 'Unknown Address' and not testnet: assert 'That address is not valid on Bitcoin Testnet' in story elif valid: - assert title == 'Verified Address' + assert title == ('Verified Address' if is_q1 else "Verified!") assert 'Found in wallet' in story assert 'Derivation path' in story - assert 'P2PKH' in story + + if is_q1: + # check it can display as QR from here + need_keypress(KEY_QR) + verify_qr_address(addr_fmt, addr) + press_cancel() + + if multisig: + assert expect_name in story + assert "Press (0) to sign message with this key" not in story + else: + assert 'P2PKH' in story + assert "Press (0) to sign message with this key" in story + need_keypress('0') + msg = "coinkite CC the most solid HWW" + sign_msg_from_address(msg, addr, path, addr_fmt, method, testnet) + else: assert title == 'Unknown Address' assert 'Searched ' in story @@ -240,7 +270,7 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo pick_menu_item, need_keypress, sim_exec, clear_ms, import_ms_wallet, press_select, goto_home, nfc_write, load_shared_mod, load_export_and_verify_signature, - cap_story, load_export, offer_minsc_import): + cap_story, load_export, offer_minsc_import, is_q1): goto_home() wipe_cache() settings_set('accts', []) @@ -299,13 +329,12 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo time.sleep(1) title, story = cap_story() - assert addr in story - assert title == 'Verified Address' + assert addr == addr_from_display_format(story.split("\n\n")[0]) + assert title == ('Verified Address' if is_q1 else "Verified!") assert 'Found in wallet' in story - # assert 'Derivation path' in story - if af == "P2SH-Segwit": - assert "P2WPKH-in-P2SH" in story - elif af == "Segwit P2WPKH": + if "msc" not in af: + assert 'Derivation path' in story + if af == "Segwit P2WPKH": assert " P2WPKH " in story else: assert af in story diff --git a/testing/test_sign.py b/testing/test_sign.py index 45f1bd4b..9f2bc821 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -4,22 +4,22 @@ # import time, pytest, os, random, pdb, struct, base64, binascii, itertools, datetime -from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError, MAX_TXN_LEN, CCUserRefused +from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError from binascii import b2a_hex, a2b_hex from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT from io import BytesIO -from pprint import pprint, pformat +from pprint import pprint from decimal import Decimal from base64 import b64encode, b64decode from base58 import encode_base58_checksum -from helpers import B2A, fake_dest_addr, parse_change_back +from helpers import B2A, fake_dest_addr, parse_change_back, addr_from_display_format from helpers import xfp2str, seconds2human_readable, hash160 from msg import verify_message from bip32 import BIP32Node -from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP, simulator_fixed_tpub +from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP from txn import * from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint -from ckcc_protocol.constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED +from ckcc_protocol.constants import STXN_VISUALIZE, STXN_SIGNED from charcodes import KEY_QR, KEY_RIGHT @@ -560,7 +560,9 @@ def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind, time.sleep(.1) _, story = cap_story() - assert chg_addr in story + split_sory = story.split("\n\n")[3].split("\n") + 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,]) @@ -630,7 +632,7 @@ def test_change_fraud_path(start_sign, use_regtest, end_sign, case, check_agains time.sleep(.1) _, story = cap_story() - assert chg_addr in story + assert chg_addr == addr_from_display_format(story.split("\n\n")[3].split("\n")[-1]) assert 'Change back:' not in story end_sign(True) @@ -738,7 +740,9 @@ def test_change_p2sh_p2wpkh(start_sign, end_sign, check_against_bitcoind, use_re check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[1,], dests=[(1, expect_addr)]) - assert expect_addr in story + split_sory = story.split("\n\n")[3].split("\n") + assert split_sory[0] == "Change back:" + assert expect_addr == addr_from_display_format(split_sory[-1]) assert parse_change_back(story) == (Decimal('1.09997082'), [expect_addr]) end_sign(True) @@ -2015,12 +2019,22 @@ def _test_single_sig_sighash(cap_story, press_select, start_sign, end_sign, dev, for idx, i in enumerate(y.inputs): if len(sighash) == 1: - assert i.sighash == SIGHASH_MAP[sighash[0]] + target = sighash[0] + sh_num = SIGHASH_MAP[target] + if target == "ALL": + assert i.sighash is None + else: + assert i.sighash == sh_num else: - assert i.sighash == SIGHASH_MAP[sighash[idx]] - # check signature hash correct checkusm appended + target = sighash[idx] + sh_num = SIGHASH_MAP[target] + if target == "ALL": + assert i.sighash is None + else: + assert i.sighash == sh_num + # check signature hash correct checksum appended for _, sig in i.part_sigs.items(): - assert sig[-1] == i.sighash + assert sig[-1] == sh_num resp = finalize_v2_v0_convert(y) @@ -3106,7 +3120,7 @@ def test_taproot_keyspend(use_regtest, bitcoind_d_sim_watch, start_sign, end_sig assert title == 'OK TO SEND?' assert "Consolidating" in story # self-spend assert " 1 input\n 2 outputs" in story - addrs = story.split("\n\n")[3].split("\n")[-2:] + addrs = [addr_from_display_format(l) for l in story.split("\n") if l and (l[0] == '\x02')] assert len(addrs) == 2 for addr in addrs: assert addr.startswith("bcrt1p")