diff --git a/README.md b/README.md
index 54af306ff..e0cbbb7d4 100644
--- a/README.md
+++ b/README.md
@@ -183,7 +183,9 @@ git clone --recursive https://github.com/Coldcard/firmware.git
cd firmware
# Apply address patch
-git apply unix/linux_addr.patch
+# if unix/linux_addr.patch exists use below command
+# not needed in current revision
+# git apply unix/linux_addr.patch
# * below is needed for ubuntu 24.04
pushd external/micropython
diff --git a/cli/signit.py b/cli/signit.py
index fd7bc0b1e..62a2bb43b 100755
--- a/cli/signit.py
+++ b/cli/signit.py
@@ -319,13 +319,14 @@ def doit(keydir, outfn=None, build_dir=None, high_water=False,
pubkey_num=pubkey_num,
timestamp=timestamp(backdate) )
- assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH, hdr.firmware_length
if hw_compat & MK_3_OK:
# actual file length limited by size of SPI flash area reserved to txn data/uploads
+ assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH, hdr.firmware_length
USB_MAX_LEN = (786432-128)
else:
- # new value for Mk4: limited only by final binary size, not SPI flash
+ # new value for Mk4 and later: limited only by final binary size, not SPI flash
+ assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH_MK4, hdr.firmware_length
USB_MAX_LEN = 1472 * 1024
assert hdr.firmware_length <= USB_MAX_LEN, \
diff --git a/docs/bip-21-extensions.md b/docs/bip-21-extensions.md
new file mode 100644
index 000000000..17a6b73bf
--- /dev/null
+++ b/docs/bip-21-extensions.md
@@ -0,0 +1,15 @@
+## Multisig Ownership address check: "wallet"
+
+If the name of the multisig wallet related to an address is provided, address search
+can be greatly accelerated. Just provide `wallet=name` parameter in a standard
+[BIP-21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) URL
+shown in QR code or NFC record. If omitted, search will continue across
+all multisig wallets known by COLDCARD.
+
+### Examples
+
+```
+tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=goldmine
+
+bitcoin:mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?label=coldcard_purchase&amount=50&wallet=Haystack%20Four
+```
diff --git a/docs/generic-wallet-export.md b/docs/generic-wallet-export.md
index f98231161..0a5032021 100644
--- a/docs/generic-wallet-export.md
+++ b/docs/generic-wallet-export.md
@@ -57,7 +57,7 @@ to be the first (non-change) receive address for the wallet.
segregate funds into sub-wallets. Don't assume it's zero.
3. When making your PSBT files to spend these amounts, remember that the XFP of the master
-(`0F056943` in this example) is is the root of the subkey paths found in the file, and
+(`0F056943` in this example) is the root of the subkey paths found in the file, and
you must include the full derivation path from master. So based on this example,
to spend a UTXO on `tb1qc58ys2dphtphg6yuugdf3d0kufmk0tye044g3l`, the input section
of your PSBT would need to specify `(m=0F056943)/84'/1'/123'/0/0`.
diff --git a/docs/key-teleport.md b/docs/key-teleport.md
new file mode 100644
index 000000000..9056e2dd8
--- /dev/null
+++ b/docs/key-teleport.md
@@ -0,0 +1,224 @@
+
+# Key Teleport
+
+Purpose: Send a small quantity of very secret data between two COLDCARD Q systems, with
+no risk of anything in the middle learning the secret.
+
+Method: ECDH and AES-256-CTR plus an extra wrapping layer, transmitted over a mixture of
+NFC, passive websites, and QR/BBQr codes.
+
+# Protocol Overview
+
+## Steps
+
+- Receiver picks an EC keypair, stores it in settings, and publishes the pubkey via a QR/NFC
+- The pubkey is encrypted by a short 8-digit numeric code, which should be
+ sent by a different channel.
+- Sender gets QR and numeric code, picks own keypair, and does ECDH to arrive at a
+ shared session key
+- Sender picks a human-readable secret which is independent of anything else (P key)
+- The secret data (perhaps a seed phrase, XPRV, secure note, full backup, etc) is
+ AES-256-CTR encrypted with P key, then encrypted + MAC added with session key
+- Data packet is sent to receiver (via BBQr), who can reconstruct the session key via ECDH
+- Prompt user for the P key to finish decoding
+- Decoded secret value is saved to Seed Vault or secure notes as appropriate
+- Receiver destroys EC keypair used in transfer
+
+### When used for PSBT Multisig
+
+- No action required on receiver
+- Sender uses the pubkey derived from pre-shared XPUB involved in the multisig wallet.
+- Same steps, but drops immediately into signing process when decoded correctly
+
+## Notes and Limitations
+
+- max 4k (after encoding) of data is possible due to HTTP limitations
+- all transfers are "data typed" and decode only on COLDCARD
+- Q model is required due to the use of QR codes to ultimately get data into the COLDCARD
+
+
+# Details
+
+## Data Type Codes
+
+The first byte encodes what the package contents (under all the encryption).
+
+- `s` - 12/18/24 words/raw master/xprv - 17-72 bytes follow, encoded in an internal format
+- `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows
+- `n` - one or many notes export (JSON array)
+- `v` - seed vault export (JSON: one secret key but includes name, source of key)
+- `p` - binary PSBT to be signed, perhaps multisig but not required.
+- `b` - complete system backup file (text lines, internal format)
+
+## QR details
+
+BBQr is always used for the QR's involved in this process, even if
+they are short enough for a normal QR code. Because the BBQr is
+being generated by the COLDCARD embedded firmware, it will not be
+compressed and will always be Base32 encoded.
+
+New type codes for BBQr are defined for the purposes of this application:
+
+- `R` contains `(pubkey)` ... begins the process from receiver; compressed pubkey is 33 bytes
+- `S` contains `(pubkey)(data)` ... data from sender; first 33 bytes are sender's pubkey
+- `E` for Multisig PSBT: `(randint)(data)` ... randint (4 byte nonce) indicates which
+ derived subkey from pre-shared xpub associated with receiver
+
+All the data is encrypted with the exception randint. Keep in mind
+this is a nonce value picked uniquely for each transfer. The
+receiver's pubkey is only weakly encrypted by the 8-digit numeric
+password, but is also a nonce effectively.
+
+### PSBT Key Selection
+
+When sending PSBT data, a nonce is picked at random by the sender
+in range: `0..(2^28)`
+
+This nonce is called `randint`. The receiver's pubkey will be
+
+ .../20250317/(randint)
+
+where `...` is the derivation used in the multisig wallet for the co-signer who will
+receive the package. The sender's keypair has the same sub key path assuming all
+co-signers have same derivation path from root (not required).
+
+Because both the sender and receiver already have each other's XPUB they can derive
+the appropriate pubkeys (and privkey for their side) without communicating
+more than `randint`. The sending COLDCARD will pick a new random value each time.
+
+When receiving a multisig PSBT encrypted this way, the receiver does not need
+to do any setup (nor numeric password) and can receive a QR code at any time.
+This works because the shared multisig wallet is already setup. Receiver will
+take the nonce value (randint) and seach all pre-defined multisig wallets for
+any pubkey that can decrypt the package successfully (based on checksum inside
+first layer of ECDH encryption).
+
+The next layer of encryption (paranoid password) is unchanged.
+
+## Encryption Details
+
+AES-256-CTR is used exclusively. Session key is picked via ECDH with final
+key value being the SHA256 over 64 bytes of coordinate X (concat) Y.
+
+While ECDH is enough to assure privacy from men in the middle, we
+add an additional layer of encryption. We call this the "paranoid key" internally
+and in the UX it is called "Teleport Password".
+
+The user sees a random 8-character password, generated as a random 40-bit value, but
+shown in Base32 (8 chars) for the human to enter. We apply PBKDF2-SHA512 with
+an iteration count of 5000 to stretch that to 512 bits, of which we use half.
+The session key is used as the key for the KDF, and the entered value as salt.
+
+- ECDH arrives at session key
+- decrypt (AES-256-CTR) the binary body of message
+- verify checksum:
+ - final 2 bytes should be `== SHA256(decrypted body[0:-2])[-2:]`
+ - if not, corruption, truncation, or wrong keys
+- if that decryption is correct, then prompt user for the paranoid key (8 chars)
+- stretch that value using session key and 5000 iterations of PBKDF2-SHA512
+- use upper 256 bits and run AES-256-CTR again
+- same checksum of 2 bytes of SHA256 are found inside after decryption
+
+Encryption adds 4 bytes of overhead because of these MAC values,
+but should catch truncation and bitrot. There are no other
+protections against truncation as length data is not transmitted.
+
+# Receiver Password
+
+When the teleport process is started, the receiver shares his pubkey
+as QR. However, we also show an 8-digit numeric password. The
+purpose of this is force the receiver to share this separately from
+the pubkey QR on another channel. The code is randomly picked, but
+only represents about 26 bits of entropy and is stretched with
+a single round of SHA256 before being used as a AES-256-CTR key
+to decrypt the pubkey. No checksum verifies correct
+decryption, so any code is accepted, and will with near-50% odds,
+decrypt to a valid pubkey.
+
+When the sender is given the receiver's pubkey via QR code, it
+prompts for the numeric code and uses it to decrypt the pubkey.
+Thus a MiTM who injects their pubkey will be detected and blocked.
+
+The "paranoid key" serves the same role in the other direction but
+it is Base32 character set, so it will not look similar or be
+confusing as to its purpose.
+
+# Web Component
+
+In order to "teleport" the contents of a QR code over NFC, we will
+publish a static website directly from an open Github repository.
+The single-page website contains javascript code which looks at the
+"hash" part of the incoming URL (`window.location.hash`) and if it
+meets the requirements, renders a large QR. The QR data must look like
+a correctly-encoded BBQr with one of the 3 type-codes above (`R` `S` or `E`).
+Otherwise the website could render any QR, which we don't want to
+support.
+
+The page will offer "copy to clipboard" features for the data inside
+the QR as a URL (ie. same URL as shown) and as an image and of course,
+the COLDCARD Q can scan from the web browser screen itself.
+
+When the BBQr data is larger than comfortable for a single QR, the
+website can split into a multi-frame BBQr. The website can
+do this without understanding the contents of the BBQr data (all
+of which is encrypted). Download options will be provided for
+single-frame QR, animated PNG, and "stacked BBQr" (a single tall
+PNG with each QR frame stacked).
+
+On the COLDCARD side, when NFC is tapped, it will offer a long URL
+to this site with the data to be transferred "after the hash". This
+is optional since the QR can be shown on the Q itself, and would
+pass the same data.
+
+Since the website is running on Github, Coinkite does not have
+access to IP addresses or other access log details. Because the data for
+teleport is "after the hash" it is never sent to Github's servers
+but remains in the browser only. All JS resources referenced by the
+webpage will have content hashes applied to prevent interference,
+and the site will be served over SSL.
+
+Visit [keyteleport.com](https://keyteleport.com/), or an
+[example small QR](https://keyteleport.com/#B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO)
+and [view source code](https://github.com/coinkite/keyteleport.com).
+
+# UX Details
+
+- When the receive process is started by the user, a pubkey is picked
+ and stored, so that they can come back later (after a power cycle)
+ and make use of the data encoded by the sender. However once a package
+ is decoded successfully, that key is deleted.
+
+- Sender must start by scanning the QR from a receiver. Then can pick what
+ to send, from secure notes to seeds and so on.
+
+- For PSBT multisig, user must pick a single co-signer (who hasn't already
+ signed) and the QR is prepared for that receiver. Because we
+ cannot do arbitary combining, it's best if the next signer continues
+ to teleport the updated PSBT to further signers. In other words,
+ a daisy-chain pattern is prefered to a star pattern. The signer
+ who completes the Mth (of N) signature will be able to finalize
+ the transaction, and ideally with PushTx feature, broadcast it.
+
+# Security Comments
+
+## Such short passwords?
+
+We are using 8-character passwords because we want them to be
+practical to share over non-digital channels such as a voice phone
+call, or hand-written note.
+
+It is very important to remind users that the passwords should be sent
+by a different channel from the QR itself. Best is to call up your
+other party and say the letters to them directly.
+
+## Is it safe to save image of QR to cloud?
+
+Yes, this seems safe. Of course, if you can control it, perhaps not
+a risk to accept... but the QR is encrypted via ECDH using a key
+that is forgotten after the transfer, so forward privacy is protected.
+Also your cloud service (or photo roll, chat app log, etc) will not
+have the 8-character password which is also required unpack the secrets.
+
+The QR codes themselves are fully random and do not reveal the
+identity of your COLDCARD, your on chain funds or anything linked
+to you.
diff --git a/docs/limitations.md b/docs/limitations.md
index c9c3cb5c4..7a5bb54af 100644
--- a/docs/limitations.md
+++ b/docs/limitations.md
@@ -14,11 +14,12 @@
# PIN Codes
- 2-2 through 6-6 in size, numeric digits only
-- pin code 999999-999999 is reserved (means 'clear pin')
+- pin code 999999-999999 was reserved (meaning 'clear pin'), but now available again
# Backup Files
- we don't know what day it is, so meta data on files will not have correct date/time
+- release date of the firmware version that made the file is used instead of true date
- encrypted files produced cannot be changed, and we don't support other tools making them
# Micro SD
@@ -55,14 +56,18 @@
- only one signature will be added per input. However, if needed the partly-signed
PSBT can be given again, and the "next" leg will be signed.
-- we do not support PSBT combining or finalizing of transactions involving
- P2SH signatures (so the combine step must be off-device)
+- finalizing of multisig transactions involving P2SH signatures:
+ * SD/Vdisk signing exports both signed PSBT and finalized txn ready for broadcast (if txn is complete)
+ * QR/NFC outputs finalized txn ready for broadcast if txn is complete otherwise signed PSBT only
+ * USB signing requires `--finalize` parameter (as for standard single signature wallets)
+
- we can sign for P2SH and P2WSH addresses that represent multisig (M of N) but
we cannot sign for non-standard scripts because we don't know how to present
that to the user for approval.
- during USB "show address" for multisig, we limit subkey paths to
16 levels deep (including master fingerprint)
-- max of 15 co-signers due to 520 byte script limitation in consensus layer with classic P2SH (same limit applies to segwit even though consensus allows up to 20 co-signers)
+- max of 15 co-signers due to 1650 byte `scriptSig` limitation in policy with classic P2SH (same limit applies to segwit even though consensus allows up to 20 co-signers).
+ note: the consensus layer sets an upper bound of 520 bytes for the length of each stack element
- (mk3) we have space for up to 8 M-of-3 wallets, or a single M-of-15 wallet. YMMV
- only a single multisig wallet can be involved in a PSBT; can't sign inputs from two different
multisig wallets at the same time.
@@ -74,6 +79,7 @@
- multisig wallet `name` can only contain printable ASCII characters `range(32, 127)`
### BIP-67
+
- importing multisig from PSBT can ONLY create `sortedmulti(...)` multisig according to BIP-67, DO NOT use with `multi(...)`
- creating airgapped multisig using COLDCARD as coordinator always produces `sortedmulti(...)` multisig according to BIP-67
- COLDCARD import/export [format](https://coldcard.com/docs/multisig/#configuration-text-file-for-multisig) only supports `sortedmulti(...)` multisig according to BIP-67. To import multisig wallet with `multi(...)` use descriptor import [format](https://github.com/bitcoin/bips/blob/master/bip-0383.mediawiki)
@@ -128,12 +134,17 @@ We will summarize transaction outputs as "change" back into same wallet, however
- `p2wsh-p2sh`: _redeemScript_ (which is: `0x00 + 0x20 + sha256(witnessScript)`), and
_witnessScript_ (which contains the multisig script)
- `p2wsh`: only _witnessScript_ (which contains the actual multisig script)
-
+ - `p2tr`(keypath singlesig): no _redeemScript_, no _witnessScript_ and output key MUST commit to an unspendable script path as follows `Q = P + int(hashTapTweak(bytes(P)))G`
+ - `p2tr`(scriptpath multisig): _taproot_merkle_root_ and _leaf_script_ more info in docs/taproot.md
# Derivation Paths
- key derivatation paths must be 12 or less in depth (`MAX_PATH_DEPTH`)
+# Pay-to-Pubkey
+
+- although we have some code for "pay to pubkey" (P2PK not P2PKH), it is untested
+ and unused since this style of payment address is obsolete and largely unused today
# NFC Feature
@@ -198,3 +209,16 @@ We will summarize transaction outputs as "change" back into same wallet, however
- if you have an XFP collision between multiple wallets in SeedVault (ie. two wallets
with same descriptors, but different seeds) you will get false negatives
+# Spending Policy
+
+- (Cosign mode) only 12 or 24 word seeds (not XPRV) are accepted for "key C"
+- velocity limit:
+ - based on a max magnitude per txn, and a required minimum block height
+ gap, based on previous `nLockTime` value in last-signed PSBT.
+ - if you sign a transaction, but never broadcast it, you will still have to wait out
+ the velocity policy.
+ - PSBT creator must put in `nLockTime` block heights (most already do to avoid fee sniping)
+- maximum of 25 whitelisted addresses can be stored
+- Web2FA: any number of mobile devices can be enrolled, but all will have the same shared secret
+- any warning from the PSBT, such as huge fees, will cause the transaction to be rejected
+
diff --git a/docs/menu-tree.txt b/docs/menu-tree.txt
index 724a17c03..8a9dea571 100644
--- a/docs/menu-tree.txt
+++ b/docs/menu-tree.txt
@@ -16,7 +16,6 @@
Advanced
12 Word Dice Roll
24 Word Dice Roll
- Migrate COLDCARD
Import Existing
12 Words
[SEED WORD ENTRY]
@@ -30,6 +29,8 @@
Import XPRV
Tapsigner Backup
Seed XOR
+ Migrate Coldcard
+ Key Teleport (start)
Help
Advanced/Tools
View Identity
@@ -57,14 +58,18 @@
List Files
Verify Sig File
NFC File Share [IF NFC ENABLED]
+ BBQr File Share [IF QR SCANNER]
+ QR File Share [IF QR SCANNER]
Format SD Card
Format RAM Disk [IF VIRTDISK ENABLED]
+ Key Teleport (start)
Paper Wallets
Perform Selftest
I Am Developer.
Serial REPL
Warm Reset
- Restore Txt Bkup
+ Restore Bkup
+ Reflash GPU [IF QWERTY KEYBOARD]
Secure Logout
Settings
Login Settings
@@ -106,6 +111,11 @@
NFC Sharing
Default Off
Enable NFC
+ NFC Push Tx
+ coldcard.com
+ mempool.space
+ Custom URL...
+ Disable
Display Units
BTC
mBTC
@@ -140,8 +150,9 @@
50%
60%
70%
- 80% (default)
+ 80%
90%
+ 95% (default)
100%
Delete PSBTs
Default Keep
@@ -149,17 +160,20 @@
Menu Wrapping
Default Off
Enable
+ Home Menu XFP [IF SECRET AND NOT TMP SEED]
+ Only Tmp
+ Always Show
---
[NORMAL OPERATION]
Ready To Sign
Passphrase [IF WORD BASED SEED]
- Restore Saved [MAYBE]
- A***********
- [0C52BAD4]
+ Restore Saved
+ c*******
+ [3A14F788]
Restore
Delete
- Edit Phrase [MAYBE]
+ Edit Phrase [IF QWERTY]
Add Word [IF NOT QWERTY]
[SEED WORD MENUS]
Add Numbers [IF NOT QWERTY]
@@ -183,35 +197,44 @@
Account Number
Custom Path
CC-2-of-4
- Secure Notes & Passwords [IF ENBALED]
- 1: note1
- "note1"
+ Secure Notes & Passwords [IF ENBALED] [MAYBE]
+ 1: note0
+ "note0"
View Note
Edit
Delete
Export
- SHORTCUT
- SHORTCUT
- 2: nostr
- "nostr"
- ↳ scg
- ↳ brb.io
+ Sign Note Text
+ 2: secret-PWD
+ "secret-PWD"
+ ↳ satoshi
+ ↳ abc.org
View Password
Send Password [MAYBE]
Export
Edit Metadata
Delete
Change Password
- SHORTCUT
- SHORTCUT
+ Sign Note Text
New Note
New Password
Export All
+ Sort By Title
Import
Type Passwords [MAYBE]
Seed Vault [MAYBE]
- 1: [B14E9AE0]
- [B14E9AE0]
+ 1: [7126EB3C]
+ [7126EB3C]
+ Use This Seed
+ Rename
+ Delete
+ 2: [CCEE13B9]
+ [CCEE13B9]
+ Use This Seed
+ Rename
+ Delete
+ 3: [03EE9989]
+ [03EE9989]
Use This Seed
Rename
Delete
@@ -222,12 +245,18 @@
Restore Backup
Clone Coldcard
Export Wallet
+ Sparrow
+ Cove
Bitcoin Core
- Sparrow Wallet
+ Nunchuk
+ Bull Bitcoin
+ Zeus
Electrum Wallet
Wasabi Wallet
+ Fully Noded
Unchained
- Lily Wallet
+ Theya
+ Bitcoin Safe
Samourai Postmix
Samourai Premix
Descriptor
@@ -235,7 +264,7 @@
Export XPUB
Segwit (BIP-84)
Classic (BIP-44)
- P2WPKH/P2SH (49)
+ P2WPKH/P2SH (BIP-49)
Master XPUB
Current XFP
Dump Summary
@@ -247,12 +276,18 @@
Verify Backup
Backup System
Export Wallet
+ Sparrow
+ Cove
Bitcoin Core
- Sparrow Wallet
+ Nunchuk
+ Bull Bitcoin
+ Zeus
Electrum Wallet
Wasabi Wallet
+ Fully Noded
Unchained
- Lily Wallet
+ Theya
+ Bitcoin Safe
Samourai Postmix
Samourai Premix
Descriptor
@@ -260,42 +295,44 @@
Export XPUB
Segwit (BIP-84)
Classic (BIP-44)
- P2WPKH/P2SH (49)
+ P2WPKH/P2SH (BIP-49)
Master XPUB
Current XFP
Dump Summary
Sign Text File
Batch Sign PSBT
+ Teleport Multisig PSBT
List Files
Verify Sig File
NFC File Share [IF NFC ENABLED]
+ BBQr File Share [IF QR SCANNER]
+ QR File Share [IF QR SCANNER]
Clone Coldcard
Format SD Card
Format RAM Disk [IF VIRTDISK ENABLED]
Secure Notes & Passwords [IF QWERTY KEYBOARD]
- 1: note1
- "note1"
+ 1: note0
+ "note0"
View Note
Edit
Delete
Export
- SHORTCUT
- SHORTCUT
- 2: nostr
- "nostr"
- ↳ scg
- ↳ brb.io
+ Sign Note Text
+ 2: secret-PWD
+ "secret-PWD"
+ ↳ satoshi
+ ↳ abc.org
View Password
Send Password [MAYBE]
Export
Edit Metadata
Delete
Change Password
- SHORTCUT
- SHORTCUT
+ Sign Note Text
New Note
New Password
Export All
+ Sort By Title
Import
Derive Seeds (BIP-85)
View Identity
@@ -314,18 +351,24 @@
Import XPRV
Tapsigner Backup
Coldcard Backup
+ Key Teleport (start)
+ Spending Policy
+ Single-Signer [IF SECRET AND NOT TMP SEED]
+ Co-Sign Multisig (CCC) [IF NOT TMP SEED]
+ HSM Mode [IF HSM AND SECRET]
+ Default Off
+ Enable
+ User Management [MAYBE]
Paper Wallets
- Enable HSM [IF HSM AND SECRET]
- Default Off
- Enable
- User Management [IF HSM AND SECRET]
NFC Tools [IF NFC ENABLED]
+ Sign PSBT
Show Address
Sign Message
Verify Sig File
Verify Address
File Share
Import Multisig
+ Push Transaction [IF PUSHTX ENABLED]
Danger Zone
Debug Functions
Seed Functions
@@ -334,13 +377,15 @@
Split Existing [IF WORD BASED SEED]
Restore Seed XOR
Destroy Seed [IF SECRET AND NOT TMP SEED]
- Lock Down Seed
+ Lock Down Seed [MAYBE]
Export SeedQR [IF WORD BASED SEED]
I Am Developer.
Serial REPL
Warm Reset
- Restore Txt Bkup
- Seed Vault [IF SECRET]
+ Restore Bkup
+ BKPW Override
+ Reflash GPU [IF QWERTY KEYBOARD]
+ Seed Vault [IF SECRET AND NOT TMP SEED]
Default Off
Enable
Perform Selftest
@@ -353,30 +398,43 @@
Warn
Testnet Mode
Bitcoin
- Testnet3
+ Testnet4
Regtest
- AE Start IDX
+ AE Start Index
Default Off
Enable
+ B85 Idx Values
+ Default Off
+ Unlimited
Settings Space
MCU Key Slots
Bless Firmware
- Reflash GPU [IF QWERTY KEYBOARD]
Wipe LFS
Settings
Login Settings
Change Main PIN
Trick PINs [IF SECRET AND NOT TMP SEED]
Trick PINs:
- ↳123-254
- PIN 123-254
+ ↳11-11
+ PIN 11-11
+ ↳Bricks CC
+ Hide Trick
+ Delete Trick
+ Change PIN
+ ↳333-3334
+ PIN 333-3334
↳Duress Wallet
Activate Wallet
Hide Trick
Delete Trick
Change PIN
+ ↳WRONG PIN
+ After 3 wrong:
+ ↳Wipes seed
+ ↳Reboots
+ Hide Trick
+ Delete Trick
Add New Trick
- Add If Wrong
Delete All
Set Nickname
Scramble Keys
@@ -421,17 +479,27 @@
View Details
Delete
Coldcard Export
+ Electrum Wallet
Descriptors
View Descriptor
Export
Bitcoin Core
- Electrum Wallet
Import from File
+ Import from QR [IF QR SCANNER]
Import via NFC [IF NFC ENABLED]
Export XPUB
Create Airgapped
Trust PSBT?
Skip Checks?
+ Full Address View?
+ Partly Censor
+ Show Full
+ Unsorted Multisig?
+ NFC Push Tx
+ coldcard.com
+ mempool.space
+ Custom URL...
+ Disable
Display Units
BTC
mBTC
@@ -466,8 +534,9 @@
50%
60%
70%
- 80% (default)
+ 80%
90%
+ 95% (default)
100%
Delete PSBTs
Default Keep
@@ -475,25 +544,171 @@
Menu Wrapping
Default Off
Enable
+ Home Menu XFP [IF SECRET AND NOT TMP SEED]
+ Only Tmp
+ Always Show
Keyboard EMU
Default Off
Enable
Secure Logout
SHORTCUT [IF NFC ENABLED]
+ Sign PSBT
Show Address
Sign Message
Verify Sig File
Verify Address
File Share
Import Multisig
+ Push Transaction [IF PUSHTX ENABLED]
---
[FACTORY MODE]
- Version: 5.x.x
Bag Me Now
+ Version: 5.x.x
DFU Upgrade
Ship W/O Bag
Debug Functions
Perform Selftest
---
+[SSSP]
+ Ready To Sign
+ Passphrase [IF WORD BASED SEED & SSSP RELATED KEYS ENABLED]
+ Restore Saved
+ c*******
+ [3A14F788]
+ Restore
+ Delete
+ Edit Phrase
+ Scan Any QR Code [IF QR SCANNER]
+ Address Explorer
+ Classic P2PKH
+ ↳ mtHSVByP9EYZ⋯Vm19gvpecb5R
+ P2SH-Segwit
+ ↳ 2NCAJ5wD4Gvm⋯NphNU8UYoEJv
+ Segwit P2WPKH
+ ↳ tb1qupyd58nd⋯vu9jtdyws9n9
+ Applications
+ Samourai
+ Post-mix
+ Pre-mix
+ Wasabi
+ Account Number
+ Custom Path
+ CC-2-of-4
+ Secure Notes & Passwords[IF ENABLED & SSSP ALLOW NOTES]
+ 1: note0
+ "note0"
+ View Note
+ Sign Note Text
+ 2: secret-PWD
+ "secret-PWD"
+ ↳ satoshi
+ ↳ abc.org
+ View Password
+ Send Password [MAYBE]
+ Sign Note Text
+ Type Passwords [MAYBE]
+ Seed Vault[IF ENABLED & SSSP RELATED KEYS ENABLED]
+ 1: [7126EB3C]
+ [7126EB3C]
+ Use This Seed
+ 2: [CCEE13B9]
+ [CCEE13B9]
+ Use This Seed
+ 3: [03EE9989]
+ [03EE9989]
+ Use This Seed
+ Advanced/Tools
+ File Management
+ Sign Text File
+ Batch Sign PSBT
+ List Files
+ Export Wallet
+ Sparrow
+ Cove
+ Bitcoin Core
+ Nunchuk
+ Bull Bitcoin
+ Zeus
+ Electrum Wallet
+ Wasabi Wallet
+ Fully Noded
+ Unchained
+ Theya
+ Bitcoin Safe
+ Samourai Postmix
+ Samourai Premix
+ Descriptor
+ Generic JSON
+ Export XPUB
+ Segwit (BIP-84)
+ Classic (BIP-44)
+ P2WPKH/P2SH (BIP-49)
+ Master XPUB
+ Current XFP
+ Dump Summary
+ Verify Sig File
+ NFC File Share [IF NFC ENABLED]
+ BBQr File Share [IF QR SCANNER]
+ QR File Share [IF QR SCANNER]
+ Format SD Card
+ Format RAM Disk [IF VIRTDISK ENABLED]
+ Export Wallet
+ Sparrow
+ Cove
+ Bitcoin Core
+ Nunchuk
+ Bull Bitcoin
+ Zeus
+ Electrum Wallet
+ Wasabi Wallet
+ Fully Noded
+ Unchained
+ Theya
+ Bitcoin Safe
+ Samourai Postmix
+ Samourai Premix
+ Descriptor
+ Generic JSON
+ Export XPUB
+ Segwit (BIP-84)
+ Classic (BIP-44)
+ P2WPKH/P2SH (BIP-49)
+ Master XPUB
+ Current XFP
+ Dump Summary
+ Teleport Multisig PSBT [MAYBE]
+ View Identity
+ Temporary Seed [IF SSSP RELATED KEYS ENABLED]
+ Import from QR Scan [IF QR SCANNER]
+ Import Words
+ 12 Words
+ 18 Words
+ 24 Words
+ Import via NFC [IF NFC ENABLED]
+ Import XPRV
+ Tapsigner Backup
+ Coldcard Backup
+ Paper Wallets
+ NFC Tools [IF NFC ENABLED]
+ Sign PSBT
+ Show Address
+ Sign Message
+ Verify Sig File
+ Verify Address
+ File Share
+ Push Transaction [IF PUSHTX ENABLED]
+ Destroy Seed
+ Secure Logout
+ EXIT TEST DRIVE [MAYBE]
+ SHORTCUT [IF NFC ENABLED]
+ Sign PSBT
+ Show Address
+ Sign Message
+ Verify Sig File
+ Verify Address
+ File Share
+ Push Transaction [IF PUSHTX ENABLED]
+---
+
diff --git a/docs/miniscript.md b/docs/miniscript.md
new file mode 100644
index 000000000..93c8a3166
--- /dev/null
+++ b/docs/miniscript.md
@@ -0,0 +1,28 @@
+# Miniscript
+
+**COLDCARD®** Mk4 experimental `EDGE` versions
+support Miniscript and MiniTapscript.
+
+## Import/Export
+
+* `Settings` -> `Miniscript` -> `Import from file`
+* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) allowed for import
+* `Settings` -> `Miniscript` -> `` -> `Descriptors`
+* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) are exported
+* export extended keys to participate in miniscript:
+ * `Advanced/Tools` -> `Export Wallet` -> `Generic JSON`
+ * `Settings` -> `Multisig Wallets` -> `Export XPUB`
+
+## Address Explorer
+
+Same as with basic multisig. After miniscript wallet is imported,
+item with `` is added to `Address Explorer` menu.
+
+
+## Limitations
+* no duplicate keys in miniscript (at least change indexes in subderivation has to be different)
+* subderivation may be omitted during the import - default `<0;1>/*` is implied
+* both keys with key origin info `[xfp/p/a/t/h]xpub/<0;1>/*` & blinded keys `xpub/<2;3>/*` allowed
+* use of blinded keys for co-signers requires PSBT provider to supply path from current key fingerprint
+* maximum number of keys allowed in segwit v0 miniscript is 20
+* check MiniTapscript limitations in `docs/taproot.md`
\ No newline at end of file
diff --git a/docs/msg-signing.md b/docs/msg-signing.md
index 72a48826e..a79b6dd75 100644
--- a/docs/msg-signing.md
+++ b/docs/msg-signing.md
@@ -41,20 +41,26 @@ IFOvGVJrm31S0j+F4dVfQ5kbRKWKcmhmXIn/Lw8iIgaCG5QNZswjrN4X673R7jTZo1kvLmiD4hlIrbuL
### What is signed
-1. **Single sig address explorer exports**. Signed by key corresponding to first (0th) address on the exported list.
-2. **Specific single sig exports**. Signed by key corresponding to external address at index zero of chosen application specific derivation `m//0/0`
+### What Is Signed
+
+1. **Single sig address explorer exports:** Signed by the key corresponding to the first (0th) address on the exported list.
+2. **Specific single sig exports:** Signed by the key corresponding to the external address at index zero of chosen application specific derivation `m/h/'h/h/0/0`.
* Bitcoin Core
* Electrum Wallet
* Wasabi Wallet
* Samourai Postmix
* Samourai Premix
* Descriptor
-3. **Generic single sig exports**. Signed by key that corresponds to address at derivation `m/44'/'/0'/0/0`
- Lily Wallet
- Generic JSON
- Dump Summary
-4. **BIP85 derived entropy exports**. Signed by path that corresponds to specific BIP85 application.
-5. **Paper wallet exports**. Signed by key and address exported as paper wallet itself.
+3. **Generic single sig exports:** Signed by key that corresponds to first (0th) external address at derivation `m/44h/h/h/0/0`.
+ * Lily Wallet
+ * Generic JSON
+ * Dump Summary
+4. **BIP85 derived entropy exports:** Signed by path that corresponds to specific BIP85 application.
+5. **Paper wallet exports:** Signed by key and address exported as paper wallet itself.
+6. **Multisig exports:** public keys are encoded as P2PKH address for all multisg signature exports
+ * Multisig wallet descriptor: signed by the key corresponding to the first external address of own enrolled extended key `my_key/0/0`
+ * Generic XPUBs export: signed by the key corresponding to the first external address of own standard P2WSH derivation `m/48h/h/h/2h/0/0`
+ * Multisig address explorer export: Signed by own key at the same derivation as first (0th) row on exported list. `my_key//`
### What is NOT signed
diff --git a/docs/seed-xor.md b/docs/seed-xor.md
index e2ac16158..b79fb8436 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.
@@ -157,6 +157,12 @@ with the others on a SEEDPLATE.
- right to A, down to B ... take that number, and go to that column
- down to C, that is answer: a ⊕ b ⊕ c
+## Open Standard
+Seed XOR is an open standard. Other software and hardware wallets are encouraged to
+implement support. No license or permission is required, including usage of the term
+"Seed XOR" when referring to implementations of this feature. Such implementations
+should match the process described in this documentation and be fully interoperable.
+
---
# 24 Words XOR Seed Example Using 3 Parts
diff --git a/docs/spending-policy.md b/docs/spending-policy.md
new file mode 100644
index 000000000..693819834
--- /dev/null
+++ b/docs/spending-policy.md
@@ -0,0 +1,209 @@
+# Spending Policy
+
+This special mode will stop you from signing transactions if they
+exceed a spending policy you define beforehand. Once enabled, many
+features of the COLDCARD are disabled or inaccessible.
+
+You might want to use this feature when traveling with your COLDCARD.
+
+## Spending Policy: Multisig (formerly CCC)
+
+We also support a mode where the COLDCARD is a multisig co-signer
+and only performs its signature when a spending policy is met. The
+other multisig signers are free to sign or not sign as appropriate.
+
+Multisig mode is more advanced and requires use of multisig addresses,
+new UTXO, and cooperating multisig on-chain wallets.
+
+This document will only discuss the "Single signer" version of
+Spending Policy. Both modes can be active at the same time, but if
+a transaction would be signed by Multisig policy, then we assume
+it's also okay to sign your main key as well.
+
+# Before You Start
+
+When a Spending Policy is in effect, there are limitations
+in effect:
+
+- Firmware updates are blocked.
+- There is no way to backup the COLDCARD.
+- Seed vault and Secure Notes are read-only (and can also be hidden).
+- Settings menu is inaccessible.
+- BIP-39 passphrases may be blocked (optional).
+
+We recommend getting the COLDCARD fully configured and setup
+for typical transactions before enabling the Spending Policy.
+
+# Setup Spending Policy
+
+Visit `Advanced / Tools > Spending Policy` menu and choose
+"Single-Signer". First some background information is shown,
+then you are prompted to define the "Bypass PIN". This PIN code
+is only used when you need to disable the spending policy, but is
+also the only way to do so once enabled... so don't loose it.
+
+Once the "Bypass PIN" is confirmed, you will arrive at menu for
+related settings. Use "Edit Policy..." to change the spending policy
+and define a Max Magnitude (limit number of BTC per transaction),
+Velocity (minimum time gaps between signed transactions). You can
+define a whitelist of up to 25 destination addresses (leave empty
+for any). Finally you can enroll your phone in 2FA (second factor)
+so that you must open an Authenticator app on your phone before
+transactions are signed.
+
+## Other Security Settings
+
+In addition to policy itself, there are a number of on/off
+switches which affect operation of the COLDCARD while the Spending
+Policy is in effect:
+
+### Word Check
+
+If enabled, you will have to enter the first and last seed word
+after the Bypass PIN as an additional security check.
+
+### Allow Notes
+
+On the Q, secure notes and passwords may be visible or hidden
+using this setting. In either case they are strictly readonly.
+
+### Related Keys
+
+BIP-39 passphrase entry, Seed Vault usage will be blocked unless this
+setting is enabled. Even when enabled, the Seed Vault is always readonly
+and cannot be changed.
+
+# Other Menu Items
+
+## Last Violation
+
+If you have recently tried and failed to sign a transaction, the
+reason for the transaction being rejected can be viewed and cleared,
+using menu item "Last Violation". It is shown only if a Spending
+Policy violation (attempt) has occurred since the last valid signing.
+
+This is meant as a debugging tool, and the information stored is
+terse.
+
+## Remove Policy
+
+This will remove your spending policy completely and remove
+the Bypass PIN. Your COLDCARD will be back to normal.
+
+## Test Drive
+
+Experiment with how the COLDCARD will function if the Spending
+Policy was enabled. You can try to sign transactions that should
+be rejected and view the menus in the new mode without rebooting.
+
+Choose "EXIT TEST DRIVE" on top menu to return to the Spending
+Policy menu. Reboot will also restore normal operation without
+any special challenges.
+
+## ACTIVATE
+
+This step will enable the Spending Policy and return to the
+main menu with it in effect. When you reboot the COLDCARD,
+the policy will still be in effect. You must use the
+Bypass PIN, followed by the normal main PIN, possibly
+followed by entering the first and last words of your seed
+phrase, before you can disable and change the policy.
+
+We recommend test-driving the feature before doing that.
+
+
+# Tips and Tricks
+
+## Money Manager Mode
+
+You could setup a Coldcard for another person, perhaps a family member,
+and enable web 2FA authentication. There does not need to be any
+other spending policy limits (velocity could be unlimited).
+
+Then enroll your own phone with the required 2FA values, and
+keep both that and the spending policy bypass PIN confidential.
+
+The holder the the Coldcard will need a 2FA code from your phone
+when they want to spend. They can call you for the 6-digit code
+from the 2FA app on your phone. This is not hard to provide over a
+voice call.
+
+Because a spending policy is in effect, they will not be able to
+see the seed words, other private key material, so regardless of
+any spoofing or phishing, they cannot move funds without your help.
+
+You should record the bypass PIN, so it can be revealed somehow,
+should you die. You do not need to share the risks associated with
+holding a copy of the seed words.
+
+## Passphrase Considerations
+
+If you are using the same BIP-39 passphrase for everything, you should
+probably do a "Lock Down Seed" (Advanced/Tools > Danger Zone > Seed
+Functions) first. This takes your master seed and BIP-39 passphrase
+and cooks them together into an XPRV which then is stored as your
+master secret. (Replacing the master seed phrase.) This process
+cannot be reversed, so other funds you may have on the same seed
+words are protected. Once you are operating in XPRV mode, you can
+define a spending policy, and know that it is restricted to only
+that wallet.
+
+When operating in XPRV mode, the "Passphrase" menu item is not shown
+because BIP-39 passwords cannot be applied to XPRV secrets.
+
+## Trick PIN Thoughts
+
+When doing your game theory w.r.t to bypass mode and this feature,
+remember that you should assume the attacker already has your main
+PIN. That's how they know they cannot spend all your coin, because
+they either tried to, or noticed the menus are very limited. They also
+have all your UTXO locations and total wallet balance (because they
+can export your xpubs to any wallet and load balance from there).
+
+Therefore, a trick pin that leads to a duress wallet after giving up
+the bypass unlock PIN, will not fool them. Best would be to provide
+a false bypass PIN that is in fact a brick/wipe PIN.
+
+
+## Lock Out Changes to Policy
+
+In the Trick Pin menu once Spending Policy has been enabled, you will
+find the Bypass PIN listed. You could delete or "hide" it. Hiding
+it is pointless since you cannot get to the trick PIN menu while
+the policy is in effect. Deleting the PIN however, is useful because
+it assures changes to spending policy are impossible. To recover
+the COLDCARD when this move is later regretted, under Advanced,
+there is "Destroy Seed" option which will clear the seed words and
+all settings, including the spending policy.
+
+### Unlock Policy & Wipe
+
+We've provided a new trick PIN that pretends to be the unlock
+spending policy pin, so the login sequence is correct... but it
+will wipe the seed in the process. It will be obvious to your
+attackers that you've wiped the seed because the main PIN will lead
+to blank wallet now (no seed loaded).
+
+### Delta Mode and Spending Policy
+
+If, from the start, you gave your "delta mode PIN" to the attackers,
+then when they bypass the policy (after also getting the bypass PIN
+from you), they will still be in Delta Mode.
+
+They could attempt unlimited spending, but transactions signed will
+not be valid. If they try to view the seed words or generally export
+private key material, they will hit many of the "wipe seed if delta
+mode" cases.
+
+## Forgotten Bypass PIN Code
+
+If you've enabled a spending policy and still remember the main PIN,
+but cannot disable the feature because you've forgotten the Bypass
+PIN, your only option is to use `Advanced > Destroy Seed`. After
+some confirmations, this erases the master seed, all settings, seed
+vault items, secure notes, and trick pins. It's basically a factory
+reset except for the main PIN code which is unchanged. Once you've
+done that, you can enter your seed words from backup (or restore a
+backup file) and continue to use the COLDCARD again.
+
+
diff --git a/docs/taproot.md b/docs/taproot.md
new file mode 100644
index 000000000..df58eba8f
--- /dev/null
+++ b/docs/taproot.md
@@ -0,0 +1,77 @@
+# Taproot
+
+**COLDCARD®** Mk4 experimental `EDGE` versions
+support Schnorr signatures ([BIP-0340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)),
+Taproot ([BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki))
+and Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support.
+
+## Output script (a.k.a address) generation
+
+If the spending conditions do not require a script path, the output key MUST commit to an unspendable script path.
+`Q = P + int(hashTapTweak(bytes(P)))G` a.k.a internal key MUST be tweaked by `TapTweak` tagged hash of itself. If
+the spending conditions require script path, internal key MUST be tweaked by `TapTweak` tagged hash of tree merkle root.
+
+Addresses in `Address Explorer` for `p2tr` are generated with above-mentioned methods. Outputs `scriptPubkeys` in PSBT
+MUST be generated with above-mentoned methods to be considered change.
+
+## Allowed descriptors
+
+1. Single signature wallet without script path: `tr(key)`
+2. Tapscript multisig with internal key and up to 8 leaf scripts:
+ * `tr(internal_key, sortedmulti_a(2,@0,@1))`
+ * `tr(internal_key, pk(@0))`
+ * `tr(internal_key, {sortedmulti_a(2,@0,@1),pk(@2)})`
+ * `tr(internal_key, {or_d(pk(@0),and_v(v:pkh(@1),older(1000))),pk(@2)})`
+
+## Provably unspendable internal key
+
+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.
+
+ `tr(xpub/<0:1>/*, sortedmulti_a(2,@0,@1))` which is the same thing as `tr(xpub, sortedmulti_a(2,@0,@1))` because `/<0;1>/*` is implied if not derivation path not provided.
+
+### Below option was deprecated in version 6.3.5X & 6.3.5QX
+2. Use `unspend(` [notation](https://gist.github.com/sipa/06c5c844df155d4e5044c2c8cac9c05e#unspendable-keys). Has to be ranged.
+
+ `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))`
+
+4. use COLDCARD specific placeholder `@` to let HWW pick a fresh integer r in the range 0...n-1 uniformly at random and use `H + rG` as internal key. COLDCARD will not store r and therefore user is not able to prove to other party how the key was generated and whether it is actually unspendable.
+
+ `tr(r=@, sortedmulti_a(MofN))`
+
+5. pick a fresh integer r in the range 0...n-1 uniformly at random yourself and provide that in the descriptor. COLDCARD generates internal key with `H + rG`. It is possible to prove to other party that this internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then reconstruct how the internal key was created.
+
+ `tr(r=77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76, sortedmulti_a(2,@0,@1))`
+
+Option 3. leaks the information that key path spending is not possible and therefore is not recommended privacy-wise.
+Options 4. and 5. are problematic to some extent as internal key is static. Use recommended options 1. and 2. if the fact that internal key is unspendable should remain private.
+
+
+## Limitations
+
+### Tapscript Limitations
+
+In current version only `TREE` of max depth 4 is allowed (max 8 leaf script allowed).
+Taproot single leaf multisig has artificial limit of max 32 signers (M=N=32).
+Number of keys in whole taptree is limited to 32.
+
+If Coldcard can sign by both key path and script path - key path has precedence.
+
+### PSBT Requirements
+
+PSBT provider MUST provide following Taproot specific input fields in PSBT:
+1. `PSBT_IN_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
+2. `PSBT_IN_TAP_INTERNAL_KEY` MUST match internal key provided in `PSBT_IN_TAP_BIP32_DERIVATION`
+3. `PSBT_IN_TAP_MERKLE_ROOT` MUST be empty if there is no script path. Otherwise it MUST match what Coldcard can calculate from registered descriptor.
+4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path.
+
+PSBT provider MUST provide following Taproot specific output fields in PSBT:
+1. `PSBT_OUT_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
+2. `PSBT_OUT_TAP_INTERNAL_KEY` must match internal key provided in `PSBT_OUT_TAP_BIP32_DERIVATION`
+3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined.
\ No newline at end of file
diff --git a/docs/web2fa.md b/docs/web2fa.md
new file mode 100644
index 000000000..3923b3f34
--- /dev/null
+++ b/docs/web2fa.md
@@ -0,0 +1,94 @@
+# Web 2FA Authentication
+
+How to support [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238)
+TOTP (Time based One Time Password) 2FA check, on our little embedded
+device without a real-time clock?
+
+Solution: Store the pre-shared secret in the COLDCARD, and send that
+securely to a trusted webserver which knows the time and can do a
+fancy UX. That webserver accepts the time-based-one-time 2FA numeric
+code from the user, and if correct, reveals a secret
+that can be used back on the COLDCARD to authorize an action.
+
+For the Mk4, the secret is 8 digit numeric code to be entered,
+for the COLDCARD Q, it is a QR code to be scanned.
+
+### History / Background
+
+The HSM feature uses HOTP tokens, which do not require a backend,
+but are not as robust as time-based tokens.
+
+Web2FA is available to be enabled as part of a Spending Policy,
+both in Multisig and Single Signer modes. When enabled, you will be
+prompted complete 2FA authentication after viewing the details of
+the transaction to be signed. You will not be able to sign without
+the correct code.
+
+## How It Works
+
+- Web backend has a ECC keypair, with pubkey known to CC firmware releases.
+- Usual 2fa base32 secret is picked by CC and stored in CC (so that server is stateless)
+- CC creates URL encrypted to the pubkey of server, containing args:
+ - shared secret for TOTP (same value as held in user's phone)
+ - the response nonce (16 bytes, or 8 digits for Mk4) to be revealed to the user
+ on successful auth
+ - flag if Q model, so can provide a QR to be scanned in that case (rather than digits)
+ - some text label for what's being approved, which is presented to user so they can pick
+ correct 2fa shared secret.
+ - above is all encrypted in transit, and only the server can decrypt
+- user is sent to that encrypted URL using NFC tap on the COLDCARD
+- user arrives at server:
+ - shown label [which also indicates the server can be trusted, since only it could decrypt it]
+ - prompt for 6 digits from authenticator app
+ - does [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238) 2FA check using current time
+- checks using current time and the shared secret provided by CC, fails if wrong.
+ - time based failure: offer retry (they typed too slow / minor clock drift)
+ - can offer to retry, but also do some rate limiting (only one attempt per 30-sec period)
+ - server will store very recent responses so attacker cannot get two codes
+ in any 30sec period (ie. blocks immediate reuse of same URL)
+ - until a valid code is given, user is stuck here
+- when valid token received:
+ - if Q, show a QR code to be scanned, with the full nonce
+ - for non-Q system, a 8-digit decimal value is given: user has to enter that into the COLDCARD
+ - web site shows instructions about what to do next on product.
+
+## From COLDCARD PoV
+
+- makes complex encrypted URL, which contains a nonce it wants, waits for that nonce back (or QR)
+- it's either the nonce from the URL, or fail
+- if the right nonce, then we know the server knows the decryption key, and we
+ are trusting it actually verify the 2FA token properly.
+
+## Encryption - Simple ECDH
+
+- CC picks a secp256k1 keypair, generates compressed pubkey
+- multiplies that private key by server's known public key
+- apply sha256(resulting coordinate) => the session key
+- apply AES-256-CTR over URL contents (ascii text)
+- prepend 33 bytes of pubkey, and then base64url encode all of it
+- full url is: `https://coldcard.com/2fa?{base64 encoded binary}`
+
+## Trust Issues
+
+- 2FA enrol happens on the CC, which picks the shared secret and shows QR for mobile
+ app setup. Same TRNG process as picking a seed.
+- Server knows the shared secret, but only during operation, and we won't store it [sorry,
+ gotta trust us on that, but no help to us to store it].
+- Only we can run the server, because the private key is company-secret.
+- MiTM and network snoopers get nothing because HTTPS is used and only your browser
+ can see the nonce, and only after you've given the right digits.
+- Coinkite server could skip the 2FA checks and just give you the answer
+ you want to type into the COLDCARD. Again, you have to trust us on that.
+
+## URL Format
+
+ https://coldcard.com/2fa?ss={shared_secret}&q={is_q}&g={nonce}&nm={label_text}
+
+- `shared_secret`: 16 chars of Base32-encoded pre-shared secret
+- `is_q`: flag indicating use of QR to provide nonce back to user
+- `nonce`: text string that is either 8 digits for Mk4, or hex digits for QR
+- `nm`: human readable label for the transaction/purpose
+
+Server will accept plaintext arguments as above, but normally everything
+after the question mark is encrypted.
+
diff --git a/external/ckcc-protocol b/external/ckcc-protocol
index 0e686dbda..2afc7d34d 160000
--- a/external/ckcc-protocol
+++ b/external/ckcc-protocol
@@ -1 +1 @@
-Subproject commit 0e686dbda686f76c4d3e8069558b2a31f9d1c2b1
+Subproject commit 2afc7d34d27568f984022c6e006408bf6b50e369
diff --git a/external/libngu b/external/libngu
index 1cccb25ef..537519a82 160000
--- a/external/libngu
+++ b/external/libngu
@@ -1 +1 @@
-Subproject commit 1cccb25ef7736efae4a1de83d5dbdc13a2db0e80
+Subproject commit 537519a829259622ea6b0334fbafd6cae852852f
diff --git a/external/micropython b/external/micropython
index 97d35f058..4107246f8 160000
--- a/external/micropython
+++ b/external/micropython
@@ -1 +1 @@
-Subproject commit 97d35f058f504a354fc6df79a8b3db5c91862501
+Subproject commit 4107246f8a080807b62c3b4838e71e812ea68b6f
diff --git a/graphics/graphics_mk4.py b/graphics/graphics_mk4.py
index c2ca930d3..4db6a7ddb 100644
--- a/graphics/graphics_mk4.py
+++ b/graphics/graphics_mk4.py
@@ -19,7 +19,7 @@ class Graphics:
scroll = (3, 61, 1, 0, b'@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@@\xe0@')
- selected = (15, 12, 2, 0, b'\x00\x00\x00\x00\x00\x06\x00\x0c\x00\x18\x0000`\x18\xc0\r\x80\x07\x00\x02\x00\x00\x00')
+ selected = (9, 12, 2, 0, b'\x00\x00\x00\x00\x00\x80\x01\x80\x01\x00\x03\x00\x82\x00\xc6\x00d\x00<\x00\x18\x00\x00\x00')
sm_box = (11, 17, 2, 0, b'\xe4\xe0\x80 \x80 \x80 \x00\x00\x00\x00\x80 \x00\x00\x00\x00\x00\x00\x80 \x00\x00\x00\x00\x80 \x80 \x80 \xe4\xe0')
diff --git a/graphics/mono/selected.txt b/graphics/mono/selected.txt
index 827efe94c..faebfd6b7 100644
--- a/graphics/mono/selected.txt
+++ b/graphics/mono/selected.txt
@@ -1,12 +1,12 @@
- xx
- xx
- xx
- xx
- xx xx
- xx xx
- xx xx
- xxx
- x
+ X
+ XX
+ X
+ XX
+X X
+XX XX
+ XX X
+ XXXX
+ XX
diff --git a/misc/binfonter/config.py b/misc/binfonter/config.py
index e028da502..dbe190b31 100644
--- a/misc/binfonter/config.py
+++ b/misc/binfonter/config.py
@@ -74,4 +74,9 @@
x x x x x
'''),
+# thin space
+('\u2009', dict(y=0, w=5), '''\
+
+'''),
+
])
diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md
index f9390be1c..2e09779de 100644
--- a/releases/ChangeLog.md
+++ b/releases/ChangeLog.md
@@ -4,53 +4,25 @@ 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).
+- Enhancement: Address format guessing changed away from using PSBT XPUB's derivation paths.
+ Now based on witness/redeem script of first PSBT input instead.
+- Enhancement: Show master XFP of backup secret and ask user for confirmation before loading backup.
+- Enhancement: Show firmware version added to hobbled Advanced/Tools menu.
+- Bugfix: Exiting text input of Custom Backup Password caused yikes.
+- Bugfix: Temporary seeds in SSSP mode were not able to update block height.
# Mk4 Specific Changes
-## 5.4.0 - 2024-09-12
+## 5.4.5 - 2025-11-03
-- Shared enhancements and fixes listed above.
-- Bugfix: Correct intermittent card inserted/not inserted detection error.
+- None.
# Q Specific Changes
-## 1.3.0Q - 2024-09-12
-
-- 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.3.5Q - 2025-11-03
+
+- Enhancement: Show backup filename at the top of the screen during backup password entry.
diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md
new file mode 100644
index 000000000..bbb0c2076
--- /dev/null
+++ b/releases/EdgeChangeLog.md
@@ -0,0 +1,62 @@
+# Change Log
+
+## Warning: Edge Version
+
+```diff
+- 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.
+```
+
+This lists the changes in the most recent EDGE firmware, for each hardware platform.
+
+# Shared Improvements - Both Mk4 and Q
+
+### WARNING: 6.4.0X is not backwards-compatible with previous EDGE firmware versions.
+#### 6.4.0X stores multisig wallet internally as Miniscript wallets. Newly created multisig wallets won't be visible if you downgrade after creating them on 6.4.0X. Existing multisig wallets will be converted into Miniscript, yet preserved in old format if downgrade is desired.
+
+- New Feature: Key Teleport
+- New Feature: Spending Policy for Miniscript Wallets
+- New Feature: Internal descriptor cache speeding up sequential operation with miniscript wallets.
+ To take full advantage of the feature work with miniscript wallets sequentially. First, do all operations
+ needed with `wallet1` before changing to `wallet2`.
+- New Feature: Add ability to import/export [BIP-388](https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki) Wallet Policies.
+ BIP-388 policies are now also used as our wallet serialization format, which optimized setting storage.
+- New Feature: Sign with specific miniscript wallet. `Settings -> Multisig/Miniscript -> -> Sign PSBT`
+- New Feature: Miniscript wallet name can be specified for `sign` USB command
+- New Feature: Rename Miniscript wallet via UX. `Settings -> Multisig/Miniscript -> -> Rename`.
+- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
+ Navigate to `Advanced/Tools -> Export Wallet -> Key Expression`
+- Enhancement: Slightly faster HW accelerated tagged hash
+- Enhancement: PSBT class optimizations. Ability to sign bigger txn.
+- Enhancement: Signing TXN UI shows Miniscript wallet name.
+- Change: Deprecation of legacy mulitsig import format. Ability to import/export in this format was removed.
+ Old functionality - renaming by reimporting descriptor with different name was removed.
+ Use descriptors or BIP-388 wallet policies
+- Change: Deprecated `p2sh` USB command. Use `miniscript` USB commands to handle multisig wallets.
+- Change: Descriptor template was remove from Generic JSON export, and `key_exp` was added
+ with BIP-380 extended key expression `[xfp/origin_path]xpub`.
+- Bugfix: Disjoint derivation in miniscript wallets
+- Bugfix: Disallow P2SH legacy miniscript
+- Bugfix: Do not allow to import miniscripts with relative lock without consensus meaning.
+ Only allow to import block-based in range `older(1 - 65535)` & time-based in range `older(4194305 - 4259839)`
+
+# Mk4 Specific Changes
+
+## 6.4.0X - 2025-11-20
+
+- synced with master up to `5.4.5`
+- Enhancement: Show QR of XOR-split seeds
+
+
+# Q Specific Changes
+
+## 6.4.0QX - 2025-11-20
+
+- synced with master up to `1.3.5Q`
+
+
+# Release History
+
+- [`History-Edge.md`](History-Edge.md)
diff --git a/releases/History-Edge.md b/releases/History-Edge.md
new file mode 100644
index 000000000..4d7223847
--- /dev/null
+++ b/releases/History-Edge.md
@@ -0,0 +1,110 @@
+## Warning: Edge Version
+
+```diff
+- 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.
+```
+
+# 6.3.5X & 6.3.5QX Shared Improvements - Both Mk4 and Q
+
+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
+
+- all updates from `5.4.1`
+
+# Q Specific Changes
+
+- all updates from version `1.3.1Q`
+
+
+# 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
+- New Feature: Address ownership for miniscript and tapscript wallets
+- Enhancement: Address explorer simplified UI for tapscript addresses
+- Bugfix: Constant `AFC_BECH32M` incorrectly set `AFC_WRAPPED` and `AFC_BECH32`.
+- Bugfix: Trying to set custom URL for NFC push transaction caused yikes
+
+### Mk4 Specific Changes
+
+- Bugfix: Fix yikes displaying BIP-85 WIF when both NFC and VDisk are OFF
+- Bugfix: Fix inability to export change addresses when both NFC and Vdisk id OFF
+- Bugfix: In BIP-39 words menu, show space character rather than Nokia-style placeholder
+ which could be confused for an underscore.
+
+### Q Specific Changes
+
+- Enhancement: Miniscript and (BB)Qr codes
+- Bugfix: Properly clear LCD screen after simple QR code is shown
+
+
+## 6.2.2X - 2024-01-18
+
+- New Feature: Miniscript [USB interface](https://github.com/Coldcard/ckcc-protocol/blob/master/README.md#miniscript)
+- New Feature: Named miniscript imports. Wrap descriptor in json
+ `{"name:"n0", "desc":""}` with `name` key to use this name instead of the
+ filename. Mostly usefull for USB and NFC imports that have no file, in which case name
+ was created from descriptor checksum.
+- Enhancement: Allow keys with same origin, differentiated only by change index derivation
+ in miniscript descriptor.
+- Enhancement: HSM `wallet` rule enabled for miniscript
+- Enhancement: Add `msas` in to the `share_addrs` HSM [rule](https://coldcard.com/docs/hsm/rules/)
+ to be able to check miniscript addresses in HSM mode.
+- Enhancement: HW Accelerated AES CTR for BSMS and passphrase saver
+- Bugfix: Do not allow to import duplicate miniscript
+ wallets (thanks to [AnchorWatch](https://www.anchorwatch.com/))
+- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot
+
+## 6.2.1X - 2023-10-26
+
+- New Feature: Enroll Miniscript wallet via USB (requires ckcc `v1.4.0`)
+- New Feature: Temporary Seed from COLDCARD encrypted backup
+- Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu.
+ If current active temporary seed is not saved yet, `Add current tmp` menu item is
+ present in Seed Vault menu.
+- Reorg: `12 Words` menu option preferred on the top of the menu in all the seed menus
+- Enhancement: Mainnet/Testnet separation. Only show wallets for current active chain.
+- contains all the changes from the newest stable `5.2.0-mk4` firmware
+
+## 6.1.0X - 2023-06-20
+
+- New Feature: Miniscript and MiniTapscript support (`docs/miniscript.md`)
+- Enhancement: Tapscript up to 8 leafs
+- Address explorer display refined slightly (cosmetic)
+
+## 6.0.0X - 2023-05-12
+
+- New Feature: Taproot keyspend & Tapscript multisig `sortedmulti_a` (tree depth = 0)
+- New Feature: Support BIP-0129 Bitcoin Secure Multisig Setup (BSMS).
+ Both Coordinator and Signer roles are supported.
+- Enhancement: change Key Origin Information export format in multisig `addresses.csv` according to [BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions)
+ `(m=0F056943)/m/48'/1'/0'/2'/0/0` --> `[0F056943/48'/1'/0'/2'/0/0]`
+- Bugfix: correct `scriptPubkey` parsing for segwit v1-v16
+- Bugfix: do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT
\ No newline at end of file
diff --git a/releases/History-Mk4.md b/releases/History-Mk4.md
index e1a65346a..3a78f39e5 100644
--- a/releases/History-Mk4.md
+++ b/releases/History-Mk4.md
@@ -1,6 +1,156 @@
*See ChangeLog.md for more recent changes, these are historic versions*
+## 5.4.4 - 2025-09-30
+
+- Spending policies for "Single Signers" adds new spending policy options:
+ - limit your Coldcard so it refuses to sign transactions that are "too big"
+ - require 2FA authentication before signing any transaction (NFC+web)
+ - velocity limits can restrict how often new transactions can be signed
+ - see `docs/spending-policy.md` for more details
+ - "Enable HSM" and "User Management" have moved into `Advanced > Spending Policy`.
+ - Old "CCC" feature has been renamed and moved into that menu as well: "Co-Sign Multisig"
+- Added `Bull Bitcoin` export to `Export Wallet` menu.
+- Enhancement: Added warning for zero value outputs if not `OP_RETURN`.
+- Enhancement: Show QR codes of output addresses in transaction output explorer. Explorer is
+ now offered for transactions of all sizes, not just complex ones.
+- Enhancement: Added file rename, when listing contents of SD card.
+- Enhancement: Added ability to restore Coldcard backup via USB (needs latest of ckcc version)
+- Enhancement: Address ownership allows to specify particular multisig wallet in which to search,
+ if `wallet` query parameter is provided via trivial extension to
+ [BIP-21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki).
+ Example: `tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=Haystack`
+- Bugfix: If all change outputs have `nValue=0`, they were not shown in UX.
+- Bugfix: Disallow negative input/output amounts in PSBT.
+- Bugfix: Fix filesystem initialization after Wipe LFS or Destroy Seed.
+- Bugfix: Fix MicroSD selftest code.
+- Bugfix: NFC loop exporting secrets would not work after first value exported.
+- Bugfix: Multisig address format handling.
+- Bugfix: Ownership check failing to find addresses near max (~760), needed to be re-run to succeed
+- (Mk4 only) Bugfix: Part of extended keys (xpubs) were not always visible.
+- (Mk4 only) Change: Mk4 default menu wrap-around lowered from 16 to 10 items.
+
+
+## 5.4.3 - 2025-05-14
+
+- Enhancement: Text word-wrap done more carefully so never cuts off any text, and yet
+ doesn't waste space.
+- Bugfix: `Add current tmp` option, which could be shown in `Seed Vault` menu under
+ specific circumstances, would corrupt master settings if selected.
+- Bugfix: PUSHDATA2 in bitcoin script caused yikes.
+- Bugfix: Warning for unknown scripts was not shown at the top of the signing story.
+- Bugfix: With both NFC & Virtual Disk OFF, user cannot exit `Export Wallet` menu. Gets stuck
+ in export loop and needs reboot to escape.
+- Bugfix: Part of extended keys in stories were not always visible.
+
+
+## 5.4.2 - 2025-04-16
+
+- Huge new feature: CCC - ColdCard Cosign
+ - COLDCARD holds a key in a 2-of-3 multisig, in addition to the normal signing key it has.
+ - it applies a spending policy like an HSM:
+ - velocity and magnitude limits
+ - whitelisted destination addresses
+ - 2FA authentication using phone app ([RFC 6238](https://www.rfc-editor.org/rfc/rfc6238))
+ - but will sign its part of a transaction automatically if those condition are met,
+ giving you 2 keys of the multisig and control over the funds
+ - spending policy can be exceeded with help of the other co-signer (3rd key), when needed
+ - cannot view or change the CCC spending policy once set, policy violations are not explained
+ - existing multisig wallets can be used by importing the spending-policy-controlled key
+- New Feature: Multisig transactions are finalized. Allows use of [PushTX](https://pushtx.org/)
+ with multisig wallets. Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/limitations.md#p2sh--multisig)
+- New Feature: Signing artifacts re-export to various media. Now you have the option of
+ exporting the signing products (transaction/PSBT) to different media than the original source.
+ Incoming PSBT over QR can be signed and saved to SD card if desired.
+- New Feature: Multisig export files are signed now. Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/msg-signing.md#signed-exports)
+- Enhancement: NFC export usability upgrade: NFC keeps exporting until CANCEL/X is pressed
+- Enhancement: Add `Bitcoin Safe` option to `Export Wallet`
+- Enhancement: 10% performance improvement in USB upload speed for large files
+- Bugfix: Do not allow change Main PIN to same value already used as Trick PIN, even if
+ Trick PIN is hidden.
+- Bugfix: Fix stuck progress bar under `Receiving...` after a USB communications failure
+- Bugfix: Showing derivation path in Address Explorer for root key (m) showed double slash (//)
+- Bugfix: Can restore developer backup with custom password other than 12 words format
+- Bugfix: Virtual Disk auto mode ignores already signed PSBTs (with "-signed" in file name)
+- Bugfix: Virtual Disk auto mode stuck on "Reading..." screen sometimes
+- Bugfix: Finalization of foreign inputs from partial signatures. Thanks Christian Uebber
+- Bugfix: Temporary seed from COLDCARD backup failed to load stored multisig wallets
+- Change: `Destroy Seed` also removes all Trick PINs from SE2.
+- Change: `Lock Down Seed` requires pressing confirm key (4) to execute
+
+## 5.4.1 - 2025-02-13
+
+- 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 Change:
+ - Enhancement: Export single sig descriptor with simple QR.
+
+
+## 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
- New Feature: PushTX: once enabled with a service provider's URL, you can tap the COLDCARD
diff --git a/releases/History-Q.md b/releases/History-Q.md
index 6553be86f..b0023eb53 100644
--- a/releases/History-Q.md
+++ b/releases/History-Q.md
@@ -1,5 +1,222 @@
*See ChangeLog.md for more recent changes, these are historic versions*
+## 1.3.4Q - 2025-09-30
+
+- Spending policies for "Single Signers" adds new spending policy options:
+ - limit your Coldcard so it refuses to sign transactions that are "too big"
+ - require 2FA authentication before signing any transaction (NFC+web)
+ - velocity limits can restrict how often new transactions can be signed
+ - see `docs/spending-policy.md` for more details
+ - "Enable HSM" and "User Management" have moved into `Advanced > Spending Policy`.
+ - Old "CCC" feature has been renamed and moved into that menu as well: "Co-Sign Multisig"
+- Added `Bull Bitcoin` export to `Export Wallet` menu.
+- Enhancement: Added warning for zero value outputs if not `OP_RETURN`.
+- Enhancement: Show QR codes of output addresses in transaction output explorer. Explorer is
+ now offered for transactions of all sizes, not just complex ones.
+- Enhancement: Added file rename, when listing contents of SD card.
+- Enhancement: Added ability to restore Coldcard backup via USB (needs latest of ckcc version)
+- Enhancement: Address ownership allows to specify particular multisig wallet in which to search,
+ if `wallet` query parameter is provided via trivial extension to
+ [BIP-21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki).
+ Example: `tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=Haystack`
+- Bugfix: If all change outputs have `nValue=0`, they were not shown in UX.
+- Bugfix: Disallow negative input/output amounts in PSBT.
+- Bugfix: Fix filesystem initialization after Wipe LFS or Destroy Seed.
+- Bugfix: Fix MicroSD selftest code.
+- Bugfix: NFC loop exporting secrets would not work after first value exported.
+- Bugfix: Multisig address format handling.
+- Bugfix: Ownership check failing to find addresses near max (~760), needed to be re-run to succeed
+- (Q only) Enhancement: Enters "forever calculator" mode when Q would otherwise be electronic waste
+ (ie. after 13 PIN failures). Always enabled, regardless of "login calculator" setting.
+- (Q only) Bugfix: Correct line positioning when 24 seed words displayed.
+
+
+## 1.3.3Q - 2025-05-14
+
+- Enhancement: Text word-wrap done more carefully so never cuts off any text, and yet
+ doesn't waste space.
+- Bugfix: `Add current tmp` option, which could be shown in `Seed Vault` menu under
+ specific circumstances, would corrupt master settings if selected.
+- Bugfix: PUSHDATA2 in bitcoin script caused yikes.
+- Bugfix: Warning for unknown scripts was not shown at the top of the signing story.
+
+- Bugfix: Do not allow to teleport PSBTs from SD card when CC has no secrets.
+- Bugfix: Calculator login mode: added "rand()" command, removed support
+ for variables/assignments.
+
+
+## 1.3.2Q - 2025-04-16
+
+- Feature: Key Teleport -- Easily and securely move seed phrases, secure notes/passwords,
+ multisig PSBT files, and even full Coldcard backups, between two Q using QR codes
+ and/or NFC with helper website. See protocol spec in
+ [docs/key-teleport.md](https://github.com/Coldcard/firmware/blob/master/docs/key-teleport.md)
+ - can send master seed (words, xprv), anything held in seed vault, secure notes/passwords
+ (singular, or all) and PSBT involved in a multisig to the other co-signers
+ - full COLDCARD backup is possible as well, but receiver must be "unseeded" Q for best result
+ - ECDH to create session key for AES-256-CTR, with another layer of AES-256-CTR using a
+ short password (stretched by PBKDF2-SHA512) inside
+ - receiver shows sender a (simple) QR and a numeric code; sender replies with larger BBQr
+ and 8-char password
+- Enhancement: Always choose the biggest possible display size for QR
+- Bugfix: Only BBQr is allowed to export Coldcard, Core, and pretty descriptor
+- Huge new feature: CCC - ColdCard Cosign
+ - COLDCARD holds a key in a 2-of-3 multisig, in addition to the normal signing key it has.
+ - it applies a spending policy like an HSM:
+ - velocity and magnitude limits
+ - whitelisted destination addresses
+ - 2FA authentication using phone app ([RFC 6238](https://www.rfc-editor.org/rfc/rfc6238))
+ - but will sign its part of a transaction automatically if those condition are met,
+ giving you 2 keys of the multisig and control over the funds
+ - spending policy can be exceeded with help of the other co-signer (3rd key), when needed
+ - cannot view or change the CCC spending policy once set, policy violations are not explained
+ - existing multisig wallets can be used by importing the spending-policy-controlled key
+- New Feature: Multisig transactions are finalized. Allows use of [PushTX](https://pushtx.org/)
+ with multisig wallets. Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/limitations.md#p2sh--multisig)
+- New Feature: Signing artifacts re-export to various media. Now you have the option of
+ exporting the signing products (transaction/PSBT) to different media than the original source.
+ Incoming PSBT over QR can be signed and saved to SD card if desired.
+- New Feature: Multisig export files are signed now. Read more [here](https://github.com/Coldcard/firmware/blob/master/docs/msg-signing.md#signed-exports)
+- Enhancement: NFC export usability upgrade: NFC keeps exporting until CANCEL/X is pressed
+- Enhancement: Add `Bitcoin Safe` option to `Export Wallet`
+- Enhancement: 10% performance improvement in USB upload speed for large files
+- Bugfix: Do not allow change Main PIN to same value already used as Trick PIN, even if
+ Trick PIN is hidden.
+- Bugfix: Fix stuck progress bar under `Receiving...` after a USB communications failure
+- Bugfix: Showing derivation path in Address Explorer for root key (m) showed double slash (//)
+- Bugfix: Can restore developer backup with custom password other than 12 words format
+- Bugfix: Virtual Disk auto mode ignores already signed PSBTs (with "-signed" in file name)
+- Bugfix: Virtual Disk auto mode stuck on "Reading..." screen sometimes
+- Bugfix: Finalization of foreign inputs from partial signatures. Thanks Christian Uebber
+- Bugfix: Temporary seed from COLDCARD backup failed to load stored multisig wallets
+- Change: `Destroy Seed` also removes all Trick PINs from SE2.
+- Change: `Lock Down Seed` requires pressing confirm key (4) to execute
+
+## 1.3.1Q - 2025-02-13
+
+- 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).
+
+- 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.
+
+
+## 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.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
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 50dcaa013..5bcf6d922 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -4,19 +4,20 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk4 and Q
-# Mk4 Specific Changes
+- New Feature: Export [BIP-380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) extended key expression.
+ Navigate to `Advanced/Tools -> Export Wallet -> Key Expression`
-- tbd
+# Mk4 Specific Changes
+## 5.4.5 - 2025-12-xx
-## 5.4.? - 2024-??-??
+- Enhancement: Show QR of XOR-split seeds
-- tbd
+# Q Specific Changes
+## 1.3.6Q - 2025-12-xx
-# Q Specific Changes
+- tbd
-## 1.3.?Q - 2024-??-??
-- Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed.
diff --git a/releases/signatures.txt b/releases/signatures.txt
index 6c697e424..2c35f95d4 100644
--- a/releases/signatures.txt
+++ b/releases/signatures.txt
@@ -2,104 +2,45 @@
Hash: SHA256
95eff9e044cdb6b3d00961ae72d450684d5441c6a3661ab550a3c3aa0882e754 README.md
-97107b5be1c8b65efa4bd36b7d1798e4ed15917861bd2d40784d66302a61d335 Next-ChangeLog.md
-f6d8a1edf0993cdecea7cdc34f48ce344f249ec0fc2d28fbc4da9ebc163c6148 History-Q.md
-3e98b0f292b30460e128c3d41e9dd33428524516ce433fe4a3b99132025ca64c History-Mk4.md
+52985f02188c59ff78cd03d496a9ae18a73d5c27a7d0cf98961394e1b6ea4007 Next-ChangeLog.md
+c708e41529f07f845e5217cce919d59235932693586aab3cf7cadb0d959e0d65 History-Q.md
+3a914286f544cd5c2ed9ef1196451dcd24aec2416045efb61672a6204c9843e0 History-Mk4.md
c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md
-7c06aa1d5168e02d928da087f13c74b94e40f52e5eb281af21edcfdf6cabe5ce ChangeLog.md
-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
-4db89ecffa1376bfc68a37110c2041a29afe52b005d527ecde701131168fc19c 2024-09-12T1733-v1.3.0Q-q1-coldcard-factory.dfu
-4d83715772b31643abde3b9a0bb328003f4a31d14e2fe9c1e038077a518acaea 2024-07-05T1348-v5.3.3-mk4-coldcard.dfu
-020d6d5c3baa724713b2f906112bb95f7eff43c3f5a4f8f11b77d8c2e96ccc88 2024-07-05T1348-v5.3.3-mk4-coldcard-factory.dfu
-54da941c8df84fcb84adcc62fdd3ee97d1fc12e2a9a648551ca614fcbacade3f 2024-07-05T1342-v1.2.3Q-q1-coldcard.dfu
-7f704aa37887ed84d6a25f124e9b4a31187430d7cf6b198eb83b86af8ae4e5ea 2024-07-05T1342-v1.2.3Q-q1-coldcard-factory.dfu
-ddf5ce1ef1ee2e6ba2922b333213d0cb939a2658b294c0f24c0e489de3fe7c75 2024-07-04T1501-v6.3.3X-mk4-coldcard.dfu
-9a2c5ef80a6f8212caa3b455e203da3549a79b08b473113662cf80fff587566a 2024-07-04T1459-v6.3.3QX-q1-coldcard.dfu
-a990cc94066486a37071c011cd85a29caed433cb4ca3f1c4dce7f715ef81dc3c 2024-06-26T1741-v5.3.2-mk4-coldcard.dfu
-218d17069d05c0ec2829e5629c5216121028d15b145c31b552e2f52daa7bf172 2024-06-26T1741-v5.3.2-mk4-coldcard-factory.dfu
-b87505b407b0477e2d15f71cfb20645ac55ac5b7c74493d25a2c9c97e807b2b3 2024-06-26T1739-v1.2.2Q-q1-coldcard.dfu
-efff41069f3f82d4e69d08a02a565ae0d2cd55c07dbbbe4c1328e6e3b6d8faa1 2024-06-26T1739-v1.2.2Q-q1-coldcard-factory.dfu
-90b1edfbe194b093258f9cda8f4add4aa3317e9ea205ff35914da7d91410fdae 2024-05-09T1529-v1.2.1Q-q1-coldcard.dfu
-c7889532323f7b0c08e84589c7cc756e2c46e209b4eea031bdfef4a633a813c1 2024-05-09T1529-v1.2.1Q-q1-coldcard-factory.dfu
-ef6526d37bc1a929c94dc8388f3863f6cc1582addf26495f761123f0bfb7aa30 2024-05-09T1527-v5.3.1-mk4-coldcard.dfu
-98c675e98a18b2437c52e30a9867c271bbca9969771caa34299556ef3fcb1a43 2024-05-09T1527-v5.3.1-mk4-coldcard-factory.dfu
-c7c79a21c206e8b0e816c86ef1b43cd6932cb767ed97291d5fbc2f0e749f95b7 2024-05-06T1812-v1.2.0Q-q1-coldcard.dfu
-5c6b69948f0193b3a7bd252195136d6d9f84ab14fbc8c5349150e7d238708c6f 2024-05-06T1812-v1.2.0Q-q1-coldcard-factory.dfu
-bab6818787eec45ef28b6c297e2504ffd4fa041ab19da8a3fd27543dffe876b8 2024-05-06T1811-v5.3.0-mk4-coldcard.dfu
-3da458c0dabe9a17eaeb92ee959006a64a3e6838eeb31f887a18840f020ef8b9 2024-05-06T1811-v5.3.0-mk4-coldcard-factory.dfu
-101f336310b9b460d717d91d2572ea9e9ef7ac3edbdaf132c7c3aa46bb89050a 2024-04-02T1416-v1.1.0Q-q1-coldcard.dfu
-5d034bc6b1abec49a067a90766bdb769faf9a1b52b2c9b7e541d32484cf783fc 2024-04-02T1416-v1.1.0Q-q1-coldcard-factory.dfu
-6ea843a56e87d7d811d90be6bfa4703794bbc8318d9709e88ada05740e03b12d 2024-03-14T1419-v1.0.1Q-q1-coldcard.dfu
-f53c79c64f02dd1e860a8d32f9319edd279485d97f07815b2a1eb180a1305459 2024-03-14T1419-v1.0.1Q-q1-coldcard-factory.dfu
-122e6d757eb5a8ce073d98a85851f376adec97856336c5a8f05b953b5c87a533 2024-03-10T1537-v1.0.0Q-q1-coldcard.dfu
-ae04aaac47f07e10143c75b5c772b54739830214c8234356d003137897f3f4f4 2024-03-10T1537-v1.0.0Q-q1-coldcard-factory.dfu
-6aaa9d5bf1726fe4d4a4834010d9b9b6525e8592bb97945cd08cc728fc884068 2024-03-02T1750-v0.0.8Q-q1-coldcard.dfu
-a0cd556693fae5b8b03f2a498c0abb1e6d747f91a92bd8f2559a676f8707d840 2024-03-02T1750-v0.0.8Q-q1-coldcard-factory.dfu
-18fe081d84a950e1fddb2151ad50917697dfc218cd68e2e359229b0bdadbff37 2024-02-26T1442-v0.0.7Q-q1-coldcard.dfu
-e4f4fe89cf3743d794568fd5b32b14551966139e9199602ea10468f925fab1cf 2024-02-26T1442-v0.0.7Q-q1-coldcard-factory.dfu
-2dc7a27f43958f2de9851f221183c94258ac915ae43d997b39b644e7b9daff8f 2024-02-22T1423-v0.0.6Q-q1-coldcard.dfu
-1e4f4d4c04835d78fcc4857d3264034a56dccf594e307d7408d7c4cdcdb0a926 2024-02-22T1423-v0.0.6Q-q1-coldcard-factory.dfu
-d51573c72d8958ea35357d4e0a36ce6aaa2d05924577efb219e2cc189be63f08 2024-02-16T1635-v0.0.5Q-q1-coldcard.dfu
-55f4ef9c3ae116f50db938acfc3a4b09717965f82cf6de8cc7385f68cd66d285 2024-02-16T1635-v0.0.5Q-q1-coldcard-factory.dfu
-8fd1ced0d5e0338d845f6d5ec5ab069a5143cceade02d4f17e86b7d182b489eb 2024-02-15T1843-v0.0.4Q-q1-coldcard.dfu
-43fac084727b0e69bae7fc040a62854673fd585dc2435d93bf146c80762e41cf 2024-02-15T1843-v0.0.4Q-q1-coldcard-factory.dfu
-3064bf7f1a039e7cd5c1a13c6aff8cc4338e52ef2177abbdca4b196955f9e434 2024-02-08T2005-v0.0.3Q-q1-coldcard.dfu
-788e7a1b182f920016617411b875fa7095ae007c6a53fc476afb1c93f0eed1c9 2024-02-08T2005-v0.0.3Q-q1-coldcard-factory.dfu
+01a68d9397830935bce570d6c2cd319b773dc8eefd3e0cad43ba231c2ebc9732 History-Edge.md
+8d36e8433af21b021daff1d0743ff0a441a2bf7d29ba280e5c99982bbef06742 EdgeChangeLog.md
+c14793ad2ef0ebca0a97dba9a0b41657ce48d7b121eb107101977385564fdf5a ChangeLog.md
+f04617b52fc0db6e95cac0dddd9ddd90754219f38b63a26d08c848e208069edb 2025-11-20T1602-v6.4.0X-mk4-coldcard.dfu
+993ef645ca83988c576febfaa248c0a5044e948d3d1e4443f31d5f9fd5734fe1 2025-11-20T1602-v6.4.0X-mk4-coldcard-factory.dfu
+371f13f3e1a5ef28d14933daf03820f0e51d26ffa96008dd5595da0dfac646cf 2025-11-20T1601-v6.4.0QX-q1-coldcard.dfu
+f7e73850b3c3dc33b1cd0fa7a94909931c1a4bbd881a7224a71da77807976640 2025-11-20T1601-v6.4.0QX-q1-coldcard-factory.dfu
+495f37ce7ddaba2e9fc3f03dec582f1646f258a3d0cec5e71c04d127357b2fa3 2025-02-19T1941-v6.3.5X-mk4-coldcard.dfu
+580701fb2de24362d8de6cf998d5fd42ca9ab003aff75f3c0140d915a06a6803 2025-02-19T1941-v6.3.5X-mk4-coldcard-factory.dfu
+605ebb5acde19447e5c1d7c8cfd0302c89de5c5870d85f06b185ecab3437f94e 2025-02-19T1939-v6.3.5QX-q1-coldcard.dfu
+245db07574a535a3f068ed9a759bf0088f0d0e1e39704a0e0727f90119833602 2025-02-19T1939-v6.3.5QX-q1-coldcard-factory.dfu
+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
a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu
-4651fb81dc04ac07ae53535f4246ef7f32611c50853de9edaefa68f3c64e1fac 2023-12-21T1526-v5.2.2-mk4-coldcard.dfu
-a49cd00808732c67b359c9f86814ddeafc63a1040823b6c1d2035a870575c9ed 2023-12-21T1526-v5.2.2-mk4-coldcard-factory.dfu
-06d1048bea43c5d7c72c5e5f395a676620ce884aed0cd152627a86d922e2f3ab 2023-12-19T1444-v5.2.1-mk4-coldcard.dfu
-3eb9c4b1add88a6fe412d783b8f4b895241a67e423bbacc6a13816a5216a30fe 2023-12-19T1444-v5.2.1-mk4-coldcard-factory.dfu
+cc93209e800bc05386b5613969e62c27b9acd4388e3a922686525da90a505778 2024-01-18T1507-v6.2.2X-mk4-coldcard-factory.dfu
f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu
1dcfb450f81883afe8f655239f06e238de7bae51e740cd4aa5ae6a0541772ad8 2023-10-26T1343-v6.2.1X-mk4-coldcard-factory.dfu
-7fbed097d2757b21fde920f4b10f5f50d7e1aeca01ff52186dfde4883af5cace 2023-10-10T1735-v5.2.0-mk4-coldcard.dfu
-4e3023676be88d6c6480c7f37de302f3a865077f9a2214de9c5a55b24afcba2c 2023-10-10T1735-v5.2.0-mk4-coldcard-factory.dfu
-fd707f2f69d006c9db84ceacd2a0dde79c3cb71730750e2676af610942898717 2023-09-08T2009-v5.1.4-mk4-coldcard.dfu
-d2a4a8b71b0b102971bf8a6c98968dee776a77e0a5707db862e34be5276fbc78 2023-09-08T2009-v5.1.4-mk4-coldcard-factory.dfu
-c03d4e2d1115e9440d1762c95fc82ae5a31122e84ee88d6537a8e75f26f66954 2023-09-07T1501-v5.1.3-mk4-coldcard.dfu
-3602f307df06b6658d7731172c2eb3f192a0bc8ee02c606e3cb97c1aa8d49af2 2023-09-07T1501-v5.1.3-mk4-coldcard-factory.dfu
-f6fb19d95bd1e38535f137bed60cafbfcd52379a686e3d12f372f881d78e640e 2023-06-26T1241-v4.1.9-coldcard.dfu
489e161f686a0c631fc605054f8e7271208b16191b669174b8a58f5af28b0f4a 2023-06-20T1506-v6.1.0X-mk4-coldcard.dfu
-233398cc8f6b9e894072448eb8b8a82a4f546219ce461dd821f0ed0a38b61900 2023-06-19T1627-v4.1.8-coldcard.dfu
+66c83c3f95fd3d0796b1e452d2e8ed8ac6a4abead53faf5ae793eceb6f7bbdb5 2023-06-20T1506-v6.1.0X-mk4-coldcard-factory.dfu
2e8ed970f518a476d0b34752ecbad75bab246669aa65de8f43801364c6f5753e 2023-05-12T1316-v6.0.0X-mk4-coldcard.dfu
-7aefd5bcce533f15337e83618ebbd42925d336792c82a5ca19a430b209b30b8a 2023-04-07T1330-v5.1.2-mk4-coldcard.dfu
-a6c007992139a847f0f238769023727e8cbc05c54c916b388a4dd8bc7490f0aa 2023-04-07T1330-v5.1.2-mk4-coldcard-factory.dfu
-99804b440f41ea47675456b4e20e7bb4e9cb434556c5813ab83c26fcda0f4e80 2023-02-27T2105-v5.1.1-mk4-coldcard.dfu
-8b37d0f2bf9ca8990f424e5a79fe62405e1ec3aca515760e509afec8f2dbacbc 2023-02-27T2105-v5.1.1-mk4-coldcard-factory.dfu
-bcf4284f7733e9de8d4dba238368552b056a27308e466721be7ca624192e257f 2023-02-27T1509-v5.1.0-mk4-coldcard.dfu
-cc946bcb63211e15d85db577e25ab2432d4a74d5dad77d710539e505dce7914a 2022-11-14T1854-v4.1.7-coldcard.dfu
-010827a60ebfc25b8a6e2bb94cc69b938419957ac6d4a9b6c0b1357c4c6c8632 2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
-bc4d0b2b985aea3a78eb9351cdadf60d1ab00801ed1e7192765b94181cb8933b 2022-10-05T1517-v4.1.6-coldcard.dfu
-884f373717c9c605920a1dc29e0f890bf7b3cc6b141666814e396094aeedb3f8 2022-07-29T1816-v5.0.6-mk4-coldcard.dfu
-3c680195ef49cd0eb86d8e2426443511e8834bcea2d0a86ab52a35cc9365a801 2022-07-20T1508-v5.0.5-mk4-coldcard.dfu
-7bd2b98186370f2d895e1e43949694f6ba61a1c021f72a63f0f86a30f338a0fc 2022-05-27T1500-v5.0.4-mk4-coldcard.dfu
-5aa2ccc65e2e5279db78b3068b9f3c60c34dd7cc330c2cc1243160db31a2d0f0 2022-05-04T1258-v4.1.5-coldcard.dfu
-6dbf0aca0f98fb7bdc761eeead4786617b804dad4afb42ee02febf23d31b5e9b 2022-05-04T1254-v5.0.3-mk3-coldcard.dfu
-d5d9bf50892a4aab6e2ffb106a3d206853a60f879daa94a6f90d68a69bf4fa33 2022-05-04T1252-v5.0.3-mk4-coldcard.dfu
-9bb028d3e60239f0fcdb3b1f91075785e2c21795789b38c4c619c1f64c2950ef 2022-04-25T1618-v4.1.4-coldcard.dfu
-a363b1f0d1b27b8f21dbaac32844a59dacab8c2fee126815cda84c4df31fd7cd 2022-04-19T1805-v5.0.2-mk4-coldcard.dfu
-afb6048397af4093e63567563544098e1cfb45b7ca673536253eb6494d60125c 2022-03-24T1645-v5.0.1-mk3-coldcard.dfu
-605807bd448711d54e14057892a100bac299a103f5b5fb6466d73f9a36d0694b 2022-03-24T1643-v5.0.1-mk4-coldcard.dfu
-badd10c078996516c6464c9bfa5f696747dd7206c97d1e6a75d6f5ee0436619a 2022-03-14T1907-v5.0.0-mk4-coldcard.dfu
-dedfcf8385e35dbdbb26b92f8c0667105404062ad83c8830d809cf9193434d9c 2021-09-02T1752-v4.1.3-coldcard.dfu
-d01d81305b209dadcf960b9e9d20affb8d4f11e9f9f916c5a06be29298c80dc2 2021-07-28T1347-v4.1.2-coldcard.dfu
-08e1ec1fd073afbbc9014db6da07fd96c6b20a6710fe491eb805afeba865fe3f 2021-04-30T1748-v4.1.1-coldcard.dfu
-2c39330bef467af8dcd7e2f393a970e1ca177b1812f830269916657ff79598eb 2021-04-29T1725-v4.1.0-coldcard.dfu
-5e0c5f4ba9fa0e5fd7f9846e25c6cd28821a86ff5e1207c56cc3a4f4c3741f15 2021-04-07T1424-v4.0.2-coldcard.dfu
-f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T1927-v4.0.1-coldcard.dfu
-3097fa3c173247637aa27376036e384940adeb67ce727c9795471f46deaa5210 2021-01-14T1617-v3.2.2-coldcard.dfu
-9e4aeee48d4399a761fec5d4c65cb2495ef5bc0b46995c085d63a65cf67362cb 2021-01-07T1439-v3.2.1-coldcard.dfu
-bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu
+8dd5ff029bb2b08c857604f0c9b5773931f6683ee331ecbc35d9ab4c460b745f 2023-05-12T1316-v6.0.0X-mk4-coldcard-factory.dfu
-----BEGIN PGP SIGNATURE-----
-iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmbjJicACgkQo6MbrVoq
-WxAnMwf/e2kR1aK6AJiriRa1n3XDomw8ivaUQXUApmK0kawBhVBDLKw5aa3lvTcS
-dg80wnenzNdE/QxctL+FkaZzKYsKbFpstkBEbZKcgbHVcinypKJJfICrhIBVVyZw
-wdhJMGOLEyWMysqfaYMtYJQPkg5nIn0rRxn4yWXIeXAQLcFgdlWzVykqfGZW1xYr
-CcVvxMqufXfc6c5aRFQzBO/YVHiRYzvK1NGDPztJEjXYU3zxnExAZFxk0vgpxvE3
-CahKfSSTNv54u4CTLxYCdHPRq9OM6yL/w3OUyUQFklCizk2PjrObsJQW4szbbjlx
-r7+587Pc5cpJCZn73Q0Y5/SWgnqm4g==
-=/h9F
+iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmkfO5QACgkQo6MbrVoq
+WxBh/wf/XRyXjpnLjIdLnX9302U/bWMSMONUoyTlEVbGpv3fAt5nZOm6PATjK7Sf
+TEnoRcteI7kJkoFKj5rFiNyOma1H0VcLJ2eKvKQXdYCYqKByhhrHW1n8Epi+/+fB
+hIRjiRxbMtHBDa8g6TntClHti5jkMW5u1mLUx6ZykO5yMjTuxHV4VuqYAjGv80UE
+F9VJM847xUpm9xafA5iOQCnfyECPuTv/g+OCeKVR6SpdMz0mZgm0OSeh9rqWqKvO
+9g/kpJMa3DPkkEa9RZUqG++3BwyRY49GwHXDWY8cgPVnT5nlJAn1qfswMpNwtaUx
+jHUn6S6YXsw7fJ6Vt0FzGLOZ56F3Vw==
+=Yx2T
-----END PGP SIGNATURE-----
diff --git a/shared/actions.py b/shared/actions.py
index 5194b6048..f02e8549e 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -4,19 +4,19 @@
#
# 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, wipe_if_deltamode
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 export import make_json_wallet, make_summary_file, make_descriptor_wallet_export
+from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words
+from export import export_contents, 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
+from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export
from files import CardSlot, CardMissingError, needs_microsd
-from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, MAX_TXN_LEN_MK4
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR
from glob import settings
from pincodes import pa
from menu import start_chooser, MenuSystem, MenuItem
@@ -24,8 +24,6 @@
from charcodes import KEY_NFC, KEY_QR, KEY_CANCEL
-CLEAR_PIN = '999999-999999'
-
async def start_selftest(*args):
# selftest is harmless, no need to warn anymore,
# but this layer saves memory in typical cases
@@ -321,7 +319,7 @@ async def initial_pin_setup(*a):
if ch == '6': break
# do the actual picking
- pin = await lll.get_new_pin(title)
+ pin = await lll.get_new_pin()
del lll
if pin is None: return
@@ -525,7 +523,7 @@ async def new_from_dice(menu, label, item):
async def any_active_duress_ux():
from trick_pins import tp
- tp.reload()
+ # if TPs are hidden this msg will not be shown
if any(tp.get_duress_pins()):
await ux_show_story('You have one or more duress wallets defined '
'under Trick PINs. Please empty them, and clear '
@@ -562,7 +560,7 @@ async def convert_ephemeral_to_master(*a):
msg += 'A reboot is part of this process. '
msg += 'PIN code, and %s funds are not affected.' % _type
- if not await ux_confirm(msg):
+ if not await ux_confirm(msg, confirm_key='4'):
return await ux_aborted()
# settings.save is part of re-building fs
@@ -574,24 +572,30 @@ async def clear_seed(*a):
# This is super dangerous for the customer's money.
import seed
- if await any_active_duress_ux():
- return await ux_aborted()
+ # in hobble mode, they cannot reach duress wallets and/or maybe we don't
+ # want to reveal them? So don't block them based on that.
+ if not pa.hobbled_mode:
+ if await any_active_duress_ux():
+ return await ux_aborted()
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.'):
+ 'Saved temporary seed settings and Seed Vault are lost. '
+ 'Trick PINs are also completely removed.'):
return await ux_aborted()
- ch = await ux_show_story('''Are you REALLY sure though???\n\n\
+ if not await ux_confirm('''Are you REALLY sure though???\n\n\
This action will certainly cause you to lose all funds associated with this wallet, \
unless you have a backup of the seed words and know how to import them into a \
-new wallet.\n\nPress (4) to prove you read to the end of this message and accept all \
-consequences.''', escape='4')
- if ch != '4':
+new wallet.''', 'AGAIN...', confirm_key='4'):
return await ux_aborted()
+ # clear all trick PINs from SE2
+ from trick_pins import tp
+ tp.clear_all()
+
# clear settings, address cache, settings from tmp seeds / seedvault seeds
from files import wipe_flash_filesystem
wipe_flash_filesystem(False)
@@ -603,7 +607,6 @@ async def clear_seed(*a):
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
@@ -617,7 +620,12 @@ def render_master_secrets(mode, raw, node):
qr = ' '.join(w[0:4] for w in words)
qr_alnum = True
- msg = 'Seed words (%d):\n' % len(words)
+ title = 'Seed words (%d):' % len(words)
+ msg = ""
+ if not version.has_qwerty:
+ msg += title + "\n"
+ title = None
+
msg += ux_render_words(words)
if stash.bip39_passphrase:
@@ -627,28 +635,30 @@ def render_master_secrets(mode, raw, node):
elif mode == 'xprv':
+ title = "Extended Private Key" if version.has_qwerty else None
msg = c.serialize_private(node)
qr = msg
elif mode == 'master':
+ title = "Master Secret" if version.has_qwerty else None
msg = '%d bytes:\n\n' % len(raw)
qr = str(b2a_hex(raw), 'ascii')
msg += qr
else:
raise ValueError(mode)
- return msg, qr, qr_alnum
+ return title, msg, qr, qr_alnum
async def view_seed_words(*a):
- import stash
-
if not await ux_confirm('The next screen will show the seed words'
' (and if defined, your BIP-39 passphrase).'
'\n\nAnyone with knowledge of those words '
'can control all funds in this wallet.'):
return
- from glob import dis
+ import stash
+ from glob import dis, NFC
+
dis.fullscreen("Wait...")
dis.busy_bar(True)
@@ -658,33 +668,35 @@ async def view_seed_words(*a):
raw = mode = None
if stash.bip39_passphrase:
# get main secret - bypass tmp
- with stash.SensitiveValues(bypass_tmp=True) as sv:
- if not sv.deltamode:
- assert sv.mode == "words"
- raw = sv.raw[:]
- mode = sv.mode
+ with stash.SensitiveValues(bypass_tmp=True, enforce_delta=True) as sv:
+ assert sv.mode == "words"
+ raw = sv.raw[:]
+ mode = sv.mode
stash.SensitiveValues.clear_cache()
- with stash.SensitiveValues(bypass_tmp=False) as sv:
- if sv.deltamode:
- # give up and wipe self rather than show true seed values.
- import callgate
- callgate.fast_wipe()
-
+ with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv:
dis.busy_bar(False)
- msg, qr, qr_alnum = render_master_secrets(mode or sv.mode,
- raw or sv.raw,
- sv.node)
-
+ title, msg, qr, qr_alnum = render_master_secrets(mode or sv.mode,
+ raw or sv.raw,
+ sv.node)
+ esc = "1"
if not version.has_qwerty:
- msg += '\n\nPress (1) to view as QR Code.'
+ msg += '\n\nPress (1) to view as QR Code'
+ if NFC:
+ msg += ", (3) to share via NFC"
+ esc += "3"
+ msg += "."
while 1:
- ch = await ux_show_story(msg, sensitive=True, escape='1'+KEY_QR)
+ ch = await ux_show_story(msg, title=title, sensitive=True, escape=esc,
+ hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
if ch in '1'+KEY_QR:
from ux import show_qr_code
- await show_qr_code(qr, qr_alnum)
+ await show_qr_code(qr, qr_alnum, is_secret=True)
+ continue
+ elif NFC and (ch in '3'+KEY_NFC):
+ await NFC.share_text(qr, is_secret=True)
continue
break
@@ -707,12 +719,7 @@ async def export_seedqr(*a):
# Note: cannot reach this menu item if no words. If they are tmp, that's cool.
- with stash.SensitiveValues(bypass_tmp=False) as sv:
- if sv.deltamode:
- # give up and wipe self rather than show true seed values.
- import callgate
- callgate.fast_wipe()
-
+ with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv:
if sv.mode != 'words':
raise ValueError(sv.mode)
@@ -724,7 +731,7 @@ async def export_seedqr(*a):
del words
from ux import show_qr_code
- await show_qr_code(qr, True, msg="SeedQR")
+ await show_qr_code(qr, True, msg="SeedQR", is_secret=True)
stash.blank_object(qr)
@@ -748,6 +755,10 @@ async def version_migration():
# version 5.0.6 is installed
settings.remove_key('vdsk')
+ # 6.4.0 multisig migration
+ from wallet import do_640_multisig_migration
+ await do_640_multisig_migration()
+
async def version_migration_prelogin():
# same, but for setting before login
# these have moved into SE2 for Mk4 and so can be removed
@@ -795,26 +806,37 @@ async def start_login_sequence():
# If that didn't work, or no skip defined, force
# them to login successfully.
-
+ sp_unlock = False
try:
+ from trick_pins import tp
+
# Get a PIN and try to use it to login
# - does warnings about attempt usage counts
await block_until_login()
+ sp_unlock = tp.was_sp_unlock()
+ if sp_unlock:
+ # Trying to unlock spending policy: ask for main PIN next.
+ await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
+ pa.reset()
+ await block_until_login()
+
+ # we don't really know if that was the Main PIN (could easily be the bypass
+ # PIN again) and if it's a duress wallet, that's cool...
+
# Do we need to do countdown delay? (real or otherwise)
- # Q/Mk4 approach:
- # - wiping has already occured if that was picked
+ # - wiping has already occured if that was selected by trick details
# - delay is variable, stored in tc_arg
- from trick_pins import tp
delay = tp.was_countdown_pin()
- # Maybe they do know the right PIN, but do a delay anyway, because they wanted that
+ # Maybe they do know the right PIN, but always do a delay anyway, because they wanted that
if not delay:
delay = settings.get('lgto', 0)
if delay:
# kill some time, with countdown, and get "the" PIN again for real login
pa.reset()
+
await ux_login_countdown(delay * (60 if not version.is_devmode else 1))
# keep it simple for Mk4+: just challenge again for any PIN
@@ -828,7 +850,7 @@ async def start_login_sequence():
# safe to do so. Remember the bootrom checks PIN on every access to
# the secret, so "letting" them past this point is harmless if they don't know
# the true pin.
- sys.print_exception(exc)
+ # sys.print_exception(exc)
if not pa.is_successful():
raise
@@ -842,16 +864,32 @@ async def start_login_sequence():
# handle upgrades/downgrade issues
try:
await version_migration()
- except:
- pass
+ except: pass
# Maybe insist on the "right" microSD being already installed?
try:
from pwsave import MicroSD2FA
MicroSD2FA.enforce_policy()
- except BaseException as exc:
- # robustness: keep going!
- sys.print_exception(exc)
+ except: pass
+
+ # apply the hobbling for the spending policy, if appropriate
+ try:
+ from ccc import sssp_spending_policy, sssp_word_challenge
+
+ if sp_unlock and sssp_spending_policy('words'):
+ # challenge them also for first and last seed word! (will reboot on fail)
+ await sssp_word_challenge()
+ dis.fullscreen("Startup...")
+
+ if sp_unlock:
+ # Disable spending policy going forward; user has to re-enable.
+ pa.hobbled_mode = False
+ sssp_spending_policy('en', set_value=False)
+ else:
+ # normal entry mode, but might have policy enabled, if so enable it now.
+ pa.hobbled_mode = sssp_spending_policy('en')
+
+ except: pass
# implement idle timeout now that we are logged-in
IMPT.start_task('idle', idle_logout())
@@ -872,6 +910,13 @@ async def start_login_sequence():
# is early in boot process
print("XFP save failed: %s" % exc)
+ # 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, and Nunchuk) 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'))
# If HSM policy file is available, offer to start that,
@@ -889,6 +934,14 @@ async def start_login_sequence():
await ar.interact()
except: pass
+ if pa.is_deltamode():
+ # pretend Secure Notes & Passwords is disabled
+ # pretend SeedVault is disabled
+ try:
+ settings.remove_key("secnap")
+ settings.master_set("seedvault", False)
+ except: pass
+
if version.has_nfc and settings.get('nfc', 0):
# Maybe allow NFC now
import nfc
@@ -932,7 +985,7 @@ async def restore_main_secret(*a):
goto_top_menu()
def make_top_menu():
- from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu
+ from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu, HobbledTopMenu
from glob import hsm_active, settings
from pincodes import pa
@@ -948,7 +1001,9 @@ def make_top_menu():
assert pa.is_successful(), "nonblank but wrong pin"
if pa.has_secrets():
- _cls = NormalSystem[:]
+ # let them do a few things, but not all the things, when "hobbled"
+ _cls = HobbledTopMenu[:] if pa.hobbled_mode else NormalSystem[:]
+
if pa.tmp_value or settings.get("hmx", False):
active_xfp = settings.get("xfp", 0)
sl, sr = ("[", "]") if pa.tmp_value else ("<", ">")
@@ -980,7 +1035,7 @@ def goto_top_menu(first_time=False):
The file created is sensitive--in terms of privacy--but should not \
compromise your funds directly.'''
-PICK_ACCOUNT = '''\n\nPress (1) to enter a non-zero account number.'''
+PICK_ACCOUNT = '\n\nPress %s to continue. Press (1) to enter a non-zero account number.' % OK
async def dump_summary(*A):
@@ -1001,6 +1056,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
@@ -1014,26 +1070,46 @@ async def export_xpub(label, _2, item):
path = "m"
addr_fmt = AF_CLASSIC
else:
- remap = {44:0, 49:1, 84:2}[mode]
+ 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
-
- if '{acct}' in path:
- msg += "Press (1) to select account other than zero. "
+ 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
+
+ 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
@@ -1045,7 +1121,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)
@@ -1055,8 +1131,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
@@ -1079,9 +1153,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))
@@ -1094,19 +1168,21 @@ 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]
+ addition, f_pattern = "", "descriptor.txt"
+ int_ext = direct_way = None
+ allowed_af = chains.SINGLESIG_AF
if item.arg:
- int_ext, allowed_af, ll, f_pattern = item.arg
+ int_ext, allowed_af, ll, f_pattern, direct_way = item.arg
addition = " for " + ll
- ch = await ux_show_story(ss_descriptor_export_story(addition), escape='1')
-
account_num = 0
- if ch == '1':
- account_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
- elif ch != 'y':
- return
+ if not direct_way:
+ ch = await ux_show_story(ss_descriptor_export_story(addition), escape='1')
+
+ if ch == '1':
+ account_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
+ elif ch != 'y':
+ return
if int_ext is None:
ch = await ux_show_story(
@@ -1117,17 +1193,58 @@ async def ss_descriptor_skeleton(_0, _1, item):
int_ext = False if ch == "1" else True
if len(allowed_af) == 1:
- await make_descriptor_wallet_export(allowed_af[0], account_num,
- int_ext=int_ext,
- fname_pattern=f_pattern)
+ await make_descriptor_wallet_export(allowed_af[0], account_num, int_ext=int_ext,
+ fname_pattern=f_pattern, direct_way=direct_way)
else:
rv = [
- MenuItem(addr_fmt_label(af), f=descriptor_skeleton_step2,
- arg=(af, account_num, int_ext, f_pattern))
+ MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2,
+ arg=(af, account_num, int_ext, f_pattern, direct_way))
for af in allowed_af
]
the_ux.push(MenuSystem(rv))
+
+async def key_expression_skeleton_step2(_1, _2, item):
+ # pick a semi-random file name, render and save it.
+ orig_path = item.arg
+ await make_key_expression_export(orig_path)
+
+async def key_expression_skeleton(_0, _1, item):
+ # Export key expression -> [xfp/d/e/r]xpub
+
+ acct_num = 0
+ ch = await ux_show_story("This saves a extended key expression."
+ + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1')
+ if ch == '1':
+ acct_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
+ elif ch != 'y':
+ return
+
+ todo = [
+ ("Segwit P2WPKH", "m/84h/%dh/%dh"),
+ ("Taproot P2TR", "m/86h/%dh/%dh"),
+ ("Classic P2PKH", "m/44h/%dh/%dh"),
+ ("P2SH-Segwit", "m/49h/%dh/%dh"),
+ ("Multi P2WSH", "m/48h/%dh/%dh/2h"),
+ ("Multi P2TR", "m/48h/%dh/%dh/3h"),
+ ("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h"),
+ ]
+
+ from address_explorer import KeypathMenu
+
+ async def doit(*a):
+ return KeypathMenu(ranged=False, done_fn=make_key_expression_export)
+
+ ct = chains.current_chain().b44_cointype
+
+ rv = [
+ MenuItem(label, f=key_expression_skeleton_step2, arg=orig_der % (ct, acct_num))
+ for label, orig_der in todo
+ ]
+ rv += [MenuItem("Custom Path", menu=doit)]
+
+ the_ux.push(MenuSystem(rv))
+
async def samourai_post_mix_descriptor_export(*a):
name = "POST-MIX"
post_mix_acct_num = 2147483646
@@ -1157,9 +1274,9 @@ async def samourai_account_descriptor(name, account_num):
async def descriptor_skeleton_step2(_1, _2, item):
# pick a semi-random file name, render and save it.
- addr_fmt, account_num, int_ext, f_pattern = item.arg
+ addr_fmt, account_num, int_ext, f_pattern, dw = item.arg
await make_descriptor_wallet_export(addr_fmt, account_num, int_ext=int_ext,
- fname_pattern=f_pattern)
+ fname_pattern=f_pattern, direct_way=dw)
async def bitcoin_core_skeleton(*A):
@@ -1185,9 +1302,9 @@ async def bitcoin_core_skeleton(*A):
async def electrum_skeleton_step2(_1, _2, item):
# pick a semi-random file name, render and save it.
addr_fmt, account_num = item.arg
- await make_json_wallet('Electrum wallet',
- lambda: generate_electrum_wallet(addr_fmt, account_num),
- "new-electrum.json")
+ await export_contents('Electrum wallet',
+ lambda: generate_electrum_wallet(addr_fmt, account_num),
+ "new-electrum.json", is_json=True)
async def _generic_export(prompt, label, f_pattern):
# like the Multisig export, make a single JSON file with
@@ -1199,7 +1316,8 @@ async def _generic_export(prompt, label, f_pattern):
elif ch != 'y':
return
- await make_json_wallet(label, lambda: generate_generic_export(account_num), f_pattern)
+ await export_contents(label, lambda: generate_generic_export(account_num),
+ f_pattern, is_json=True)
async def generic_skeleton(*A):
# like the Multisig export, make a single JSON file with
@@ -1234,7 +1352,8 @@ async def wasabi_skeleton(*A):
return
# no choices to be made, just do it.
- await make_json_wallet('Wasabi wallet', lambda: generate_wasabi_wallet(), 'new-wasabi.json')
+ await export_contents('Wasabi wallet', lambda: generate_wasabi_wallet(),
+ 'new-wasabi.json', is_json=True)
async def unchained_capital_export(*a):
# they were using our airgapped export, and the BIP-45 path from that
@@ -1251,9 +1370,8 @@ async def unchained_capital_export(*a):
xfp = xfp2str(settings.get('xfp', 0))
fname = 'unchained-%s.json' % xfp
- await make_json_wallet('Unchained',
- lambda: generate_unchained_export(account_num),
- fname)
+ await export_contents('Unchained', lambda: generate_unchained_export(account_num),
+ fname, is_json=True)
async def backup_everything(*A):
@@ -1275,17 +1393,13 @@ async def verify_backup(*A):
# do a limited CRC-check over encrypted file
await backups.verify_backup_file(fn)
-async def import_extended_key_as_secret(extended_key, ephemeral, meta=None):
+async def import_extended_key_as_secret(extended_key, ephemeral, origin=None):
try:
import seed
if ephemeral:
- await seed.set_ephemeral_seed_extended_key(extended_key, meta=meta)
+ await seed.set_ephemeral_seed_extended_key(extended_key, origin=origin)
else:
await seed.set_seed_extended_key(extended_key)
- except ValueError:
- msg = ("Sorry, wasn't able to find a valid extended private key to import. "
- "It should be at the start of a line, and probably starts with 'xprv'.")
- await ux_show_story(title="FAILED", msg=msg)
except Exception as e:
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
@@ -1331,7 +1445,7 @@ def contains_xprv(fname):
else:
# only get here if NFC was not chosen
# pick a likely-looking file.
- fn = await file_picker(suffix='txt', min_size=50, max_size=2000, taster=contains_xprv,
+ fn = await file_picker(suffix='.txt', min_size=50, max_size=2000, taster=contains_xprv,
none_msg="Must contain " + label + ".", **choice)
if not fn: return
@@ -1343,57 +1457,91 @@ def contains_xprv(fname):
extended_key = ln
break
- await import_extended_key_as_secret(extended_key, ephemeral, meta='Imported XPRV')
+ await import_extended_key_as_secret(extended_key, ephemeral, origin='Imported XPRV')
# not reached; will do reset.
-EMPTY_RESTORE_MSG = '''\
+async def need_clear_seed(*a):
+ await ux_show_story('''\
You must clear the wallet seed before restoring a backup because it replaces \
the seed value and the old seed would be lost.\n\n\
-Visit the advanced menu and choose 'Destroy Seed'.'''
-
-async def restore_temporary(*A):
+Visit the advanced menu and choose 'Destroy Seed'.''')
+async def restore_backup(a, b, item):
+ # normal word based imports (tmp or master depending on item.arg)
fn = await file_picker(suffix=".7z")
-
if fn:
import backups
- await backups.restore_complete(fn, temporary=True)
-
-async def restore_everything(*A):
+ await backups.restore_complete(fn, item.arg, True)
- if not pa.is_secret_blank():
- await ux_show_story(EMPTY_RESTORE_MSG)
+async def restore_backup_dev(*a):
+ # used ONLY for Restore Bkup in I Am Developer
+ fn = await file_picker(suffix=[".7z", ".txt"])
+ if fn:
+ words = False if fn[-3:] == ".7z" else None
+ import backups
+ await backups.restore_complete(fn, not pa.is_secret_blank(), words)
+
+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
- # restore everything, using a password, from single encrypted 7z file
- fn = await file_picker(suffix='.7z')
+ wipe_if_deltamode()
- if fn:
- import backups
- await backups.restore_complete(fn)
+ 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 += "."
-async def restore_everything_cleartext(*A):
- # Asssume no password on backup file; devs and crazy people only
+ 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 not pa.is_secret_blank():
- await ux_show_story(EMPTY_RESTORE_MSG)
- return
+ if (npwd is None) or (npwd == pwd): continue
- # restore everything, using NO password, from single text file, like would be wrapped in 7z
- fn = await file_picker(suffix='.txt')
+ settings.set('bkpw', npwd)
+ settings.save()
+ await ux_dramatic_pause("Saved.", 2)
- if fn:
- import backups
- prob = await backups.restore_complete_doit(fn, [])
- if prob:
- await ux_show_story(prob, title='FAILED')
async def wipe_filesystem(*A):
if not await ux_confirm('''\
Erase internal filesystem and rebuild it. Resets contents of internal flash area \
used for settings, address search cache, and HSM config file. Does not affect funds, \
-or seed words but will reset settings used with other BIP-39 passphrases. \
-Does not affect MicroSD card, if any.'''):
+or seed words but will reset settings used with other temporary seeds & BIP-39 passphrases. \
+Does not affect MicroSD card, if any.''', confirm_key="4"):
return
from files import wipe_flash_filesystem
@@ -1416,57 +1564,64 @@ async def wipe_sd_card(*A):
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') \
or f.endswith('.txt') or f.endswith(".json") or fname.endswith(".sig")
- while 1:
- txid = None
- fn = await file_picker(min_size=10, max_size=MAX_TXN_LEN_MK4, taster=is_suitable)
- if not fn: return
+ try:
+ while 1:
+ txid = None
+ fn = await file_picker(min_size=10, max_size=MAX_TXN_LEN, taster=is_suitable)
+ if not fn: return
- basename = fn.split('/')[-1]
- ext = fn.split('.')[-1].lower()
+ basename = fn.split('/')[-1]
+ ext = fn.split('.')[-1].lower()
- try:
- with CardSlot() as card:
- with open(fn, 'rb') as fp:
- data = fp.read()
+ try:
+ with CardSlot() as card:
+ with open(fn, 'rb') as fp:
+ data = fp.read()
- except CardMissingError:
- await needs_microsd()
- return
+ except CardMissingError:
+ await needs_microsd()
+ return
- if ext == "txn":
- tc = "T"
- txid = txid_from_fname(basename)
- if data[2:8] == b'000000':
- # it's a txn, and we wrote as hex
+ if ext == "txn":
+ tc = "T"
+ txid = txid_from_fname(basename)
+ if data[2:8] == b'000000':
+ # it's a txn, and we wrote as hex
+ data = data.decode()
+ else:
+ assert data[2:8] == bytes(6)
+ data = b2a_hex(data).decode()
+ elif data[0:5] == b'psbt\xff':
+ tc = "P"
+ elif data[0:6] in (b'cHNidP', b'707362'):
+ tc = "U"
+ data = data.decode().strip()
+ elif ext in ('txt', 'json', 'sig'):
+ tc = "U"
+ if ext == "json":
+ tc = "J"
data = data.decode()
else:
- assert data[2:8] == bytes(6)
- data = b2a_hex(data).decode()
- elif data[0:5] == b'psbt\xff':
- tc = "P"
- elif data[0:6] in (b'cHNidP', b'707362'):
- tc = "U"
- data = data.decode().strip()
- elif ext in ('txt', 'json', 'sig'):
- tc = "U"
- if ext == "json":
- tc = "J"
- data = data.decode()
- else:
- raise ValueError(ext)
-
- await export_by_qr(data, txid, tc)
+ raise ValueError(ext)
+ await export_by_qr(data, txid, tc, force_bbqr=force_bbqr)
+ except Exception as e:
+ await ux_show_story(
+ title="ERROR",
+ msg="Failed to share file via QR.\n\n%s\n%s" % (e, problem_file_line(e))
+ )
async def nfc_share_file(*A):
# Share txt, txn and PSBT files over NFC.
@@ -1576,41 +1731,58 @@ async def list_files(*A):
from pincodes import pa
digest = chk.digest()
- basename = fn.rsplit('/', 1)[-1]
- msg_base = 'SHA256(%s)\n\n%s\n\nPress ' % (basename, B2A(digest))
- escape = "6"
+ path, basename = fn.rsplit('/', 1)
+ msg_base = 'SHA256(%s)\n\n' + B2A(digest) + '\n\nPress (1) to rename file, '
+ escape = "61"
if pa.has_secrets():
- msg_sign = '(4) to sign file digest and export detached signature, '
+ msg_base += '(4) to sign file digest and export detached signature, '
escape += "4"
- else:
- msg_sign = ""
- msg_delete = '(6) to delete.'
- msg = msg_base + msg_sign + msg_delete
+ msg_base += '(6) to delete.'
+
while True:
- ch = await ux_show_story(msg, escape=escape)
+ ch = await ux_show_story(msg_base % basename, escape=escape)
if ch == "x": break
- if ch in '46':
+ if ch in '461':
with CardSlot() as card:
if ch == '6':
card.securely_blank_file(fn)
break
+ elif ch == '1':
+ new_basename = await ux_input_text(basename, max_len=32, min_len=3)
+ if new_basename:
+ try:
+ # prohibit both slashes and space in filenames
+ for s in "\/ ":
+ assert s not in new_basename, "illegal char"
+ uos.rename(path + "/" + basename, path + "/" + new_basename)
+ basename = new_basename
+ except Exception as e:
+ await ux_show_story("Failed to rename the file. " + str(e),
+ title="Failure")
else:
- from auth import write_sig_file
+ from msgsign import write_sig_file
sig_nice = write_sig_file([(digest, fn)])
await ux_show_story("Signature file %s written." % sig_nice)
- msg = msg_base + msg_delete
return
async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
choices=None, none_msg=None, force_vdisk=False, slot_b=None,
- allow_batch_sign=False, ux=True):
+ allow_batch=False, ux=True):
# present a menu w/ a list of files... to be read
# - optionally, enforce a max size, and provide a "tasting" function
- # - if msg==None, don't prompt, just do the search and return list
+ # - if (not ux), don't prompt, just do the search and return list
# - if choices is provided; skip search process
# - escape: allow these chars to skip picking process
# - slot_b: None=>pick slot w/ card in it, or A if both.
+ # - allow_batch: adds an "all of the above" choice: ("menu label", menu_handler)
+ # - suffix argument MUST contain the dot (.txt), if list of suffixes, all MUST
+
+ if suffix:
+ # actually make it a list of "suffixes"
+ if not isinstance(suffix, list):
+ suffix = [suffix]
+ assert all(s[0] == '.' for s in suffix)
if choices is None:
choices = []
@@ -1624,13 +1796,13 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
# ignore subdirs
continue
- if suffix:
- if not isinstance(suffix, list):
- suffix = [suffix]
- if not any([fn.lower().endswith(s) for s in suffix]):
- continue
+ if fn[0] == '.':
+ # unix-style hidden files
+ continue
- if fn[0] == '.': continue
+ if suffix and not any(fn.lower().endswith(s) for s in suffix):
+ # wrong suffix, skip
+ continue
full_fname = path + '/' + fn
@@ -1654,7 +1826,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
label = fn
while label in sofar:
# just the file name isn't unique enough sometimes?
- # - shouldn't happen anymore now that we dno't support internal FS
+ # - shouldn't happen anymore now that we don't support internal FS
# - unless we do muliple paths
label += path.split('/')[-1] + '/' + fn
@@ -1676,7 +1848,7 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
if none_msg:
msg += none_msg
if suffix:
- msg += '\n\nThe filename must end in "%s". ' % suffix
+ msg += '\n\nThe filename must end in: ' + ' OR '.join(suffix)
msg += '\n\nMaybe insert (another) SD card and try again?'
@@ -1691,10 +1863,10 @@ async def clicked(_1,_2,item):
choices.sort()
items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices]
- if allow_batch_sign and len(choices) > 1:
- # we know that each choices member is psbt as allow_batch_sign is only True
- # in Ready To Sign
- items.insert(0, MenuItem("[Sign All]", f=batch_sign, arg=choices))
+ if allow_batch and len(choices) > 1:
+ # Allow an "all" selection
+ label, funct = allow_batch
+ items.insert(0, MenuItem(label, f=funct, arg=choices))
menu = MenuSystem(items)
the_ux.push(menu)
@@ -1731,7 +1903,7 @@ async def bless_flash(*a):
pa.greenlight_firmware()
# redraw our screen
- dis.show()
+ dis.busy_bar(False) # includes dis.show()
def is_psbt(filename):
@@ -1756,7 +1928,7 @@ async def _batch_sign(choices=None):
return
assert isinstance(picked, dict)
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
if not choices:
@@ -1782,20 +1954,22 @@ async def batch_sign(_1, _2, item):
import sys
await ux_show_story("FAILURE: batch sign failed\n\n" + problem_file_line(e))
-
-async def ready2sign(*a):
- # Top menu choice of top menu! Signing!
- # - check if any signable in SD card, if so do it
+async def _ready2sign(intro="", probe=True, miniscript_wallet=None):
+ # - if probe=True -> check if any signable in SD card (A slot on Q), if so do it
+ # - if probe=False -> offer all enabled import options via UX
# - if no card, check virtual disk for PSBT
- # - if still nothing, then talk about USB connection
from pincodes import pa
from glob import NFC
opt = {}
+ choices = []
+ sb_only = False
- # just check if we have candidates, no UI
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
- max_size=MAX_TXN_LEN, taster=is_psbt)
+ if probe:
+ # just check if we have candidates, no UI
+ sb_only = True
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
+ max_size=MAX_TXN_LEN, taster=is_psbt)
if pa.tmp_value:
title = '[%s]' % xfp2str(settings.get('xfp'))
@@ -1803,25 +1977,17 @@ async def ready2sign(*a):
title = None
if not choices:
- msg = '''Coldcard is ready to sign spending transactions!
-
-Put the proposed transaction onto MicroSD card \
-in PSBT format (Partially Signed Bitcoin Transaction) \
-or upload a transaction to be signed \
-from your desktop wallet software or command line tools.\n\n'''
-
- footnotes = ("\n\nYou will always be prompted to confirm the details "
- "before any signature is performed.")
-
# if we have only one SD card inserted, at this point, we know no PSBTs on them
# as above file_picker already checked
# if we have both inserted, A was already checked - so only care about B
- picked = await import_export_prompt("PSBT", is_import=True, intro=msg,
- footnotes=footnotes, slot_b_only=True,
+ footnotes = ("You will always be prompted to confirm the details "
+ "before any signature is performed.")
+ picked = await import_export_prompt("PSBT", is_import=True, intro=intro,
+ footnotes=footnotes, slot_b_only=sb_only,
title=title)
if isinstance(picked, dict):
opt = picked # reset options to what was chosen by user
- choices = await file_picker(suffix='psbt', min_size=50, ux=False,
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
max_size=MAX_TXN_LEN, taster=is_psbt,
**opt)
if not choices:
@@ -1830,9 +1996,9 @@ async def ready2sign(*a):
return
else:
if NFC and picked == KEY_NFC:
- await NFC.start_psbt_rx()
+ await NFC.start_psbt_rx(miniscript_wallet)
if picked == KEY_QR:
- await _scan_any_qr()
+ await _scan_any_qr(miniscript_wallet=miniscript_wallet)
return
@@ -1842,16 +2008,29 @@ async def ready2sign(*a):
input_psbt = path + '/' + fn
else:
# multiples - ask which, and offer batch to sign them all
- input_psbt = await file_picker(choices=choices, allow_batch_sign=True)
+ input_psbt = await file_picker(choices=choices, allow_batch=("[Sign All]", batch_sign))
if not input_psbt:
return
# start the process
from auth import sign_psbt_file
-
+ opt["miniscript_wallet"] = miniscript_wallet
await sign_psbt_file(input_psbt, **opt)
+async def ready2sign(*a):
+ # Top menu choice of top menu! Signing!
+ # - check if any signable in SD card, if so do it
+ # - if no card, check virtual disk for PSBT
+
+ await _ready2sign('''Coldcard is ready to sign spending transactions!
+
+Put the proposed transaction onto MicroSD card \
+in PSBT format (Partially Signed Bitcoin Transaction) \
+or upload a transaction to be signed \
+from your desktop wallet software or command line tools.''')
+
+
async def sign_message_on_sd(*a):
# Menu item: choose a file to be signed (as a short text message)
#
@@ -1863,10 +2042,11 @@ def is_signable(filename):
# 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
@@ -1891,7 +2071,7 @@ def is_sig_file(filename):
return
# start the process
- from auth import verify_txt_sig_file
+ from msgsign import verify_txt_sig_file
await verify_txt_sig_file(fn)
@@ -1938,7 +2118,7 @@ async def incorrect_pin():
while 1:
lll.reset()
lll.subtitle = "New " + title
- pin = await lll.get_new_pin(title, allow_clear=False)
+ pin = await lll.get_new_pin()
if pin is None:
return await ux_aborted()
@@ -2174,14 +2354,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()
@@ -2234,10 +2411,11 @@ async def scan_any_qr(menu, label, item):
expect_secret, tmp = item.arg
await _scan_any_qr(expect_secret, tmp)
-async def _scan_any_qr(expect_secret=False, tmp=False):
+async def _scan_any_qr(expect_secret=False, tmp=False, miniscript_wallet=None):
from ux_q1 import QRScannerInteraction
x = QRScannerInteraction()
- await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
+ await x.scan_anything(expect_secret=expect_secret, tmp=tmp,
+ miniscript_wallet=miniscript_wallet)
PUSHTX_SUPPLIERS = [
@@ -2247,6 +2425,21 @@ async def _scan_any_qr(expect_secret=False, tmp=False):
('mempool.space', 'https://mempool.space/pushtx#'),
]
+async def feature_requires_nfc():
+ # prompt them that it's need (iff not already enabled)
+ # - return F if they decline
+ if settings.get('nfc'):
+ return True
+
+ # force on NFC, so it works... but they can still turn it off later, etc.
+ if not await ux_confirm("This feature requires NFC to be enabled. %s to enable." % OK):
+ return False
+
+ settings.set("nfc", 1)
+ await change_nfc_enable(1)
+
+ return True
+
async def pushtx_setup_menu(*a):
# let them pick a URL from menu to enable "pushtx" feature, and provide
# some background, and even let them enter a custom URL.
@@ -2265,12 +2458,9 @@ async def pushtx_setup_menu(*a):
if ch != "y":
return
- if not settings.get('nfc'):
- # force on NFC, so it works... but they can still turn it off later, etc.
- if not await ux_confirm("This feature requires NFC to be enabled. %s to enable." % OK):
- return
- settings.set("nfc", 1)
- await change_nfc_enable(1)
+ if not await feature_requires_nfc():
+ # they don't want to proceed
+ return
async def doit(menu, picked, xx_self):
# using stock values, or Disable
diff --git a/shared/address_explorer.py b/shared/address_explorer.py
index c672e062b..6032c24ff 100644
--- a/shared/address_explorer.py
+++ b/shared/address_explorer.py
@@ -7,32 +7,23 @@
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
-from multisig import MultisigWallet
+from menu import MenuSystem, MenuItem, ToggleMenuItem
+from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH, AF_P2TR, AF_CLASSIC
+from wallet import MiniScriptWallet
from uasyncio import sleep_ms
from uhashlib import sha256
-from ubinascii import hexlify as b2a_hex
from glob import settings
-from auth import write_sig_file
-from utils import addr_fmt_label, censor_address
+from msgsign import write_sig_file
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, truncate_address
-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):
+ def __init__(self, path=None, nl=0, ranged=True, done_fn=None):
self.prefix = None
+ self.done_fn = done_fn
+ self.ranged = ranged
if path is None:
# Top level menu; useful shortcuts, and special case just "m"
@@ -41,10 +32,14 @@ def __init__(self, path=None, nl=0):
MenuItem("m/44h/⋯", f=self.deeper),
MenuItem("m/49h/⋯", f=self.deeper),
MenuItem("m/84h/⋯", f=self.deeper),
- MenuItem("m/0/{idx}", menu=self.done),
- MenuItem("m/{idx}", menu=self.done),
+ MenuItem("m/86h/⋯", f=self.deeper),
MenuItem("m", f=self.done),
]
+ if self.ranged:
+ items += [
+ MenuItem("m/0/{idx}", menu=self.done),
+ MenuItem("m/{idx}", menu=self.done),
+ ]
else:
# drill down one layer: (nl) is the current leaf
# - hardened choice first
@@ -54,11 +49,14 @@ def __init__(self, path=None, nl=0):
MenuItem(p+"/⋯", menu=self.deeper),
MenuItem(p+"h", menu=self.done),
MenuItem(p, menu=self.done),
- MenuItem(p+"h/0/{idx}", menu=self.done),
- MenuItem(p+"/0/{idx}", menu=self.done), #useful shortcut?
- MenuItem(p+"h/{idx}", menu=self.done),
- MenuItem(p+"/{idx}", menu=self.done),
]
+ if self.ranged:
+ items += [
+ MenuItem(p + "h/0/{idx}", menu=self.done),
+ MenuItem(p + "/0/{idx}", menu=self.done), # useful shortcut?
+ MenuItem(p + "h/{idx}", menu=self.done),
+ MenuItem(p + "/{idx}", menu=self.done),
+ ]
# simple consistent truncation when needed
max_wide = max(len(mi.label) for mi in items)
@@ -67,7 +65,7 @@ def __init__(self, path=None, nl=0):
pl = p[0:p.rfind('/')].rfind('/')
else:
self.prefix = p # displayed on mk4 only
- pl = len(p)-2
+ pl = len(p)-2
for mi in items:
mi.arg = mi.label
mi.label = '⋯'+mi.label[pl:]
@@ -96,9 +94,12 @@ async def done(self, _1, menu_idx, item):
if isinstance(top, KeypathMenu):
the_ux.pop()
continue
- assert isinstance(top, AddressListMenu)
+ # assert isinstance(top, AddressListMenu), type(top)
break
+ if self.done_fn:
+ return await self.done_fn(final_path)
+
return PickAddrFmtMenu(final_path, top)
async def deeper(self, _1, _2, item):
@@ -106,15 +107,14 @@ async def deeper(self, _1, _2, item):
assert val.endswith('/⋯')
cpath = val[:-2]
nl = await ux_enter_bip32_index('%s/' % cpath, unlimited=True)
- return KeypathMenu(cpath, nl)
+ return KeypathMenu(cpath, nl, ranged=self.ranged, done_fn=self.done_fn)
class PickAddrFmtMenu(MenuSystem):
def __init__(self, path, parent):
self.parent = parent
items = [
- MenuItem(addr_fmt_label(AF_CLASSIC), f=self.done, arg=(path, AF_CLASSIC)),
- MenuItem(addr_fmt_label(AF_P2WPKH), f=self.done, arg=(path, AF_P2WPKH)),
- MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=self.done, arg=(path, 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"):
@@ -179,8 +179,7 @@ async def render(self):
# 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
@@ -189,7 +188,7 @@ async def render(self):
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))
@@ -199,7 +198,7 @@ async def render(self):
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)))
@@ -210,10 +209,15 @@ async def render(self):
items.append(MenuItem("Account Number", f=self.change_account))
items.append(MenuItem("Custom Path", menu=self.make_custom))
- # if they have MS wallets, add those next
- for ms in MultisigWallet.iter_wallets():
- if not ms.addr_fmt: continue
- items.append(MenuItem(ms.name, f=self.pick_multisig, arg=ms))
+ # if they have miniscript wallets, add those next
+ if MiniScriptWallet.exists():
+ items.append(ToggleMenuItem('MS Scripts/Derivs', 'aemscsv',
+ ['Default Off', 'Enable'], story=(
+ "Enable this option to add script(s) and derivations to the CSV export"
+ " of Multisig/Miniscript wallets. Default is to only export addresses.")))
+
+ for msc in MiniScriptWallet.iter_wallets():
+ items.append(MenuItem(msc.name, f=self.pick_miniscript, arg=msc))
else:
items.append(MenuItem("Account: %d" % self.account_num, f=self.change_account))
@@ -245,10 +249,10 @@ async def pick_single(self, _1, _2, item):
settings.put('axi', axi) # update last clicked address
await self.show_n_addresses(path, addr_fmt, None)
- async def pick_multisig(self, _1, _2, item):
- ms_wallet = item.arg
- settings.put('axi', item.label) # update last clicked address
- await self.show_n_addresses(None, None, ms_wallet)
+ async def pick_miniscript(self, _1, _2, item):
+ msc_wallet = item.arg
+ settings.put('axi', item.label) # update last clicked address
+ await self.show_n_addresses(None, msc_wallet.addr_fmt, msc_wallet)
async def make_custom(self, *a):
# picking a custom derivation path: makes a tree of menus, with chance
@@ -274,13 +278,13 @@ async def got_custom_path(self, path, addr_fmt):
async def show_n_addresses(self, path, addr_fmt, ms_wallet, start=0, n=10, allow_change=True):
# Displays n addresses by replacing {idx} in path format.
# - also for other {account} numbers
- # - or multisig case
+ # - or miniscript case
from glob import dis, NFC
from wallet import MAX_BIP32_IDX
start = self.start
- def make_msg(change=0):
+ def make_msg(change=0, start=start, n=n):
# Build message and CTA about export, plus the actual addresses.
if n:
msg = "Addresses %d⋯%d:\n\n" % (start, min(start + n - 1, MAX_BIP32_IDX))
@@ -293,22 +297,7 @@ def make_msg(change=0):
dis.fullscreen('Wait...')
if ms_wallet:
- # IMPORTANT safety feature: never show complete address
- # but show enough they can verify addrs shown elsewhere.
- # - makes a redeem script
- # - converts into addr
- # - assumes 0/0 is first address.
- for idx, addr, paths, script in ms_wallet.yield_addresses(start, n, change):
- addrs.append(censor_address(addr))
-
- if idx == 0 and ms_wallet.N <= 4:
- msg += '\n'.join(paths) + '\n =>\n'
- else:
- msg += '⋯/%d/%d =>\n' % (change, idx)
-
- msg += truncate_address(addr) + '\n\n'
- dis.progress_sofar(idx-start+1, n)
-
+ msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change)
else:
# single-signer wallets
from wallet import MasterSingleSigWallet
@@ -319,16 +308,17 @@ def make_msg(change=0):
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
k0 = 'to show change addresses' if allow_change and change == 0 else None
- export_msg, escape = export_prompt_builder('address summary file',
- no_qr=bool(ms_wallet), key0=k0,
- force_prompt=True)
+ export_msg, escape = export_prompt_builder(
+ 'address summary file',
+ key0=k0, force_prompt=True
+ )
if version.has_qwerty:
- escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN
+ escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN+KEY_QR
else:
escape += "79"
@@ -339,11 +329,15 @@ def make_msg(change=0):
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
- msg, addrs, escape = make_msg()
change = 0
+ msg, addrs, escape = make_msg(change, start)
while 1:
ch = await ux_show_story(msg, escape=escape)
@@ -364,14 +358,10 @@ def make_msg(change=0):
# 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
- if ms_wallet:
- # requires not multisig
- continue
-
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
@@ -384,8 +374,15 @@ def make_msg(change=0):
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 msgsign 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
@@ -408,7 +405,7 @@ def make_msg(change=0):
else:
continue # 3 in non-NFC mode
- msg, addrs, escape = make_msg(change)
+ msg, addrs, escape = make_msg(change, start)
def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, change=0):
# Produce CSV file contents as a generator
@@ -416,31 +413,14 @@ def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, cha
from ownership import OWNERSHIP
if ms_wallet:
- # For multisig, include redeem script and derivation for each signer
- yield '"' + '","'.join(['Index', 'Payment Address', 'Redeem Script']
- + ['Derivation (%d of %d)' % (i+1, ms_wallet.N) for i in range(ms_wallet.N)]
- ) + '"\n'
-
- if (start == 0) and (n > 100) and change in (0, 1):
- saver = OWNERSHIP.saver(ms_wallet, change, start)
- else:
- saver = None
-
- for (idx, addr, derivs, script) in ms_wallet.yield_addresses(start, n, change_idx=change):
- if saver:
- saver(addr)
+ # saver will be None if we don't think it worth saving these addresses
+ saver = OWNERSHIP.saver(ms_wallet, change, start, n)
- # policy choice: never provide a complete multisig address to user.
- addr = censor_address(addr)
-
- ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode())
- ln += '","'.join(derivs)
- ln += '"\n'
-
- yield ln
+ for line in ms_wallet.generate_address_csv(start, n, change, saver=saver):
+ yield line
if saver:
- saver(None) # close file
+ saver(None, 0) # close cache file
return
@@ -448,26 +428,24 @@ def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, cha
from wallet import MasterSingleSigWallet
main = MasterSingleSigWallet(addr_fmt, path, account_num)
- if n and (start == 0) and (n > 100) and change in (0, 1):
- saver = OWNERSHIP.saver(main, change, start)
- else:
- saver = None
+ # saver will be None if we don't think it worth saving these addresses
+ saver = OWNERSHIP.saver(main, change, start, n)
yield '"Index","Payment Address","Derivation"\n'
- for (idx, addr, deriv) in main.yield_addresses(start, n, change_idx=change):
+ for (idx, addr, deriv) in main.yield_addresses(start, n, change):
if saver:
- saver(addr)
+ saver(addr, idx)
yield '%d,"%s","%s"\n' % (idx, addr, deriv)
if saver:
- saver(None) # close
+ saver(None, 0) # close cache file
async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num,
start=0, count=250, change=0, **save_opts):
# write addresses into a text file on the MicroSD/VirtDisk
- from glob import dis
+ from glob import dis, settings
from files import CardSlot, CardMissingError, needs_microsd
# simple: always set number of addresses.
@@ -479,7 +457,6 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num,
# generator function
body = generate_address_csv(path, addr_fmt, ms_wallet, account_num, count,
start=start, change=change)
-
# pick filename and write
try:
with CardSlot(**save_opts) as card:
@@ -490,28 +467,32 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num,
for idx, part in enumerate(body):
ep = part.encode()
fd.write(ep)
- if not ms_wallet:
- h.update(ep)
-
+ h.update(ep)
dis.progress_sofar(idx, count or 1)
sig_nice = None
- if not ms_wallet:
+ if ms_wallet:
+ # sign with my key at the same path as first address of export
+ addr_fmt = AF_CLASSIC
+ derive = ms_wallet.get_my_deriv()
+ derive += "/%d/%d" % (change, start)
+ else:
+ addr_fmt = AF_CLASSIC if addr_fmt == AF_P2TR else addr_fmt
derive = path.format(account=account_num, change=change, idx=start) # first addr
- sig_nice = write_sig_file([(h.digest(), fname)], derive, addr_fmt)
+
+ sig_nice = write_sig_file([(h.digest(), fname)], derive, addr_fmt)
+
+
+ msg = '''Address summary file written:\n\n%s''' % nice
+ if sig_nice:
+ msg += "\n\nAddress signature file written:\n\n%s" % sig_nice
+ await ux_show_story(msg)
except CardMissingError:
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
+ await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e)))
- msg = '''Address summary file written:\n\n%s''' % nice
- if sig_nice:
- msg += "\n\nAddress signature file written:\n\n%s" % sig_nice
- await ux_show_story(msg)
async def address_explore(*a):
# explore addresses based on derivation path chosen
diff --git a/shared/auth.py b/shared/auth.py
index 8bf24ad57..cc66df3a2 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -3,25 +3,24 @@
# Operations that require user authorization, like our core features: signing messages
# and signing bitcoin transactions.
#
-import stash, ure, ux, chains, sys, gc, uio, version, ngu, ujson
+import stash, ure, chains, sys, gc, uio, version, ngu, ujson
from ubinascii import b2a_base64, a2b_base64
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from uhashlib import sha256
-from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS
-from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P2WPKH_P2SH
-from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED
+from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, SUPPORTED_ADDR_FORMATS, AF_P2TR
+from public_constants import 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 ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm
+from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction
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 HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A, show_single_address
from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput
-from files import CardSlot
-from exceptions import HSMDenied
+from files import CardSlot, CardMissingError
+from exceptions import HSMDenied, QRTooBigError
from version import MAX_TXN_LEN
from charcodes import KEY_QR, KEY_NFC, KEY_ENTER, KEY_CANCEL, KEY_LEFT, KEY_RIGHT
+from msgsign import sign_message_digest
# Where in SPI flash/PSRAM the two PSBT files are (in and out)
TXN_INPUT_OFFSET = 0
@@ -73,13 +72,12 @@ def check_busy(cls, allowed_cls=None):
if allowed_cls and isinstance(cls.active_request, allowed_cls):
return
- # check if UX actally was cleared, and we're not really doing that anymore; recover
+ # check if UX actually was cleared, and we're not really doing that anymore; recover
# - happens if USB caller never comes back for their final results
from ux import the_ux
top_ux = the_ux.top_of_stack()
if not isinstance(top_ux, cls) and cls.active_request.ux_done:
# do cleaup
- print('recovery cleanup')
cls.cleanup()
return
@@ -91,8 +89,8 @@ async def failure(self, msg, exc=None, title='Failure'):
# show line number and/or simple text about error
if exc:
- print("%s:" % msg)
- sys.print_exception(exc)
+ #print("%s:" % msg)
+ #sys.print_exception(exc)
msg += '\n\n'
em = str(exc)
@@ -128,188 +126,28 @@ async def failure(self, msg, exc=None, title='Failure'):
Press %s to continue, otherwise %s to cancel.''' % (OK, X)
-# RFC2440 style signatures, popular
-# since the genesis block, but not really part of any BIP as far as I know.
-#
-def rfc_signature_template_gen(msg, addr, sig):
- template = [
- "-----BEGIN BITCOIN SIGNED MESSAGE-----\n",
- "%s\n" % msg,
- "-----BEGIN BITCOIN SIGNATURE-----\n",
- "%s\n" % addr,
- "%s\n" % sig,
- "-----END BITCOIN SIGNATURE-----\n"
- ]
- for part in template:
- yield part
-
-def parse_armored_signature_file(contents):
- sep = "-----"
- assert contents.count(sep) == 6, "Armor text MUST be surrounded by exactly five (5) dashes."
- temp = contents.split(sep)
- msg = temp[2].strip()
- addr_sig = temp[4].strip()
- addr, sig_str = addr_sig.split()
- return msg, addr, sig_str
-
-def sign_message_digest(digest, subpath, prompt, addr_fmt=AF_CLASSIC, pk=None):
- # do the signature itself!
- from glob import dis
-
- ch = chains.current_chain()
-
- if prompt:
- dis.fullscreen(prompt, percent=.25)
-
- if pk is None:
- with stash.SensitiveValues() as sv:
- # if private key is provided, derivation subpath is ignored
- # and provided private key is used for signing
- node = sv.derive_path(subpath)
- dis.progress_bar_show(.50)
- pk = node.privkey()
- addr = ch.address(node, addr_fmt)
- else:
- node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
- dis.progress_bar_show(.50)
- addr = ch.address(node, addr_fmt)
-
- dis.progress_bar_show(.75)
- rv = ngu.secp256k1.sign(pk, digest, 0).to_bytes()
- # AF_CLASSIC header byte base 31 is returned by default from ngu - NOOP
- if addr_fmt != AF_CLASSIC:
- header_byte, rs = rv[0], rv[1:]
- # ngu only produces header base for compressed p2pkh, anyways get only rec_id
- rec_id = (header_byte - 27) & 0x03
- new_header_byte = rec_id + ch.sig_hdr_base(addr_fmt=addr_fmt)
- rv = bytes([new_header_byte]) + rs
-
- dis.progress_bar_show(1)
-
- return rv, addr
-
-def make_signature_file_msg(content_list):
- # list of tuples consisting of (hash, file_name)
- return b"\n".join([
- b2a_hex(h) + b" " + fname.encode()
- for h, fname in content_list
- ])
-
-def parse_signature_file_msg(msg):
- # only succeed for our format digest + 2 spaces + fname
- try:
- res = []
- lines = msg.split('\n')
- for ln in lines:
- d, fn = ln.split(' ')
- # should not need to strip if our file format, so dont
- # is hex? is 32 bytes long?
- assert len(a2b_hex(d)) == 32
- res.append((d, fn))
-
- return res
- except:
- return
-
-def sign_export_contents(content_list, deriv, addr_fmt, pk=None):
- msg2sign = make_signature_file_msg(content_list)
- bitcoin_digest = chains.current_chain().hash_message(msg2sign)
- sig_bytes, addr = sign_message_digest(bitcoin_digest, deriv, "Signing...", addr_fmt, pk=pk)
- sig = b2a_base64(sig_bytes).decode().strip()
- gen = rfc_signature_template_gen(addr=addr, msg=msg2sign.decode(), sig=sig)
- return gen
-
-def verify_signed_file_digest(msg):
- from files import CardSlot
-
- parsed_msg = parse_signature_file_msg(msg)
- if not parsed_msg:
- # not our format
- return
-
- try:
- err, warn = [], []
- with CardSlot() as card:
- for digest, fname in parsed_msg:
- path = card.abs_path(fname)
- if not card.exists(path):
- warn.append((fname, None))
- continue
- path = card.abs_path(fname)
-
- md = sha256()
- with open(path, "rb") as f:
- while True:
- chunk = f.read(1024)
- if not chunk:
- break
- md.update(chunk)
-
- h = b2a_hex(md.digest()).decode().strip()
- if h != digest:
- err.append((fname, h, digest))
- except:
- # fail silently if issues with reading files or SD issues
- # no digest checking
- return
-
- return err, warn
-
-def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_name=None):
- from glob import dis
-
- if derive is None:
- ct = chains.current_chain().b44_cointype
- derive = "m/44'/%d'/0'/0/0" % ct
-
- fpath = content_list[0][1]
- if len(content_list) > 1:
- # we're signing contents of more files - need generic name for sig file
- assert sig_name
- sig_nice = sig_name + ".sig"
- sig_fpath = fpath.rsplit("/", 1)[0] + "/" + sig_nice
- else:
- sig_fpath = fpath.rsplit(".", 1)[0] + ".sig"
- sig_nice = sig_fpath.split("/")[-1]
-
- sig_gen = sign_export_contents([(h, f.split("/")[-1]) for h, f in content_list],
- derive, addr_fmt, pk=pk)
-
- with open(sig_fpath, 'wt') as fd:
- for i, part in enumerate(sig_gen):
- fd.write(part)
- # rfc template generator has length of 6
- dis.progress_bar_show(i / 6)
- return sig_nice
-
-def validate_text_for_signing(text):
- # Check for some UX/UI traps in the message itself.
- # - messages must be short and ascii only. Our charset is limited
- # - too many spaces, leading/trailing can be an issue
-
- MSG_MAX_SPACES = 4 # impt. compared to -=- positioning
-
- result = to_ascii_printable(text)
+class ApproveMessageSign(UserAuthorizedAction):
+ def __init__(self, text, subpath, addr_fmt, approved_cb=None,
+ msg_sign_request=None, only_printable=True):
+ super().__init__()
+ is_json = False
- length = len(result)
- assert length >= 2, "msg too short (min. 2)"
- assert length <= MSG_SIGNING_MAX_LENGTH, "msg too long (max. %d)" % MSG_SIGNING_MAX_LENGTH
- assert " " not in result, 'too many spaces together in msg(max. 3)'
- # other confusion w/ whitepace
- assert result[0] != ' ', 'leading space(s) in msg'
- assert result[-1] != ' ', 'trailing space(s) in msg'
+ from msgsign import validate_text_for_signing, parse_msg_sign_request
- # looks ok
- return result
+ if msg_sign_request:
+ text, subpath, addr_fmt, is_json = parse_msg_sign_request(msg_sign_request)
-class ApproveMessageSign(UserAuthorizedAction):
- def __init__(self, text, subpath, addr_fmt, approved_cb=None):
- super().__init__()
- self.text = validate_text_for_signing(text)
+ 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
+ if self.addr_fmt == AF_P2TR:
+ raise ValueError("Unsupported address format: 'p2tr'")
+
from glob import dis
dis.fullscreen('Wait...')
@@ -321,22 +159,22 @@ def __init__(self, text, subpath, addr_fmt, approved_cb=None):
async def interact(self):
# Prompt user w/ details and get approval
- from glob import dis, hsm_active
+ from glob import hsm_active
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]
+ self.result, _ = sign_message_digest(digest, self.subpath, "Signing...", self.addr_fmt)
if self.approved_cb:
# for micro sd case
@@ -351,35 +189,43 @@ async def interact(self):
def sign_msg(text, subpath, addr_fmt):
- subpath = cleanup_deriv_path(subpath)
+ # Start the approval process for message signing.
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 approve_msg_sign(text, subpath, addr_fmt, approved_cb=None,
+ msg_sign_request=None, kill_menu=False,
+ only_printable=True):
+
+ # Ask user if they want to sign some short text message.
+ 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 push
+ 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)
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
+ from msgsign import sd_sign_msg_done
async def done(signature, address, text):
# complete. write out result
@@ -388,203 +234,24 @@ async def done(signature, address, text):
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
-
-def verify_signature(msg, addr, sig_str):
- warnings = ""
- script = None
- hash160 = None
- invalid_addr_fmt_msg = "Invalid address format - must be one of p2pkh, p2sh-p2wpkh, or p2wpkh."
- invalid_addr = "Invalid signature for message."
-
- if addr[0] in "1mn":
- addr_fmt = AF_CLASSIC
- decoded_addr = ngu.codecs.b58_decode(addr)
- hash160 = decoded_addr[1:] # remove prefix
- elif addr.startswith("bc1q") or addr.startswith("tb1q") or addr.startswith("bcrt1q"):
- if len(addr) > 44: # testnet/mainnet max singlesig len 42, regtest 44
- # p2wsh
- raise ValueError(invalid_addr_fmt_msg)
- addr_fmt = AF_P2WPKH
- _, _, hash160 = ngu.codecs.segwit_decode(addr)
- elif addr[0] in "32":
- addr_fmt = AF_P2WPKH_P2SH
- decoded_addr = ngu.codecs.b58_decode(addr)
- script = decoded_addr[1:] # remove prefix
- else:
- raise ValueError(invalid_addr_fmt_msg)
-
- try:
- sig_bytes = a2b_base64(sig_str)
- if not sig_bytes or len(sig_bytes) != 65:
- # can return b'' in case of wrong, can also raise
- raise ValueError("invalid encoding")
-
- header_byte = sig_bytes[0]
- header_base = chains.current_chain().sig_hdr_base(addr_fmt)
- if (header_byte - header_base) not in (0, 1, 2, 3):
- # wrong header value only - this can still verify OK
- warnings += "Specified address format does not match signature header byte format."
-
- # least two significant bits
- rec_id = (header_byte - 27) & 0x03
- # need to normalize it to 31 base for ngu
- new_header_byte = 31 + rec_id
- sig = ngu.secp256k1.signature(bytes([new_header_byte]) + sig_bytes[1:])
- except ValueError as e:
- raise ValueError("Parsing signature failed - %s." % str(e))
-
- digest = chains.current_chain().hash_message(msg.encode('ascii'))
- try:
- rec_pubkey = sig.verify_recover(digest)
- except ValueError as e:
- raise ValueError("Invalid signature for msg - %s." % str(e))
-
- rec_pubkey_bytes = rec_pubkey.to_bytes()
- rec_hash160 = ngu.hash.hash160(rec_pubkey_bytes)
-
- if script:
- target = bytes([0, 20]) + rec_hash160
- target = ngu.hash.hash160(target)
- if target != script:
- raise ValueError(invalid_addr)
- else:
- if rec_hash160 != hash160:
- raise ValueError(invalid_addr)
- return warnings
-
-async def verify_armored_signed_msg(contents, digest_check=True):
- # digest_check=False for NFC cases, where we do not have filesystem
- from glob import dis
-
- dis.fullscreen("Verifying...")
-
- try:
- msg, addr, sig_str = parse_armored_signature_file(contents)
- except Exception as e:
- e_line = problem_file_line(e)
- await ux_show_story("Malformed signature file. %s %s" % (str(e), e_line), title="FAILURE")
- return
-
- try:
- sig_warn = verify_signature(msg, addr, sig_str)
- except Exception as e:
- await ux_show_story(str(e), title="ERROR")
- return
-
- title = "CORRECT"
- warn_msg = ""
- err_msg = ""
- story = "Good signature by address:\n %s" % addr
-
- if digest_check:
- digest_prob = verify_signed_file_digest(msg)
- if digest_prob:
- err, digest_warn = digest_prob
- if digest_warn:
- title = "WARNING"
- wmsg_base = "not present. Contents verification not possible."
- if len(digest_warn) == 1:
- fname = digest_warn[0][0]
- warn_msg += "'%s' is %s" % (fname, wmsg_base)
- else:
- warn_msg += "Files:\n" + "\n".join("> %s" % fname for fname, _ in digest_warn)
- warn_msg += "\nare %s" % wmsg_base
-
- if err:
- title = "ERROR"
- for fname, calc, got in err:
- err_msg += ("Referenced file '%s' has wrong contents.\n"
- "Got:\n%s\n\nExpected:\n%s" % (fname, got, calc))
-
- if sig_warn:
- # we know not ours only because wrong recid header used & not BIP-137 compliant
- story = "Correctly signed, but not by this Coldcard. %s" % sig_warn
-
- 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:
- with card.open(filename, 'rt') as fd:
- text = fd.read()
- except CardMissingError:
- await needs_microsd()
- return
- except Exception as e:
- await ux_show_story('Error: ' + str(e))
- return
-
- await verify_armored_signed_msg(text)
+ 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)
async def try_push_tx(data, txid, txn_sha=None):
- from glob import settings, PSRAM, NFC
# if NFC PushTx is enabled, do that w/o questions.
+ from glob import settings, PSRAM, NFC
+
url = settings.get('ptxurl', False)
if NFC and url:
try:
@@ -595,55 +262,87 @@ async def try_push_tx(data, txid, txn_sha=None):
await NFC.share_push_tx(url, txid, data, txn_sha)
return True
except: pass # continue normally if it fails, perhaps too big?
- return False
+ return False
class ApproveTransaction(UserAuthorizedAction):
- def __init__(self, psbt_len, flags=0x0, approved_cb=None, psbt_sha=None, is_sd=None):
+ def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None,
+ output_encoder=None, filename=None, miniscript_wallet=None):
super().__init__()
self.psbt_len = psbt_len
- self.do_finalize = bool(flags & STXN_FINALIZE)
- self.do_visualize = bool(flags & STXN_VISUALIZE)
+
+ # do finalize is None if not USB, None = decide based on is_complete
+ if flags is None:
+ self.do_finalize = self.do_visualize = None
+ else:
+ self.do_finalize = bool(flags & STXN_FINALIZE)
+ self.do_visualize = bool(flags & STXN_VISUALIZE)
+
self.stxn_flags = flags
self.psbt = None
self.psbt_sha = psbt_sha
- self.approved_cb = approved_cb
+ self.input_method = input_method
+ self.output_encoder = output_encoder
+ self.filename = filename
self.result = None # will be (len, sha256) of the resulting PSBT
- self.is_sd = is_sd
self.chain = chains.current_chain()
+ self.miniscript_wallet = miniscript_wallet
def render_output(self, o):
# Pretty-print a transactions output.
# - expects CTxOut object
# - gives user-visible string
+ # returns: tuple(ux_output_rendition, address_or_script_str_for_qr_display)
#
val = ' '.join(self.chain.render_value(o.nValue))
try:
dest = self.chain.render_address(o.scriptPubKey)
-
- return '%s\n - to address -\n%s\n' % (val, dest)
+ # known script types are short enough that we can display QR on both hw versions
+ return '%s\n - to address -\n%s\n' % (val, show_single_address(dest)), dest
except ValueError:
pass
+ # Handle future things better: allow them to happen at least.
+ # sending to some unknown script, possibly very long
+ # but full-show required for verification
+ # OP_RETURN dest contains also OP_RETURN itself (for PSBT qr explorer)
+ dest = B2A(o.scriptPubKey)
+
# check for OP_RETURN
data = self.chain.op_return(o.scriptPubKey)
- if data:
- data_hex, data_ascii = data
- to_ret = '%s\n - OP_RETURN -\n%s' % (val, data_hex)
- if data_ascii:
- return to_ret + " (ascii: %s)\n" % data_ascii
- return to_ret + "\n"
+ # In UX story only data are shown as OP_RETURN is part of base msg
+ if data is None:
+ rv = '%s\n - to script -\n%s\n' % (val, dest)
+ else:
+ base = '%s\n - OP_RETURN -\n%s'
+ if not data:
+ dest = ""
+ rv = base % (val, "null-data\n")
+ else:
+ data_ascii = None
+ if len(data) > 160:
+ # completely arbitrary limit, prevents huge stories
+ # anchor data are not relevant for verification - can be hidden
+ ss = b2a_hex(data[:80]).decode() + "\n ⋯\n" + b2a_hex(data[-80:]).decode()
+ # but we show empty QR in txn explorer for these big, modified data
+ else:
+ ss = b2a_hex(data).decode()
+ if (min(data) >= 32) and (max(data) < 127): # printable & not huge
+ try:
+ data_ascii = data.decode("ascii")
+ except: pass
- # Handle future things better: allow them to happen at least.
- self.psbt.warnings.append(
- ('Output?', 'Sending to a script that is not well understood.'))
- dest = B2A(o.scriptPubKey)
+ rv = base % (val, ss)
+ if data_ascii:
+ rv += " (ascii: %s)" % data_ascii
+ rv += "\n"
- return '%s\n - to script -\n%s\n' % (val, dest)
+ return rv, dest
async def interact(self):
# Prompt user w/ details and get approval
from glob import dis, hsm_active
+ from ccc import CCCFeature, SSSPFeature
# step 1: parse PSBT from PSRAM into in-memory objects.
@@ -652,6 +351,7 @@ async def interact(self):
# NOTE: psbtObject captures the file descriptor and uses it later
self.psbt = psbtObject.read_psbt(fd)
except BaseException as exc:
+ # sys.print_exception(exc)
if isinstance(exc, MemoryError):
msg = "Transaction is too complex"
exc = None
@@ -661,28 +361,29 @@ async def interact(self):
return await self.failure(msg, exc)
dis.fullscreen("Validating...")
+ self.psbt.active_miniscript = self.miniscript_wallet
# Do some analysis/ validation
try:
- await self.psbt.validate() # might do UX: accept multisig import
- dis.progress_bar_show(0.10)
- self.psbt.consider_inputs()
-
- dis.progress_bar_show(0.33)
- self.psbt.consider_keys()
-
- dis.progress_bar_show(0.66)
- self.psbt.consider_outputs()
+ await self.psbt.validate() # might do UX: accept multisig import
+
+ ccc_c_xfp = CCCFeature.get_xfp() # can be None
+ args = self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
+ self.psbt.consider_outputs(*args, cosign_xfp=ccc_c_xfp)
+ del args # not needed anymore
+ # we can properly assess sighash only after we know
+ # which outputs are change
self.psbt.consider_dangerous_sighash()
- dis.progress_bar_show(0.85)
except FraudulentChangeOutput as exc:
- print('FraudulentChangeOutput: ' + exc.args[0])
+ # sys.print_exception(exc)
+ #print('FraudulentChangeOutput: ' + exc.args[0])
return await self.failure(exc.args[0], title='Change Fraud')
except FatalPSBTIssue as exc:
- print('FatalPSBTIssue: ' + exc.args[0])
+ #print('FatalPSBTIssue: ' + exc.args[0])
return await self.failure(exc.args[0])
except BaseException as exc:
+ # sys.print_exception(exc)
del self.psbt
gc.collect()
@@ -694,6 +395,16 @@ async def interact(self):
return await self.failure(msg, exc)
+ # early test for spending policy; not an error if violates policy
+ # - might add warnings
+ could_ccc_sign, ccc_needs_2fa = CCCFeature.could_cosign(self.psbt)
+
+ # test for allowing any signature when in single-signer mode
+ # - but CCC will override it.
+ should_block, ss_needs_2fa = SSSPFeature.can_allow(self.psbt)
+ if should_block and not could_ccc_sign:
+ return await self.failure('Spending Policy violation.')
+
# step 2: figure out what we are approving, so we can get sign-off
# - outputs, amounts
# - fee
@@ -715,6 +426,10 @@ async def interact(self):
elif wl >= 2:
msg.write('(%d warnings below)\n\n' % wl)
+ if self.psbt.active_miniscript:
+ # show name of the multisig/miniscript wallet that we signed with
+ msg.write("Wallet: " + self.psbt.active_miniscript.name + "\n\n")
+
if self.psbt.consolidation_tx:
# consolidating txn that doesn't change balance of account.
msg.write("Consolidating %s %s\nwithin wallet.\n\n" %
@@ -735,7 +450,7 @@ async def interact(self):
))
# outputs + change story created here
- needs_txn_explorer = self.output_summary_text(msg)
+ self.output_summary_text(msg)
gc.collect()
if self.psbt.ux_notes:
@@ -744,7 +459,6 @@ async def interact(self):
for label, m in self.psbt.ux_notes:
msg.write('- %s: %s\n' % (label, m))
- msg.write("\n")
if self.psbt.warnings:
msg.write('---WARNING---\n\n')
@@ -761,16 +475,19 @@ async def interact(self):
ux_clear_keys(True)
dis.progress_bar_show(1) # finish the Validating...
+
if not hsm_active:
- msg.write("\nPress %s to approve and sign transaction." % OK)
- if needs_txn_explorer:
- msg.write(" Press (2) to explore txn.")
- if self.is_sd and CardSlot.both_inserted():
+ esc = "2"
+ msg.write("Press %s to approve and sign transaction."
+ " Press (2) to explore txn outputs." % OK)
+ if (self.input_method == "sd") and CardSlot.both_inserted():
+ esc += "b"
msg.write(" (B) to write to lower SD slot.")
- msg.write(" X to abort.")
+ msg.write(" %s to abort." % X)
+
while True:
- ch = await ux_show_story(msg, title="OK TO SEND?", escape="2b")
- if ch == "2" and needs_txn_explorer:
+ ch = await ux_show_story(msg, title="OK TO SEND?", escape=esc)
+ if ch == "2":
await self.txn_explorer()
continue
else:
@@ -778,8 +495,8 @@ async def interact(self):
del msg
break
else:
+ # get approval (maybe) from the HSM
ch = await hsm_active.approve_transaction(self.psbt, self.psbt_sha, msg.getvalue())
- dis.progress_bar_show(1) # finish the Validating...
except MemoryError:
# recovery? maybe.
@@ -793,7 +510,7 @@ async def interact(self):
return await self.failure(msg)
if ch not in 'yb':
- # they don't want to!
+ # they don't want to sign!
self.refused = True
await ux_dramatic_pause("Refused.", 1)
@@ -803,73 +520,59 @@ async def interact(self):
self.done()
return
+ if ccc_needs_2fa and could_ccc_sign:
+ # They still need to pass web2fa challenge (but it meets other specs ok)
+ try:
+ await CCCFeature.web2fa_challenge()
+ except:
+ could_ccc_sign = False
+ ch2 = await ux_show_story("Will not add CCC signature. Proceed anyway?")
+ if ch2 != 'y':
+ return await self.failure("2FA Failed")
+
+ elif ss_needs_2fa:
+ # Need 2FA for single-sig case .. refuse to sign if it fails.
+ try:
+ await SSSPFeature.web2fa_challenge()
+ except:
+ return await self.failure("2FA Failed")
+
# do the actual signing.
try:
dis.fullscreen('Wait...')
gc.collect() # visible delay caused by this but also sign_it() below
self.psbt.sign_it()
+
+ if could_ccc_sign:
+ # this is where the CCC co-signing happens.
+ dis.fullscreen('Co-Signing...')
+ gc.collect()
+ CCCFeature.sign_psbt(self.psbt)
+ else:
+ # maybe capture new min-height for velocity limit
+ SSSPFeature.update_last_signed(self.psbt)
+
except FraudulentChangeOutput as exc:
return await self.failure(exc.args[0], title='Change Fraud')
except MemoryError:
msg = "Transaction is too complex"
return await self.failure(msg)
except BaseException as exc:
+ # sys.print_exception(exc)
return await self.failure("Signing failed late", exc)
- if self.approved_cb:
- # for NFC, micro SD cases
- kws = dict(psbt=self.psbt)
- if self.is_sd and (ch == "b"):
- kws["slot_b"] = True
- await self.approved_cb(**kws)
- self.done()
- return
-
- txid = None
try:
- # re-serialize the PSBT back out
- with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as fd:
- if self.do_finalize:
- txid = self.psbt.finalize(fd)
- else:
- self.psbt.serialize(fd)
-
- self.result = (fd.tell(), fd.checksum.digest())
-
- self.done(redraw=(not txid))
-
+ await done_signing(self.psbt, self, self.input_method,
+ self.filename, self.output_encoder,
+ slot_b=(ch == "b"), finalize=self.do_finalize)
+ self.done()
+ except AbortInteraction:
+ # user might have sent new sign cmd, while we still at export prompt
+ pass
except BaseException as exc:
+ # sys.print_exception(exc)
return await self.failure("PSBT output failed", exc)
- from glob import NFC
-
- if self.do_finalize and txid and not hsm_active:
-
- if await try_push_tx(self.result[0], txid, self.result[1]):
- return # success, exit
-
- kq, kn = "(1)", "(3)"
- if version.has_qwerty:
- kq, kn = KEY_QR, KEY_NFC
- while 1:
- # Show txid when we can; advisory
- # - maybe even as QR, hex-encoded in alnum mode
- tmsg = txid + '\n\nPress %s for QR Code of TXID. ' % kq
-
- if NFC:
- tmsg += 'Press %s to share signed txn via NFC.' % kn
-
- ch = await ux_show_story(tmsg, "Final TXID", escape='13'+KEY_NFC+KEY_QR)
-
- if ch in '1'+KEY_QR:
- await show_qr_code(txid, True)
- continue
-
- if ch in KEY_NFC+"3" and NFC:
- await NFC.share_signed_txn(txid, TXN_OUTPUT_OFFSET,
- self.result[0], self.result[1])
- continue
- break
async def txn_explorer(self):
# Page through unlimited-sized transaction details
@@ -880,11 +583,16 @@ def make_msg(offset, count):
dis.fullscreen('Wait...')
rv = ""
end = min(offset + count, self.psbt.num_outputs)
-
- for idx, out in self.psbt.output_iter(offset, end):
+ addrs = []
+ change = []
+ for i, (idx, out) in enumerate(self.psbt.output_iter(offset, end)):
outp = self.psbt.outputs[idx]
item = "Output %d%s:\n\n" % (idx, " (change)" if outp.is_change else "")
- item += self.render_output(out)
+ msg, addr_or_script = self.render_output(out)
+ item += msg
+ addrs.append(addr_or_script)
+ if outp.is_change:
+ change.append(i)
item += "\n"
rv += item
dis.progress_sofar(idx-offset+1, count)
@@ -892,18 +600,31 @@ def make_msg(offset, count):
rv += 'Press RIGHT to see next group'
if offset:
rv += ', LEFT to go back'
- rv += '. X to quit.'
- return rv
+ if not version.has_qwerty:
+ # Q has hint key
+ rv += ", (4) to show QR code"
+ rv += ('. %s to quit.' % X)
+
+ return rv, addrs, change, end
start = 0
n = 10
- msg = make_msg(start, n)
+ msg, addrs, change, end = make_msg(start, n)
while True:
- ch = await ux_show_story(msg, escape='79'+KEY_RIGHT+KEY_LEFT)
+ ch = await ux_show_story(msg, title="%d-%d" % (start, end-1),
+ escape='479'+KEY_RIGHT+KEY_LEFT+KEY_QR,
+ hint_icons=KEY_QR)
if ch == 'x':
del msg
return
+ elif ch in "4"+KEY_QR:
+ from ux import show_qr_codes
+ # showing addresses from PSBT, no idea what is in there
+ # handle QR code failures gracefully
+ await show_qr_codes(addrs, False, start, is_addrs=True,
+ change_idxs=change, can_raise=False)
+ continue
elif (ch in KEY_LEFT+"7"):
if (start - n) < 0:
continue
@@ -920,11 +641,13 @@ def make_msg(offset, count):
# nothing changed - do not recalc msg
continue
- msg = make_msg(start, n)
+ msg, addrs, change, end = 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)
@@ -932,7 +655,8 @@ async def save_visualization(self, msg, sign_text=False):
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)
@@ -941,11 +665,11 @@ async def save_visualization(self, msg, sign_text=False):
if chk:
# append the signature
digest = ngu.hash.sha256s(chk.digest())
- sig = sign_message_digest(digest, 'm', None, AF_CLASSIC)[0]
+ sig, _ = sign_message_digest(digest, 'm', None, AF_CLASSIC)
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
@@ -959,14 +683,15 @@ def output_summary_text(self, msg):
MAX_VISIBLE_OUTPUTS = const(10)
MAX_VISIBLE_CHANGE = const(20)
- needs_txn_explorer = False
largest_outs = []
largest_change = []
total_change = 0
+ has_change = False
for idx, tx_out in self.psbt.output_iter():
outp = self.psbt.outputs[idx]
if outp.is_change:
+ has_change = True
total_change += tx_out.nValue
if len(largest_change) < MAX_VISIBLE_CHANGE:
largest_change.append((tx_out.nValue, self.chain.render_address(tx_out.scriptPubKey)))
@@ -976,7 +701,8 @@ def output_summary_text(self, msg):
else:
if len(largest_outs) < MAX_VISIBLE_OUTPUTS:
- largest_outs.append((tx_out.nValue, self.render_output(tx_out)))
+ rendered, _ = self.render_output(tx_out)
+ largest_outs.append((tx_out.nValue, rendered))
if len(largest_outs) == MAX_VISIBLE_OUTPUTS:
# descending sort from the biggest value to lowest (sort on out.nValue)
largest_outs = sorted(largest_outs, key=lambda x: x[0], reverse=True)
@@ -996,7 +722,8 @@ def output_summary_text(self, msg):
if outp.is_change:
ret = (here, self.chain.render_address(tx_out.scriptPubKey))
else:
- ret = (here, self.render_output(tx_out))
+ rendered, _ = self.render_output(tx_out)
+ ret = (here, rendered)
largest.insert(keep, ret)
# foreign outputs (soon to be other people's coins)
@@ -1008,7 +735,6 @@ def output_summary_text(self, msg):
left = self.psbt.num_outputs - len(largest_outs) - self.psbt.num_change_outputs
if left > 0:
- needs_txn_explorer = True
msg.write('.. plus %d smaller output(s), not shown here, which total: ' % left)
# calculate left over value
@@ -1018,36 +744,33 @@ def output_summary_text(self, msg):
msg.write("\n")
# change outputs - verified to be coming back to our wallet
- if total_change > 0:
+ if has_change:
msg.write("Change back:\n%s %s\n" % self.chain.render_value(total_change))
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\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:
- needs_txn_explorer = True
msg.write('.. plus %d smaller change output(s), not shown here, which total: ' % left_c)
- msg.write('%s %s\n' % self.chain.render_value(total_change - visible_change_sum))
-
- msg.write("\n")
-
- # if we didn't already show all outputs, then give user a chance to
- # view them individually
- return needs_txn_explorer
+ msg.write('%s %s\n\n' % self.chain.render_value(total_change - visible_change_sum))
-def sign_transaction(psbt_len, flags=0x0, psbt_sha=None):
+def sign_transaction(psbt_len, flags=0x0, psbt_sha=None, miniscript_wallet=None):
# transaction (binary) loaded into PSRAM already, checksum checked
+ # optional miniscript_wallet arg, choose particular enrolled wallet by name to sign
UserAuthorizedAction.check_busy(ApproveTransaction)
- UserAuthorizedAction.active_request = ApproveTransaction(psbt_len, flags, psbt_sha=psbt_sha)
+ UserAuthorizedAction.active_request = ApproveTransaction(
+ psbt_len, flags, psbt_sha=psbt_sha, input_method="usb",
+ miniscript_wallet=miniscript_wallet,
+ )
# kill any menu stack, and put our thing at the top
abort_and_goto(UserAuthorizedAction.active_request)
@@ -1072,22 +795,295 @@ def psbt_encoding_taster(taste, psbt_len):
raise ValueError("not psbt")
return decoder, output_encoder, psbt_len
-
-async def sign_psbt_file(filename, force_vdisk=False, slot_b=None):
+
+
+async def done_signing(psbt, tx_req, input_method=None, filename=None,
+ output_encoder=None, slot_b=False, finalize=None):
+ # User authorized PSBT for signing, and we added signatures.
+ # - allow PushTX if enabled (first thing)
+ # - can save final TXN out to SD card/VirtDisk, share by NFC, QR.
+
+ from glob import PSRAM, hsm_active
+ from sffile import SFFile
+ from ux import show_qr_code, import_export_prompt
+
+ first_time = True
+ msg = None
+ title = None
+
+ is_complete = psbt.is_complete()
+ if finalize is not None:
+ # USB case - user can choose whether to attempt finalization
+ is_complete = finalize
+
+ with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
+ if is_complete:
+ txid = psbt.finalize(psram)
+ noun = "Finalized TX ready for broadcast"
+ else:
+ psbt.serialize(psram)
+ noun = "Partly Signed PSBT"
+ txid = None
+
+ data_len = psram.tell()
+ data_sha2 = psram.checksum.digest()
+
+ # BBQR is at TMP_OUTPUT_OFFSET + 1MB - allowing it in this case would overwrite txn
+ # allow_qr = data_len < (1024*1024)
+ # actual more reasonable limit - as BBQR has some overhead and only 1Mbit of space
+ allow_qr = data_len < (671*1024)
+
+ if input_method == "usb":
+ # return result over USB before going to all options
+ tx_req.result = data_len, data_sha2
+ if hsm_active:
+ # it is enough to just return back via USB, other options
+ # are pointless
+ return
+
+ first_time = False
+ msg = noun + " shared via USB."
+ title = "PSBT Signed"
+
+ if txid and await try_push_tx(data_len, txid, data_sha2):
+ # go directly to reexport menu after pushTX
+ first_time = False
+ title = "TX Pushed"
+
+ # for specific cases, key teleport is an option
+ offer_kt = False
+ if not is_complete and version.has_qwerty and psbt.active_miniscript:
+ offer_kt = 'use Key Teleport to send PSBT to other co-signers'
+
+ while True:
+ ch = None
+ if first_time:
+ # first time, assume they want to send out same way it came in -- don't prompt
+ if input_method == "qr":
+ if allow_qr:
+ ch = KEY_QR
+ elif input_method == "nfc":
+ ch = KEY_NFC
+ elif input_method == "kt":
+ ch = 't'
+ else:
+ # SD/VDisk
+ ch = {"force_vdisk": input_method == "vdisk", "slot_b": slot_b}
+
+ if not ch:
+ # show all possible export options (based on hardware enabled, features)
+ intro = []
+ if msg:
+ intro.append(msg)
+ if txid:
+ intro.append('TXID:\n' + txid)
+
+ # "force_prompt" is needed after first iteration as we can be Mk4, with NFC,Vdisk off,
+ # no QR support & not finalizing (no option to show txid provided).
+ # In that case this would just return dict and keep producing signed
+ # files on SD infinitely (would never actually prompt).
+ ch = await import_export_prompt(noun, intro="\n\n".join(intro), offer_kt=offer_kt,
+ txid=txid, title=title, force_prompt=not first_time,
+ no_qr=not version.has_qwerty or not allow_qr)
+ if ch == KEY_CANCEL:
+ UserAuthorizedAction.cleanup()
+ break
+
+ elif txid and (ch == '6'):
+ await show_qr_code(txid, is_alnum=True, force_msg=True)
+ continue
+
+ elif ch == KEY_QR:
+ here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len)
+ msg = txid or 'Partly Signed PSBT'
+ try:
+ if len(here) > 920:
+ # too big for simple QR - use BBQr instead
+ raise QRTooBigError
+ hex_here = b2a_hex(here).upper().decode()
+ await show_qr_code(hex_here, is_alnum=True, msg=msg)
+ except QRTooBigError:
+ from ux_q1 import show_bbqr_codes
+ await show_bbqr_codes('T' if txid else 'P', here, msg)
+
+ msg = noun + " shared via QR."
+ del here
+
+ elif ch == KEY_NFC:
+ from glob import NFC
+ if is_complete:
+ await NFC.share_signed_txn(txid, TXN_OUTPUT_OFFSET, data_len, data_sha2)
+ else:
+ await NFC.share_psbt(TXN_OUTPUT_OFFSET, data_len, data_sha2)
+
+ msg = noun + " shared via NFC."
+
+ elif (ch == 't') and not is_complete:
+ # they might want to teleport it, but only if we have PSBT
+ # there is no need to teleport PSBT if txn is already complete & ready to be broadcast
+ from teleport import kt_send_psbt
+ ok = await kt_send_psbt(psbt, data_len)
+ if ok:
+ title = "Sent by Teleport"
+ else:
+ title = "Failed to Teleport"
+
+ continue
+
+ else:
+ # typical case: save to SD card, show filenames we used
+ assert isinstance(ch, dict)
+ msg = await _save_to_disk(psbt, txid, ch, is_complete, data_len,
+ output_encoder, filename)
+
+ input_method = None
+ first_time = False
+ title = "PSBT Signed"
+
+async def _save_to_disk(psbt, txid, save_options, is_complete, data_len, output_encoder, filename=None):
+ # Saving a PSBT from PSRAM to something disk-like.
+ # - handle save-to-SD/VirtDisk cases. With re-attempt when no card, etc.
+ assert isinstance(save_options, dict) # from import_export_prompt
+
+ from glob import dis, settings, PSRAM
+ import os
+
+ dis.fullscreen("Wait...")
+
+ if filename:
+ _, basename = filename.rsplit('/', 1)
+ base = basename.rsplit('.', 1)[0]
+ else:
+ base = 'recent-txn'
+
+ # default encoding is binary
+ output_encoder = output_encoder or (lambda x:x)
+
+ out2_fn = None
+ out_fn = None
+
+ del_after = settings.get('del', 0)
+
+ def _chunk_write(file_d, ofs, chunk=2048):
+ written = 0
+ while written < data_len:
+ if (written + chunk) > data_len:
+ chunk = data_len - written
+
+ file_d.write(PSRAM.read_at(ofs, chunk))
+ written += chunk
+ ofs += chunk
+
+ while 1:
+ # try to put back into same spot, but also do top-of-card
+ if not is_complete:
+ # keep the filename under control during multiple passes
+ target_fname = base.replace('-part', '') + '-part.psbt'
+ else:
+ # add -signed to end. We won't offer to sign again.
+ target_fname = base + '-signed.psbt'
+
+ # attempt write-out
+ try:
+ with CardSlot(**save_options) as card:
+ out_full, out_fn = card.pick_filename(target_fname)
+ out_path = out_full.rsplit("/", 1)[0] + "/"
+
+ if is_complete and del_after:
+ # don't write signed PSBT if we'd just delete it anyway
+ out_fn = None
+ else:
+ with output_encoder(card.open(out_full, 'wb')) as fd:
+ # save as updated PSBT
+ if not is_complete:
+ _chunk_write(fd, TXN_OUTPUT_OFFSET)
+ else:
+ psbt.serialize(fd)
+
+ if is_complete:
+ # write out as hex too, if it's final
+ out2_full, out2_fn = card.pick_filename(
+ base + '-final.txn' if not del_after else 'tmp.txn',
+ out_path)
+
+ if out2_full:
+ with HexWriter(card.open(out2_full, 'w+t')) as fd:
+ # save transaction, in hex
+ if is_complete:
+ _chunk_write(fd, TXN_OUTPUT_OFFSET)
+ else:
+ txid = psbt.finalize(fd)
+
+ if del_after:
+ # rename it now that we know the txid
+ after_full, out2_fn = card.pick_filename(
+ txid + '.txn', out_path, overwrite=True)
+ os.rename(out2_full, after_full)
+
+ if del_after and filename:
+ # this can do nothing if they swapped SDCard between steps, which is ok,
+ # but if the original file is still there, this blows it away.
+ # - if not yet final, the foo-part.psbt file stays
+ try:
+ card.securely_blank_file(filename)
+ except: pass
+
+ # success and done!
+ break
+
+ except CardMissingError:
+ prob = 'Need a card!\n\n'
+
+ except OSError as exc:
+ prob = 'Failed to write!\n\n%s\n\n' % exc
+ # sys.print_exception(exc)
+ # fall through to try again
+
+ # If this point reached, some problem, we could not write.
+
+ if save_options.get('force_vdisk'):
+ await ux_show_story(prob, title='Error')
+ # they can't fix here, so give up
+ return
+
+ # prompt them to input another card?
+ ch = await ux_show_story(
+ prob + "Please insert a card to receive signed transaction, "
+ "and press OK.", title="Need Card")
+ if ch == 'x':
+ return
+
+ # Done, show the filenames we used.
+ if out_fn:
+ msg = "Updated PSBT is:\n\n%s" % out_fn
+ if out2_fn:
+ msg += '\n\n'
+ else:
+ # del_after is probably set
+ msg = ''
+
+ if out2_fn:
+ msg += 'Finalized transaction (ready for broadcast):\n\n%s' % out2_fn
+
+ return msg
+
+
+async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=False, ux_abort=False,
+ miniscript_wallet=None):
# sign a PSBT file found on a MicroSD card
# - or from VirtualDisk (mk4)
- from files import CardSlot, CardMissingError
+ # - to re-use reading/decoding logic, pass just_read
from glob import dis
from ux import the_ux
- tmp_buf = bytearray(1024)
+ tmp_buf = bytearray(4096)
# copy file into PSRAM
# - can't work in-place on the card because we want to support writing out to different card
- # - accepts hex or base64 encoding, but binary prefered
+ # - accepts hex or base64 encoding, but binary preferred
with CardSlot(force_vdisk, readonly=True, slot_b=slot_b) as card:
with card.open(filename, 'rb') as fd:
- dis.fullscreen('Reading...')
+ dis.fullscreen('Reading...', 0)
# see how long it is
psbt_len = fd.seek(0, 2)
@@ -1118,138 +1114,26 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None):
out.write(here)
total += len(here)
- dis.progress_bar_show(total / psbt_len)
+ dis.progress_sofar(total, psbt_len)
# might have been whitespace inflating initial estimate of PSBT size
assert total <= psbt_len
psbt_len = total
- async def done(psbt, slot_b=None):
- dis.fullscreen("Wait...")
- orig_path, basename = filename.rsplit('/', 1)
- orig_path += '/'
- base = basename.rsplit('.', 1)[0]
- out2_fn = None
- out_fn = None
- txid = None
-
- from glob import settings
- import os
- del_after = settings.get('del', 0)
-
- while 1:
- # try to put back into same spot, but also do top-of-card
- is_comp = psbt.is_complete()
- if not is_comp:
- # keep the filename under control during multiple passes
- target_fname = base.replace('-part', '')+'-part.psbt'
- else:
- # add -signed to end. We won't offer to sign again.
- target_fname = base+'-signed.psbt'
-
- for path in [orig_path, None]:
- try:
- with CardSlot(force_vdisk, readonly=True, slot_b=slot_b) 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:
- with CardSlot(force_vdisk, slot_b=slot_b) as card:
- if is_comp and del_after:
- # don't write signed PSBT if we'd just delete it anyway
- out_fn = None
- else:
- with output_encoder(card.open(out_full, 'wb')) as fd:
- # save as updated PSBT
- psbt.serialize(fd)
-
- if is_comp:
- # write out as hex too, if it's final
- out2_full, out2_fn = card.pick_filename(
- base+'-final.txn' if not del_after else 'tmp.txn', out_path)
-
- with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as fd0:
- txid = psbt.finalize(fd0)
- fd0.flush_out() # need to flush here as we are probably not gona call .read( again
- tx_len, tx_sha = fd0.tell(), fd0.checksum.digest()
- if txid and await try_push_tx(tx_len, txid, tx_sha):
- return # success, exit
-
- if out2_full:
- fd0.seek(0)
-
- with HexWriter(card.open(out2_full, 'w+t')) as fd:
- # save transaction, in hex
- tmp_buf = bytearray(4096)
- while True:
- rv = fd0.readinto(tmp_buf)
- if not rv: break
- fd.write(memoryview(tmp_buf)[:rv])
-
- if del_after:
- # rename it now that we know the txid
- after_full, out2_fn = card.pick_filename(
- txid+'.txn', out_path, overwrite=True)
- os.rename(out2_full, after_full)
-
- if del_after:
- # this can do nothing if they swapped SDCard between steps, which is ok,
- # but if the original file is still there, this blows it away.
- # - if not yet final, the foo-part.psbt file stays
- try:
- card.securely_blank_file(filename)
- except: pass
-
- # success and done!
- break
-
- except OSError as exc:
- prob = 'Failed to write!\n\n%s\n\n' % exc
- sys.print_exception(exc)
- # fall thru to try again
-
- if force_vdisk:
- await ux_show_story(prob, title='Error')
- return
-
- # prompt them to input another card?
- ch = await ux_show_story(prob+"Please insert an SDCard to receive signed transaction, "
- "and press %s." % OK, title="Need Card")
- if ch == 'x':
- await ux_aborted()
- return
-
- # done.
- if out_fn:
- msg = "Updated PSBT is:\n\n%s" % out_fn
- if out2_fn:
- msg += '\n\n'
- else:
- # del_after is probably set
- msg = ''
-
- if out2_fn:
- msg += 'Finalized transaction (ready for broadcast):\n\n%s' % out2_fn
- if txid and not del_after:
- msg += '\n\nFinal TXID:\n'+txid
-
- await ux_show_story(msg, title='PSBT Signed')
-
- UserAuthorizedAction.cleanup()
+ if just_read:
+ return psbt_len
UserAuthorizedAction.cleanup()
- UserAuthorizedAction.active_request = ApproveTransaction(psbt_len, approved_cb=done,
- is_sd=not force_vdisk)
- the_ux.push(UserAuthorizedAction.active_request)
+ UserAuthorizedAction.active_request = ApproveTransaction(
+ psbt_len, input_method="vdisk" if force_vdisk else "sd",
+ filename=filename, output_encoder=output_encoder,
+ miniscript_wallet=miniscript_wallet,
+ )
+ if ux_abort:
+ # needed for auto vdisk mode
+ abort_and_push(UserAuthorizedAction.active_request)
+ else:
+ the_ux.push(UserAuthorizedAction.active_request)
class RemoteBackup(UserAuthorizedAction):
def __init__(self):
@@ -1271,8 +1155,53 @@ async def interact(self):
except BaseException as exc:
self.failed = "Error during backup process."
- print("Backup failure: ")
- sys.print_exception(exc)
+ #print("Backup failure: ")
+ #sys.print_exception(exc)
+ finally:
+ self.done()
+
+
+class RemoteRestoreBackup(UserAuthorizedAction):
+ def __init__(self, file_len, bitflag):
+ super().__init__()
+ self.file_len = file_len
+ self.custom_pwd = bitflag & 1
+ self.plaintext = bitflag & 2
+ self.force_tmp = bitflag & 4
+
+ def to_words(self):
+ # conversion to "words" argument of "restore_complete" function
+ if self.plaintext:
+ return None
+ elif self.custom_pwd:
+ return False
+ return True
+
+ def to_tmp(self):
+ # conversion to "temporary" argument of "restore_complete" function
+ from pincodes import pa
+ if pa.is_secret_blank() and not self.force_tmp:
+ # no master secret & not forcing tmp
+ # will load backup as master seed
+ return False, "master"
+
+ # has master secret --> load backup as tmp
+ # secret is blank but user forcing tmp
+ return True, "temporary"
+
+ async def interact(self):
+ try:
+ # requires confirm from user
+ tmp, noun = self.to_tmp()
+ if await ux_confirm("Restore uploaded backup as a %s seed?" % noun):
+ from backups import restore_complete
+ await restore_complete(self.file_len, tmp, self.to_words(), usb=True)
+ else:
+ self.refused = True
+
+ except BaseException as exc:
+ self.failed = "Error during backup restore."
+ # sys.print_exception(exc)
finally:
self.done()
@@ -1287,6 +1216,12 @@ def start_remote_backup():
# kill any menu stack, and put our thing at the top
abort_and_goto(UserAuthorizedAction.active_request)
+def start_remote_restore_backup(file_len, bitflag):
+ UserAuthorizedAction.cleanup()
+ UserAuthorizedAction.active_request = RemoteRestoreBackup(file_len, bitflag)
+ # kill any menu stack, and put our thing at the top
+ abort_and_goto(UserAuthorizedAction.active_request)
+
class NewPassphrase(UserAuthorizedAction):
def __init__(self, pw):
@@ -1333,7 +1268,7 @@ async def interact(self):
except BaseException as exc:
self.failed = "Exception"
- sys.print_exception(exc)
+ # sys.print_exception(exc)
finally:
self.done()
@@ -1375,17 +1310,19 @@ async def interact(self):
msg = self.get_msg()
msg += '\n\nCompare this payment address to the one shown on your other, less-trusted, software.'
+ esc = "4"
if not version.has_qwerty:
if NFC:
- msg += ' Press %s to share via NFC.' % (KEY_NFC if version.has_qwerty else "(3)")
+ msg += ' Press (3) to share via NFC.'
+ esc += "3"
msg += ' Press (4) to view QR Code.'
while 1:
- ch = await ux_show_story(msg, title=self.title, escape='34',
- hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
+ ch = await ux_show_story(msg, title=self.title, escape=esc,
+ 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):
@@ -1396,7 +1333,7 @@ async def interact(self):
else:
# finish the Wait...
- dis.progress_bar_show(1)
+ dis.progress_bar_show(1)
if self.restore_menu:
self.pop_menu()
@@ -1417,60 +1354,39 @@ def setup(self, addr_fmt, subpath):
self.address = sv.chain.address(node, addr_fmt)
def get_msg(self):
- return '''{addr}\n\n= {sp}''' .format(addr=self.address, sp=self.subpath)
-
-
-class ShowP2SHAddress(ShowAddressBase):
+ return '''{addr}\n\n= {sp}''' .format(addr=show_single_address(self.address),
+ sp=self.subpath)
- def setup(self, ms, addr_fmt, xfp_paths, witdeem_script):
- self.witdeem_script = witdeem_script
- self.addr_fmt = addr_fmt
- self.ms = ms
+class ShowMiniscriptAddress(ShowAddressBase):
- # calculate all the pubkeys involved.
- self.subpath_help = ms.validate_script(witdeem_script, xfp_paths=xfp_paths)
+ def setup(self, msc, change, idx):
+ self.msc = msc
+ self.change = change
+ self.idx = idx
- self.address = ms.chain.p2sh_address(addr_fmt, witdeem_script)
+ d = self.msc.to_descriptor().derive(None, change=change).derive(idx)
+ self.address = self.msc.chain.render_address(d.script_pubkey())
+ self.addr_fmt = self.msc.addr_fmt
def get_msg(self):
return '''\
{addr}
Wallet:
-
{name}
- {M} of {N}
-
-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))
-
-def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script):
- # Show P2SH address to user, also returns it.
- # - first need to find appropriate multisig wallet associated
- # - they must provide full redeem script, and we will re-verify it and check pubkeys inside it
- from multisig import MultisigWallet
-
- try:
- assert addr_format in SUPPORTED_ADDR_FORMATS
- assert addr_format & AFC_SCRIPT
- except:
- raise AssertionError('Unknown/unsupported addr format')
+Index:
+ {idx}
- # Search for matching multisig wallet that we must already know about
- xs = list(xfp_paths)
- xs.sort()
+Change:
+ {change}'''.format(addr=show_single_address(self.address), name=self.msc.name,
+ idx=self.idx, change=bool(self.change))
- ms = MultisigWallet.find_match(M, N, xs)
- assert ms, 'Multisig wallet with those fingerprints not found'
- assert ms.M == M
- assert ms.N == N
+def start_show_miniscript_address(msc, change, index):
UserAuthorizedAction.check_busy(ShowAddressBase)
- UserAuthorizedAction.active_request = ShowP2SHAddress(ms, addr_format, xfp_paths, witdeem_script)
+ UserAuthorizedAction.active_request = ShowMiniscriptAddress(msc, change, index)
# kill any menu stack, and put our thing at the top
abort_and_goto(UserAuthorizedAction.active_request)
@@ -1478,6 +1394,7 @@ def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script):
# provide the value back to attached desktop
return UserAuthorizedAction.active_request.address
+
def show_address(addr_format, subpath, restore_menu=False):
try:
assert addr_format in SUPPORTED_ADDR_FORMATS
@@ -1505,64 +1422,112 @@ def usb_show_address(addr_format, subpath):
return active_request.address
-class NewEnrollRequest(UserAuthorizedAction):
- def __init__(self, ms):
+class MiniscriptDeleteRequest(UserAuthorizedAction):
+ def __init__(self, msc):
super().__init__()
- self.wallet = ms
- # self.result ... will be re-serialized xpub
+ self.wallet = msc
async def interact(self):
- from multisig import MultisigOutOfSpace
+ from wallet import miniscript_delete
+ await miniscript_delete(self.wallet)
+ self.done()
+
+
+def maybe_delete_miniscript(msc):
+ UserAuthorizedAction.cleanup()
+ UserAuthorizedAction.active_request = MiniscriptDeleteRequest(msc)
+
+ # kill any menu stack, and put our thing at the top
+ abort_and_goto(UserAuthorizedAction.active_request)
+
+class NewMiniscriptEnrollRequest(UserAuthorizedAction):
+ def __init__(self, msc, bsms_index=None):
+ super().__init__()
+ self.wallet = msc
+ self.bsms_index = bsms_index
+
+ async def interact(self):
+ from wallet import WalletOutOfSpace
ms = self.wallet
try:
- ch = await ms.confirm_import()
-
- if ch != 'y':
+ approved = await ms.confirm_import()
+ if not approved:
# they don't want to!
self.refused = True
await ux_dramatic_pause("Refused.", 2)
- except MultisigOutOfSpace:
+ elif self.bsms_index is not None:
+ # remove signer round 2 from settings after multisig import is approved by user
+ from bsms import BSMSSettings
+ BSMSSettings.signer_delete(self.bsms_index)
+
+ except WalletOutOfSpace:
return await self.failure('No space left')
except BaseException as exc:
self.failed = "Exception"
- sys.print_exception(exc)
+ # sys.print_exception(exc)
finally:
- UserAuthorizedAction.cleanup() # because no results to store
- self.pop_menu()
+ UserAuthorizedAction.cleanup() # because no results to store
+ if self.bsms_index is not None:
+ # bsms special case, get him back to multisig menu
+ from ux import the_ux, restore_menu
+ from wallet import MiniscriptMenu
+ while 1:
+ top = the_ux.top_of_stack()
+ if not top: break
+ if not isinstance(top, MiniscriptMenu):
+ the_ux.pop()
+ continue
+ break
+ restore_menu()
+ else:
+ self.pop_menu()
-def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
- # Offer to import (enroll) a new multisig wallet. Allow reject by user.
+
+def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False,
+ bsms_index=None, desc_obj=None):
+ # Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user.
from glob import dis
- from multisig import MultisigWallet
+ from wallet import MiniScriptWallet
UserAuthorizedAction.cleanup()
- dis.fullscreen('Wait...') # needed
+ dis.fullscreen('Wait...')
dis.busy_bar(True)
+ bip388 = False
try:
- if sf_len:
- with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd:
- config = fd.read(sf_len).decode()
+ if desc_obj:
+ # caller is sending us already validated descriptor object
+ assert name
+ msc = MiniScriptWallet.from_descriptor_obj(name, desc_obj)
+ else:
+ if sf_len:
+ with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd:
+ config = fd.read(sf_len).decode()
- try:
- j_conf = ujson.loads(config)
- assert "desc" in j_conf, "'desc' key required"
- config = j_conf["desc"]
- assert config, "'desc' empty"
+ try:
+ j_conf = ujson.loads(config)
+ if "desc_template" in j_conf and "keys_info" in j_conf:
+ assert "name" in j_conf
+ config = j_conf
+ bip388 = True
+ else:
+ assert "desc" in j_conf, "'desc' key required"
+ config = j_conf["desc"]
+ assert config, "'desc' empty"
- if "name" in j_conf:
- # name from json has preference over filenames and desc checksum
- name = j_conf["name"]
- assert 2 <= len(name) <= 40, "'name' length"
- except ValueError: pass
+ if "name" in j_conf:
+ # name from json has preference over filenames and desc checksum
+ name = j_conf["name"]
+ assert 2 <= len(name) <= 40, "'name' length"
+ except ValueError: pass
- # this call will raise on parsing errors, so let them rise up
- # and be shown on screen/over usb
- ms = MultisigWallet.from_file(config, name=name)
+ # this call will raise on parsing errors, so let them rise up
+ # and be shown on screen/over usb
+ msc = MiniScriptWallet.from_file(config, name=name, bip388=bip388)
- UserAuthorizedAction.active_request = NewEnrollRequest(ms)
+ UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index)
if ux_reset:
# for USB case, and import from PSBT
@@ -1573,9 +1538,9 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
from ux import the_ux
the_ux.push(UserAuthorizedAction.active_request)
finally:
- # always finish busy bar
dis.busy_bar(False)
+
class FirmwareUpgradeRequest(UserAuthorizedAction):
def __init__(self, hdr, length, hdr_check=False, psram_offset=None):
super().__init__()
@@ -1636,7 +1601,7 @@ async def interact(self):
except BaseException as exc:
self.failed = "Exception"
- sys.print_exception(exc)
+ # sys.print_exception(exc)
finally:
UserAuthorizedAction.cleanup() # because no results to store
self.pop_menu()
diff --git a/shared/backups.py b/shared/backups.py
index f83a2b41c..5bc441233 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -5,16 +5,18 @@
import compat7z, stash, ckcc, chains, gc, sys, bip39, uos, ngu
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
-from utils import pad_raw_secret
-from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X
+from utils import deserialize_secret, swab32, xfp2str
+from sffile import SFFile
+from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text
import version, ujson
-from uio import StringIO
+from uio import StringIO, BytesIO
import seed
from glob import settings
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
@@ -43,12 +45,7 @@ def ADD(key, val):
COMMENT('Private key details: ' + chain.name)
- with stash.SensitiveValues(bypass_tmp=bypass_tmp) as sv:
- if sv.deltamode:
- # die rather than give up our secrets
- import callgate
- callgate.fast_wipe()
-
+ with stash.SensitiveValues(bypass_tmp=bypass_tmp, enforce_delta=True) as sv:
if sv.mode == 'words':
ADD('mnemonic', bip39.b2a_words(sv.raw))
@@ -103,6 +100,9 @@ def ADD(key, val):
if k == 'bkpw': continue # confusing/circular
if k == 'sd2fa': continue # do NOT backup SD 2FA (card can be lost or damaged)
if k == 'words': continue # words length is recalculated from secret
+ if k == 'ccc': continue # not supported, security issue
+ if k == 'ktrx': continue # not useful after the fact
+ if k == 'lfr': continue # temporary error msg value
if k == 'seedvault' and not v: continue
if k == 'seeds' and not v: continue
ADD('setting.' + k, v)
@@ -123,14 +123,14 @@ def ADD(key, val):
return rv.getvalue()
-def extract_raw_secret(chain, vals):
+def extract_raw_secret(vals):
# step1: the private key
# - prefer raw_secret over other values
# - TODO: fail back to other values
assert 'raw_secret' in vals
rs = vals.pop('raw_secret')
- raw = pad_raw_secret(rs)
+ raw = deserialize_secret(rs)
# check we can decode this right (might be different firmare)
opmode, bits, node = stash.SecretStash.decode(raw)
@@ -138,22 +138,23 @@ def extract_raw_secret(chain, vals):
# verify against xprv value (if we have it)
if 'xprv' in vals:
- check_xprv = chain.serialize_private(node)
+ check_xprv = chains.get_chain(vals.get('chain', 'BTC')).serialize_private(node)
assert check_xprv == vals['xprv'], 'xprv mismatch'
- return raw
+ return raw, node
def extract_long_secret(vals):
ls = None
if ('long_secret' in vals) and version.has_608:
try:
ls = a2b_hex(vals.pop('long_secret'))
- except Exception as exc:
- sys.print_exception(exc)
+ except:
+ # sys.print_exception(exc)
# but keep going.
+ pass
return ls
-def restore_from_dict_ll(vals):
+def restore_from_dict_ll(vals, raw):
# Restore from a dict of values. Already JSON decoded.
# Need a Reboot on success, return string on failure
# - low-level version, factored out for better testing
@@ -164,12 +165,6 @@ def restore_from_dict_ll(vals):
#print("Restoring from: %r" % vals)
chain = chains.get_chain(vals.get('chain', 'BTC'))
- try:
- raw = extract_raw_secret(chain, vals)
- except Exception as e:
- return ('Unable to decode raw_secret and '
- 'restore the seed value!\n\n\n'+str(e)), None
-
dis.fullscreen("Saving...")
dis.progress_bar_show(.1)
@@ -188,9 +183,7 @@ def restore_from_dict_ll(vals):
if ls is not None:
try:
pa.ls_change(ls)
- except Exception as exc:
- sys.print_exception(exc)
- # but keep going
+ except: pass # but keep going
pb = .70
dis.progress_bar_show(pb)
@@ -214,13 +207,17 @@ def restore_from_dict_ll(vals):
# old backups need this to function properly
continue
+ if k == 'ccc':
+ # CCC feature cannot be backed-up nor restored for security reasons
+ # (would allow replay attacks)
+ continue
+
if k == 'tp':
# restore trick pins, which may involve many ops
from trick_pins import tp
try:
tp.restore_backup(vals[key])
- except Exception as exc:
- sys.print_exception(exc)
+ except: pass
# continue as `tp.restore_backup` handles
# saving into settings
@@ -261,36 +258,50 @@ def restore_from_dict_ll(vals):
return None, need_ftux
-async def restore_tmp_from_dict_ll(vals):
+def text_bk_parser(contents):
+ # given a (binary encoded) text file, decode into a dict of values
+ # - use json rules to decode the "value" sides
+ vals = {}
+ for line in contents.decode().split('\n'):
+ if not line: continue
+ if line[0] == '#': continue
+
+ try:
+ k,v = line.split(' = ', 1)
+ #print("%s = %s" % (k, v))
+
+ vals[k] = ujson.loads(v)
+ except:
+ print("unable to decode line: %r" % line)
+ # but keep going!
+
+ return vals
+
+async def restore_tmp_from_dict_ll(vals, raw):
from glob import dis
chain = chains.get_chain(vals.get('chain', 'BTC'))
- try:
- raw = extract_raw_secret(chain, vals)
- except Exception as e:
- return ('Unable to decode raw_secret and '
- 'restore the seed value!\n\n\n' + str(e))
dis.fullscreen("Applying...")
from seed import set_ephemeral_seed
from actions import goto_top_menu
- await set_ephemeral_seed(raw, chain, meta="Coldcard Backup")
+ await set_ephemeral_seed(raw, chain, origin="Coldcard Backup")
for k, v in vals.items():
if not k[:8] == "setting.":
continue
key = k[8:]
- if key in ["multisig"]:
+ if key == "miniscript":
# whitelist
- settings.set(k, v)
+ settings.set(key, v)
goto_top_menu()
-async def restore_from_dict(vals):
+async def restore_from_dict(vals, raw):
# Restore from a dict of values. Already JSON decoded (ie. dict object).
# Need a Reboot on success, return string on failure
- prob, need_ftux = restore_from_dict_ll(vals)
+ prob, need_ftux = restore_from_dict_ll(vals, raw)
if prob: return prob
if need_ftux:
@@ -309,7 +320,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,35 +340,49 @@ 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" % num_pw_words,
+ words=pwd.split(" "), escape='6'
+ )
- if ch == '6' and not write_sflash:
+ if (ch == '6') and not write_sflash:
# Secret feature: plaintext mode
# - only safe for people living in faraday cages inside locked vaults.
if await ux_confirm("The file will **NOT** be encrypted and "
"anyone who finds the file will get all of your money for free!"):
- words = []
+ pwd = []
fname_pattern = 'backup.txt'
break
continue
@@ -367,43 +392,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
@@ -422,8 +447,6 @@ async def write_complete_backup(words, fname_pattern, write_sflash=False,
if write_sflash:
# for use over USB and unit testing: commit file into PSRAM
- from sffile import SFFile
-
with SFFile(0, max_size=MAX_BACKUP_FILE_SIZE, message='Saving...') as fd:
if zz:
fd.write(hdr)
@@ -452,11 +475,9 @@ async def write_complete_backup(words, fname_pattern, write_sflash=False,
except Exception as e:
# includes CardMissingError
- import sys
- sys.print_exception(e)
# catch any error
ch = await ux_show_story('Failed to write! Please insert formated MicroSD card, '
- 'and press %s to try again.\n\nX to cancel.\n\n\n' % OK +str(e))
+ 'and press %s to try again.\n\n%s to cancel.\n\n\n%s' % (OK, X, e))
if ch == 'x': break
continue
@@ -522,103 +543,157 @@ async def verify_backup_file(fname):
await ux_show_story("Backup file CRC checks out okay.\n\nPlease note this is only a check against accidental truncation and similar. Targeted modifications can still pass this test.")
-async def restore_complete(fname_or_fd, temporary=False):
+async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
from ux import the_ux
async def done(words):
# remove all pw-picking from menu stack
- seed.WordNestMenu.pop_all()
+ if not version.has_qwerty and words:
+ seed.WordNestMenu.pop_all()
prob = await restore_complete_doit(fname_or_fd, words,
temporary=temporary)
-
if prob:
await ux_show_story(prob, title='FAILED')
- if version.has_qwerty:
- from ux_q1 import seed_word_entry
- return await seed_word_entry('Enter Password:', num_pw_words,
- done_cb=done, has_checksum=False)
- # give them a menu to pick from, and start picking
- m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
+ if words:
+ if version.has_qwerty:
+ from ux_q1 import seed_word_entry, CHARS_W
+
+ basename = None
+ if isinstance(fname_or_fd, str):
+ basename = fname_or_fd.split('/')[-1]
+ if len(basename) > CHARS_W:
+ basename = basename[:16] + "⋯" + basename[-16:]
+
+ return await seed_word_entry("Enter Password%s:" % (" for" if basename else ""),
+ num_pw_words, done_cb=done, has_checksum=False,
+ line2=basename)
+
+ # give them a menu to pick from, and start picking
+ if usb:
+ # we're not originating from a menu
+ words = await seed.WordNestMenu.get_n_words(12)
+ await done(words)
+ else:
+ m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
+ the_ux.push(m)
+
+ else:
+ pwd = [] # cleartext if words=None
+ if words is False:
+ ipw = await ux_input_text("", prompt="Your Backup Password",
+ min_len=bkpw_min_len, max_len=128)
+ if not ipw: return
+ pwd.append(ipw)
+
+ await done(pwd)
+
+
+def check_and_decrypt(fd, password):
+ try:
+ compat7z.check_file_headers(fd)
+ except Exception as e:
+ raise RuntimeError('Unable to read backup file.'
+ ' Has it been touched?\n\nError: '+str(e))
+
+ from glob import dis
+ dis.fullscreen("Decrypting...")
+ try:
+ zz = compat7z.Builder()
+ fname, contents = zz.read_file(fd, password, MAX_BACKUP_FILE_SIZE,
+ progress_fcn=dis.progress_bar_show)
+
+ # simple quick sanity checks
+ assert fname.endswith('.txt') # was == 'ckcc-backup.txt'
+ assert contents[0:1] == b'#' and contents[-1:] == b'\n'
+ return contents
+
+ except Exception as e:
+ # assume everything here is "password wrong" errors
+ raise RuntimeError('Unable to decrypt backup file. Incorrect password?'
+ '\n\nTried:\n\n' + password)
- the_ux.push(m)
-async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False):
+async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False,
+ ux_confirm=True):
# Open file, read it, maybe decrypt it; return string if any error
# - some errors will be shown, None return in that case
# - no return if successful (due to reboot)
- from glob import dis
from files import CardSlot, CardMissingError, needs_microsd
# build password
password = ' '.join(words)
-
prob = None
- try:
- with CardSlot(readonly=True) as card:
- # filename already picked, taste it and maybe consider using its data.
- try:
- fd = open(fname_or_fd, 'rb') if isinstance(fname_or_fd, str) else fname_or_fd
- except:
- return 'Unable to open backup file.\n\n' + str(fname_or_fd)
+ if isinstance(fname_or_fd, int):
+ # USB restore - backup is already in PSRAM, fname of fd is length
+ # TXN_INPUT_OFFSET = 0
+ with SFFile(0, length=fname_or_fd) as fd:
+ if not words:
+ contents = fd.read(fname_or_fd)
+ else:
+ # read full size, then decrypt
+ fd = BytesIO(fd.read(fname_or_fd))
+ try:
+ contents = check_and_decrypt(fd, password)
+ except RuntimeError as e:
+ return str(e)
+ else:
+ try:
+ with CardSlot(readonly=True) as card:
+ # filename already picked, taste it and maybe consider using its data.
+ try:
+ fd = open(fname_or_fd, 'rb')
+ except:
+ return 'Unable to open backup file.\n\n' + str(fname_or_fd)
+
+ try:
+ if words:
+ contents = check_and_decrypt(fd, password)
+ else:
+ contents = fd.read()
- try:
- if not words:
- contents = fd.read()
- else:
- try:
- compat7z.check_file_headers(fd)
- except Exception as e:
- return 'Unable to read backup file. Has it been touched?\n\nError: ' \
- + str(e)
-
- dis.fullscreen("Decrypting...")
- try:
- zz = compat7z.Builder()
- fname, contents = zz.read_file(fd, password, MAX_BACKUP_FILE_SIZE,
- progress_fcn=dis.progress_bar_show)
-
- # simple quick sanity checks
- assert fname.endswith('.txt') # was == 'ckcc-backup.txt'
- assert contents[0:1] == b'#' and contents[-1:] == b'\n'
-
- except Exception as e:
- # assume everything here is "password wrong" errors
- #print("pw wrong? %s" % e)
-
- return ('Unable to decrypt backup file. Incorrect password?'
- '\n\nTried:\n\n' + password)
- finally:
- fd.close()
+ except RuntimeError as e:
+ return str(e)
+ finally:
+ fd.close()
if file_cleanup:
file_cleanup(fname_or_fd)
- except CardMissingError:
- await needs_microsd()
- return
-
- vals = {}
- for line in contents.decode().split('\n'):
- if not line: continue
- if line[0] == '#': continue
+ except CardMissingError:
+ await needs_microsd()
+ return
- try:
- k,v = line.split(' = ', 1)
- #print("%s = %s" % (k, v))
+ try:
+ vals = text_bk_parser(contents)
+ except:
+ return "Invalid backup file."
- vals[k] = ujson.loads(v)
- except:
- print("unable to decode line: %r" % line)
- # but keep going!
+ try:
+ raw, node = extract_raw_secret(vals)
+ except Exception as e:
+ return ('Unable to decode raw_secret and '
+ 'restore the seed value!\n\n\n'+str(e))
+
+ if ux_confirm:
+ # check master fingerprint from raw secret that is actually being loaded
+ # master extended public keys can be wrong & is unverified
+ xfp_str = xfp2str(swab32(node.my_fp()))
+ ch = await ux_show_story("Above is the master fingerprint of the seed stored in the backup."
+ " Press %s to continue, and load backup as %s seed. Press %s"
+ " to abort." % (OK, "temporary" if temporary else "master", X),
+ title="["+xfp_str+"]")
+ if ch != "y":
+ await ux_dramatic_pause('Aborted.', 2)
+ return
# this leads to reboot if it works, else errors shown, etc.
if temporary:
- return await restore_tmp_from_dict_ll(vals)
+ return await restore_tmp_from_dict_ll(vals, raw)
else:
- return await restore_from_dict(vals)
+ return await restore_from_dict(vals, raw)
async def clone_start(*a):
# Begins cloning process, on target device.
@@ -701,8 +776,9 @@ def delme(xfn):
uos.remove(fname) # ccbk-start.json
# this will reset in successful case, no return (but delme is called)
- prob = await restore_complete_doit(incoming, words, file_cleanup=delme)
-
+ # no need to ask for UX confirmation during clone - as user can see what is loaded on source CC
+ prob = await restore_complete_doit(incoming, words, file_cleanup=delme,
+ ux_confirm=False)
if prob:
await ux_show_story(prob, title='FAILED')
@@ -742,11 +818,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/bbqr.py b/shared/bbqr.py
index 61a90fb9d..c684e0b86 100644
--- a/shared/bbqr.py
+++ b/shared/bbqr.py
@@ -6,12 +6,14 @@
from utils import problem_file_line
from exceptions import QRDecodeExplained
from ubinascii import unhexlify as a2b_hex
+from version import MAX_TXN_LEN
b32encode = ngu.codecs.b32_encode
b32decode = ngu.codecs.b32_decode
TYPE_LABELS = dict(P='PSBT File', T='Transaction', J='JSON', C='CBOR', U='Unicode Text',
- X='Executable', B='Binary')
+ X='Executable', B='Binary',
+ R='KT Rx', S='KT Tx', E='KT PSBT')
def int2base36(n):
# convert an integer to two digits of base 36 string. 00 thu ZZ as bytes
@@ -212,7 +214,7 @@ def collect(self, scan):
# can happen if QR got corrupted between scanner and us (overlap)
# or back BBQr implementation
#print("corrupt QR: %s" % scan)
- import sys; sys.print_exception(exc)
+ # import sys; sys.print_exception(exc)
dis.draw_bbqr_progress(hdr, self.parts, corrupt=True)
return True
@@ -241,7 +243,7 @@ def collect(self, scan):
# provide UX -- even if we didn't use it
dis.draw_bbqr_progress(hdr, self.parts)
- # do we need more still?
+ # return T if we need more parts still
return (len(self.parts) < hdr.num_parts) or self.runt
class BBQrStorage:
@@ -328,14 +330,12 @@ def __init__(self):
def alloc_buf(self, upper_bound):
# using first part of PSRAM
- from public_constants import MAX_TXN_LEN_MK4
-
- if upper_bound >= MAX_TXN_LEN_MK4:
+ if upper_bound >= MAX_TXN_LEN:
raise QRDecodeExplained("Too big")
# If data is compressed, write tmp (compressed) copy into top half of PSRAM
# and we'll put final, decompressed copy at zero offset (later)
- self.psr_offset = MAX_TXN_LEN_MK4 if self.hdr.encoding == 'Z' else 0
+ self.psr_offset = MAX_TXN_LEN if self.hdr.encoding == 'Z' else 0
self.buf = True
@@ -394,7 +394,6 @@ def zlib_decompress(self):
from glob import PSRAM, dis
from uzlib import DecompIO
from io import BytesIO
- from public_constants import MAX_TXN_LEN_MK4
dis.fullscreen('Decompressing...')
@@ -414,7 +413,7 @@ def zlib_decompress(self):
buf += here
ln = len(buf) & ~3
- if off+ln > MAX_TXN_LEN_MK4:
+ if off+ln > MAX_TXN_LEN:
# test with: `yes | dd bs=1000 count=2700 | bbqr make - | pbcopy`
raise QRDecodeExplained("Too big")
diff --git a/shared/bsms.py b/shared/bsms.py
new file mode 100644
index 000000000..2e788fc44
--- /dev/null
+++ b/shared/bsms.py
@@ -0,0 +1,1062 @@
+
+# (c) Copyright 2022 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# bsms.py - Bitcoin Secure Multisig Setup: BIP-129
+#
+# For faster testing...
+# ./simulator.py --seq 99y3y4y
+#
+import ngu, os, stash, chains, aes256ctr, version
+from ubinascii import b2a_base64, a2b_base64
+from ubinascii import unhexlify as a2b_hex
+from ubinascii import hexlify as b2a_hex
+
+from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_CLASSIC, MAX_SIGNERS
+from utils import xfp2str, problem_file_line
+from menu import MenuSystem, MenuItem
+from files import CardSlot, CardMissingError, needs_microsd
+from ux import ux_show_story, ux_enter_number, restore_menu, ux_input_text
+from ux import the_ux, _import_prompt_builder, export_prompt_builder
+from descriptor import Descriptor, Key, append_checksum
+from miniscript import Sortedmulti, Number
+from charcodes import KEY_NFC, KEY_QR
+
+
+BSMS_VERSION = "BSMS 1.0"
+ALLOWED_PATH_RESTRICTIONS = "/0/*,/1/*"
+
+ENCRYPTION_TYPES = {
+ "1": "STANDARD",
+ "2": "EXTENDED",
+ "3": "NO ENCRYPTION"
+}
+
+class RejectAutoCollection(BaseException):
+ pass
+
+class BSMSOutOfSpace(RuntimeError):
+ # should not be a concern on Mk4 and later; just in case, handle well.
+ pass
+
+def exceptions_handler(f):
+ nice_name = " ".join(f.__name__.split("_")).replace("bsms", "BSMS")
+ async def new_func(*args):
+ try:
+ await f(*args)
+ except BaseException as e:
+ await ux_show_story(title="FAILURE", msg='%s\n\n%s failed\n%s' % (e, nice_name, problem_file_line(e)))
+ return new_func
+
+
+def normalize_token(token_hex):
+ if token_hex[:2] in ["0x", "0X"]:
+ token_hex = token_hex[2:] # remove 0x prefix
+ return token_hex
+
+
+def validate_token(token_hex):
+ if token_hex == "00":
+ return
+ try:
+ int(token_hex, 16)
+ except:
+ raise ValueError("Invalid token: %s" % token_hex)
+ if len(token_hex) not in [16, 32]:
+ raise ValueError("Invalid token length. Expected 64 or 128 bits (16 or 32 hex characters)")
+
+
+def key_derivation_function(token_hex):
+ if token_hex == "00":
+ return
+ return ngu.hash.pbkdf2_sha512("No SPOF", a2b_hex(token_hex), 2048)[:32]
+
+
+def hmac_key(key):
+ return ngu.hash.sha256s(key)
+
+
+def msg_auth_code(key, token_hex, data):
+ msg_str = token_hex + data
+ msg_bytes = bytes(msg_str, "utf-8")
+ return ngu.hmac.hmac_sha256(key, msg_bytes)
+
+
+def bsms_decrypt(key, data_bytes):
+ mac, ciphertext = data_bytes[:32], data_bytes[32:]
+ iv = mac[:16]
+ decrypt = aes256ctr.new(key, iv)
+ decrypted = decrypt.cipher(ciphertext)
+ try:
+ plaintext = decrypted.decode()
+ if not plaintext.startswith("BSMS"):
+ raise ValueError
+ return plaintext
+ except:
+ # failed decryption
+ return ""
+
+
+def bsms_encrypt(key, token_hex, data_str):
+ hmac_k = hmac_key(key)
+ mac = msg_auth_code(hmac_k, token_hex, data_str)
+ iv = mac[:16]
+ encrypt = aes256ctr.new(key, iv)
+ ciphertext = encrypt.cipher(data_str)
+
+ return mac + ciphertext
+
+
+def signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=None):
+ result = "%s\n" % BSMS_VERSION
+ result += "%s\n" % token_hex
+ result += "%s\n" % desc_type_key
+ result += "%s" % key_description
+
+ if sig_bytes:
+ sig = b2a_base64(sig_bytes).decode().strip()
+ result += "\n" + sig
+
+ return result
+
+
+def coordinator_data_round2(desc_template, addr, path_restrictions=ALLOWED_PATH_RESTRICTIONS):
+ result = "%s\n" % BSMS_VERSION
+ result += "%s\n" % desc_template
+ result += "%s\n" % path_restrictions
+ result += "%s" % addr
+
+ return result
+
+
+def token_summary(tokens):
+ if len(tokens) == 1:
+ return tokens[0]
+
+ numbered_tokens = ["%d. %s" % (i, token) for i, token in enumerate(tokens, start=1)]
+ return "\n\n".join(numbered_tokens)
+
+
+def coordinator_summary(M, N, addr_fmt, et, tokens):
+ addr_fmt_str = "p2wsh" if addr_fmt == AF_P2WSH else "p2sh-p2wsh"
+ summary = "%d of %d\n\n" % (M, N)
+ summary += "Address format:\n%s\n\n" % addr_fmt_str
+ summary += "Encryption type:\n%s\n\n" % ENCRYPTION_TYPES[et]
+
+ if tokens:
+ summary += "Tokens:\n" + token_summary(tokens) + "\n\n"
+
+ return summary
+
+
+class BSMSSettings:
+ # keys in settings object
+ BSMS_SETTINGS = "bsms"
+ BSMS_SIGNER_SETTINGS = "s"
+ BSMS_COORD_SETTINGS = "c"
+
+ @classmethod
+ def save(cls, updated_settings, orig):
+ try:
+ updated_settings.save()
+ except:
+ # back out change; no longer sure of NVRAM state
+ try:
+ updated_settings.set(cls.BSMS_SETTINGS, orig)
+ updated_settings.save()
+ except:
+ pass # give up on recovery
+ raise BSMSOutOfSpace
+
+ @classmethod
+ def add(cls, who, value):
+ from glob import settings
+
+ settings_bsms = settings.get(cls.BSMS_SETTINGS, {})
+ orig = settings_bsms.copy()
+ if who in settings_bsms:
+ settings_bsms[who].append(value)
+ else:
+ settings_bsms[who] = [value]
+
+ settings.set(cls.BSMS_SETTINGS, settings_bsms)
+ cls.save(settings, orig)
+
+ @classmethod
+ def delete(cls, who, index):
+ from glob import settings
+
+ settings_bsms = settings.get(cls.BSMS_SETTINGS, {})
+ orig = settings_bsms.copy()
+ if who in settings_bsms:
+ try:
+ settings_bsms[who].pop(index)
+ settings.set(cls.BSMS_SETTINGS, settings_bsms)
+ cls.save(settings, orig)
+ except IndexError:
+ pass
+
+ @classmethod
+ def signer_add(cls, token_hex):
+ cls.add(cls.BSMS_SIGNER_SETTINGS, token_hex)
+
+ @classmethod
+ def coordinator_add(cls, config_tuple):
+ cls.add(cls.BSMS_COORD_SETTINGS, config_tuple)
+
+ @classmethod
+ def signer_delete(cls, index):
+ cls.delete(cls.BSMS_SIGNER_SETTINGS, index)
+
+ @classmethod
+ def coordinator_delete(cls, index):
+ cls.delete(cls.BSMS_COORD_SETTINGS, index)
+
+ @classmethod
+ def get(cls):
+ from glob import settings
+ return settings.get(cls.BSMS_SETTINGS, {})
+
+ @classmethod
+ def get_signers(cls):
+ bsms = cls.get()
+ return bsms.get(cls.BSMS_SIGNER_SETTINGS, [])
+
+ @classmethod
+ def get_coordinators(cls):
+ bsms = cls.get()
+ return bsms.get(cls.BSMS_COORD_SETTINGS, [])
+
+
+class BSMSMenu(MenuSystem):
+ @classmethod
+ def construct(cls):
+ raise NotImplementedError
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+
+async def user_delete_signer_settings(menu, label, item):
+ index = item.arg
+ BSMSSettings.signer_delete(index)
+ the_ux.pop()
+ restore_menu()
+
+async def bsms_signer_detail(menu, label, item):
+ token_hex = BSMSSettings.get_signers()[item.arg]
+ # shoulf not raise here, as token is only saved if properly validated
+ token_dec = str(int(token_hex, 16))
+ await ux_show_story("Token HEX:\n%s\n\nToken decimal:\n%s" % (token_hex, token_dec))
+
+
+async def bsms_coordinator_detail(menu, label, item):
+ M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[item.arg]
+ summary = coordinator_summary(M, N, addr_fmt, et, tokens)
+ await ux_show_story(title="SUMMARY", msg=summary)
+
+
+async def make_bsms_signer_r2_menu(menu, label, item):
+ index = item.arg
+ rv = [
+ MenuItem('Round 2', f=bsms_signer_round2, arg=index),
+ MenuItem('Detail', f=bsms_signer_detail, arg=index),
+ MenuItem('Delete', f=user_delete_signer_settings, arg=index),
+ ]
+ return rv
+
+
+class BSMSSignerMenu(BSMSMenu):
+ @classmethod
+ def construct(cls):
+ # Dynamic
+ rv = []
+ signers = BSMSSettings.get_signers()
+ if signers:
+ for i, token_hex in enumerate(signers):
+ label = "%d %s" % (i+1, token_hex[:4])
+ rv.append(MenuItem('%s' % label, menu=make_bsms_signer_r2_menu, arg=i))
+ rv.append(MenuItem('Round 1', f=bsms_signer_round1))
+
+ return rv
+
+
+async def user_delete_coordinator_settings(menu, label, item):
+ index = item.arg
+ BSMSSettings.coordinator_delete(index)
+ the_ux.pop()
+ restore_menu()
+
+
+async def make_bsms_coord_r2_menu(menu, label, item):
+ index = item.arg
+ rv = [
+ MenuItem('Round 2', f=bsms_coordinator_round2, arg=index),
+ MenuItem('Detail', f=bsms_coordinator_detail, arg=index),
+ MenuItem('Delete', f=user_delete_coordinator_settings, arg=index),
+ ]
+ return rv
+
+
+class BSMSCoordinatorMenu(BSMSMenu):
+ @classmethod
+ def construct(cls):
+ # Dynamic
+ rv = []
+ coordinators = BSMSSettings.get_coordinators()
+ if coordinators:
+ for i, (M, N, addr_fmt, et, tokens) in enumerate(coordinators):
+ # only p2wsh and p2sh-p2wsh are allowed
+ if addr_fmt == AF_P2WSH:
+ af_str = "native"
+ else:
+ af_str = "nested"
+ label = "%d %dof%d_%s_%s" % (i+1, M, N, af_str, et)
+ rv.append(MenuItem('%s' % label, menu=make_bsms_coord_r2_menu, arg=i))
+ rv.append(MenuItem('Create BSMS', f=bsms_coordinator_start))
+
+ return rv
+
+
+async def make_ms_wallet_bsms_menu(menu, label, item):
+ from pincodes import pa
+
+ if pa.is_secret_blank():
+ await ux_show_story("You must have wallet seed before creating multisig wallets.")
+ return
+
+ await ux_show_story(
+"Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets. "
+"On the next screen you choose your role in this process.\n\n"
+"WARNING: BSMS is an EXPERIMENTAL and BETA feature which requires supporting implementations "
+"on other signing devices to work properly. Please test the final wallet carefully "
+"and report any problems to appropriate vendor. Deposit only small test amounts and verify "
+"all co-signers can sign transactions before use.")
+ rv = [
+ MenuItem('Signer', menu=make_bsms_signer_menu),
+ MenuItem('Coordinator', menu=make_bsms_coordinator_menu),
+ ]
+ return rv
+
+
+async def make_bsms_signer_menu(menu, label, item):
+ rv = BSMSSignerMenu.construct()
+ return BSMSSignerMenu(rv)
+
+
+async def make_bsms_coordinator_menu(menu, label, item):
+ rv = BSMSCoordinatorMenu.construct()
+ return BSMSCoordinatorMenu(rv)
+
+
+async def decrypt_nfc_data(key, data):
+ try:
+ data_bytes = a2b_hex(data)
+ data = bsms_decrypt(key, data_bytes)
+ return data
+ except:
+ # will be offered another chance
+ return
+
+@exceptions_handler
+async def bsms_coordinator_start(*a):
+ from glob import NFC, dis, settings
+ xfp = xfp2str(settings.get('xfp', 0))
+ # M/N
+ N = await ux_enter_number('No. of signers?(N)', 15)
+ assert 2 <= N <= MAX_SIGNERS, "Number of co-signers must be 2-15"
+
+ M = await ux_enter_number("Threshold? (M)", 15)
+ assert 1 <= M <= N, "M cannot be bigger than N (N=%d)" % N
+
+ ch = await ux_show_story("Default address format is P2WSH.\n\n"
+ "Press (2) for P2SH-P2WSH instead.", escape='2')
+ if ch == 'y':
+ addr_fmt = AF_P2WSH
+ elif ch == '2':
+ addr_fmt = AF_P2WSH_P2SH
+ else:
+ return
+
+ while 1:
+ encryption_type = await ux_show_story(
+ "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED,"
+ " and (3) for no encryption", escape="123")
+
+ if encryption_type == 'x': return
+ if encryption_type in "123":
+ break
+
+ tokens = []
+ if encryption_type == "2":
+ dis.fullscreen('Generating...')
+ for i in range(N): # each signer different 16 bytes (128bits) nonce/token
+ tokens.append(b2a_hex(ngu.random.bytes(16)).decode())
+ dis.progress_bar_show(i / N)
+ elif encryption_type == "1":
+ tokens.append(b2a_hex(ngu.random.bytes(8)).decode()) # all signers same token
+
+ summary = coordinator_summary(M, N, addr_fmt, encryption_type, tokens)
+ summary += "Press OK to continue, or X to cancel"
+ ch = await ux_show_story(title="SUMMARY", msg=summary)
+ if ch != "y":
+ return
+
+ token_hex = "00" if not tokens else tokens[0]
+ ch = await ux_show_story("Press (1) to participate as co-signer in this BSMS "
+ "with current active key [%s] and token '%s'. "
+ "Press OK to continue normally." % (xfp, token_hex), escape="1")
+ export_tokens = tokens[:]
+ if ch == "1":
+ b4 = len(BSMSSettings.get_signers())
+ await bsms_signer_round1(token_hex)
+ current = BSMSSettings.get_signers()
+ if len(current) > b4 and token_hex in current:
+ if encryption_type == "2":
+ # remove 0th token from the list as we already used that for self
+ # we do not need this token for export, but still need to store it in settings
+ export_tokens = tokens[1:]
+
+ force_vdisk = False
+ title = "BSMS token file(s)"
+ prompt, escape = export_prompt_builder(title)
+ if tokens and prompt:
+ ch = await ux_show_story(prompt, escape=escape)
+ if ch == (KEY_NFC if version.has_qwerty else '3') and tokens:
+ force_vdisk = None
+ await NFC.share_text(token_summary(export_tokens))
+ elif ch == "2":
+ force_vdisk = True
+ elif ch == '1':
+ force_vdisk = False
+ else:
+ return
+
+ msg = "Success. Coordinator round 1 saved."
+ if tokens and force_vdisk is not None:
+ dis.fullscreen("Saving...")
+ f_pattern = "bsms"
+ f_names = []
+ try:
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ for i, token in enumerate(export_tokens, start=1):
+ f_name = "%s_%s.token" % (f_pattern, token[:4])
+ fname, nice = card.pick_filename(f_name)
+ with open(fname, 'wt') as fd:
+ fd.write(token)
+ f_names.append(nice)
+ dis.progress_bar_show(i / len(tokens))
+ except CardMissingError:
+ await needs_microsd()
+ return
+ except Exception as e:
+ await ux_show_story('Failed to write!\n\n\n' + str(e))
+ return
+ msg = '''%s written.\n\nFiles:\n\n%s''' % (title, "\n\n".join(f_names))
+
+ BSMSSettings.coordinator_add((M, N, addr_fmt, encryption_type, tokens))
+ await ux_show_story(msg)
+ restore_menu()
+
+
+async def nfc_import_signer_round1_data(N, tkm, et, get_token_func):
+ from glob import NFC
+
+ all_data = []
+ for i in range(N):
+ token = get_token_func(i)
+ for attempt in range(2):
+ prompt = "Share co-signer #%d round-1 data" % (i + 1)
+ if et == "2":
+ prompt += " for token starting with %s" % token[:4]
+ ch = await ux_show_story(prompt)
+ if ch != "y":
+ return
+
+ data = await NFC.read_bsms_data()
+ if et in "12":
+ encryption_key = key_derivation_function(token)
+ data = await decrypt_nfc_data(encryption_key, data)
+ if not data:
+ fail_msg = "Decryption failed for co-signer #%d" % (i + 1)
+ if et == "2":
+ fail_msg += " with token %s" % token[:4]
+ ch = await ux_show_story(
+ title="FAILURE",
+ msg=fail_msg + ". Try again?" if attempt == 0 else fail_msg) # second chance
+ if ch == "y" and attempt == 0:
+ continue
+ else:
+ return
+ tkm[token] = encryption_key
+
+ all_data.append(data)
+ break # exit "second chance" loop
+ return all_data
+
+@exceptions_handler
+async def bsms_coordinator_round2(menu, label, item):
+ import version as version_mod
+ from glob import NFC, dis
+ from actions import file_picker
+
+ bsms_settings_index = item.arg
+ chain = chains.current_chain()
+
+ force_vdisk = False
+
+ # this can be RAM intensive (max 15 F mapped to keys)
+ # => ((32 + 16) * 15) roughly (actually more with python overhead)
+ token_key_map = {}
+
+ # choose correct values based on label (index in coordinator bsms settings)
+ M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[bsms_settings_index]
+
+ def get_token(index):
+ if len(tokens) == 1 and et == "1":
+ token = tokens[0]
+ elif len(tokens) == N and et == "2":
+ token = tokens[index]
+ else:
+ token = "00"
+ return token
+
+ is_encrypted = et in "12" and tokens
+ suffix = ".dat" if is_encrypted else ".txt"
+ mode = "rb" if is_encrypted else "rt"
+ prompt, escape = _import_prompt_builder("co-signer round 1 files", False, False)
+ if prompt:
+ ch = await ux_show_story(prompt, escape=escape)
+ if ch == (KEY_NFC if version_mod.has_qwerty else '3'):
+ force_vdisk = None
+ r1_data = await nfc_import_signer_round1_data(N, token_key_map, et, get_token)
+ else:
+ if ch == "1":
+ force_vdisk = False
+ else:
+ force_vdisk = True
+
+ if force_vdisk is not None:
+ # auto-collection attempt
+ r1_data = []
+ try:
+ f_pattern = "bsms_sr1"
+ auto_msg = "Press OK to pick co-signer round 1 files manually, or press (1) to attempt auto-collection."
+ auto_msg += " For auto-collection to succeed all filenames have to start with '%s'" % f_pattern
+ auto_msg += " and end with extension '%s'." % suffix
+ if et == "2": # EXTENDED
+ auto_msg += (" In addition for EXTENDED encryption all files must contain first four characters of"
+ " respective token. For example '%s_af9f%s'." % (f_pattern, suffix))
+ elif et == "3": # NO_ENCRYPTION
+ auto_msg += (" In addition for NO ENCRYPTION cases, number of files with above mentioned"
+ " pattern and suffix must equal number of signers (N).")
+ auto_msg += " If above is not respected auto-collection fails and defaults to manual selection of files."
+ ch = await ux_show_story(auto_msg, escape="1")
+ if ch == "x": return # exit
+ if ch == "y": raise RejectAutoCollection
+ # try autodiscovery first - if failed - default to manual input
+ dis.fullscreen("Collecting...")
+ file_names = []
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ f_list = os.listdir(card.mountpt)
+ f_list_len = len(f_list)
+ for i, name in enumerate(f_list, start=1):
+ if not card.is_dir(name) and f_pattern in name and name.endswith(suffix):
+ file_names.append(name)
+ dis.progress_bar_show(i / f_list_len)
+ file_names_len = len(file_names)
+ dis.fullscreen("Validating...")
+ if et == "1":
+ # can have multiple of these files - we will try to decrypt all that
+ # have above pattern. Those that fail will be ignored and at the end
+ # we check if we have correct num of files (num==N)
+ token = get_token(0) # STANDARD encryption has just one token
+ encryption_key = key_derivation_function(token)
+ token_key_map[token] = encryption_key
+
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ for i, fname in enumerate(file_names, start=1):
+ with open(card.abs_path(fname), mode) as f:
+ data = f.read()
+ data = bsms_decrypt(encryption_key, data)
+ if not data:
+ continue
+
+ assert data.startswith("BSMS"), "Failure - not BSMS file?"
+ r1_data.append(data)
+ dis.progress_bar_show(i / file_names_len)
+
+ elif et == "2":
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ for i in range(N):
+ token = get_token(i)
+ for fname in file_names:
+ if token[:4] in fname:
+ with open(card.abs_path(fname), mode) as f:
+ data = f.read()
+ encryption_key = key_derivation_function(token)
+ data = bsms_decrypt(encryption_key, data)
+
+ assert data, "Failed to decrypt %s with token %s" % (fname, token)
+ assert data.startswith("BSMS"), "Failure - not BSMS file?"
+ token_key_map[token] = encryption_key
+ r1_data.append(data)
+
+ break
+ else:
+ assert False, "haven't find file for token %s" % token
+
+ dis.progress_bar_show(i / N)
+ else:
+ assert file_names_len == N, "Need same number of files (%d) as co-signers(N=%d)"\
+ % (file_names_len, N)
+
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ for i, fname in enumerate(file_names, start=1):
+ with open(card.abs_path(fname), mode) as f:
+ data = f.read()
+ assert data.startswith("BSMS"), "Failure - not BSMS file?"
+ r1_data.append(data)
+ dis.progress_bar_show(i / file_names_len)
+
+ assert len(r1_data) == N, "No. of signer round 1 data auto-collected "\
+ "does not equal number of signers (N)"
+ except BaseException as e:
+ if isinstance(e, RejectAutoCollection):
+ # raised when user manually chooses not to use auto-collection
+ msg_prefix = ""
+ else:
+ msg_prefix = "Auto-collection failed. Defaulting to manual selection of files. "
+
+ # iterate over N and prompt user to choose correct files
+ for i in range(N):
+ token = get_token(i)
+ f_pick_msg = msg_prefix
+ f_pick_msg += 'Select co-signer #%d file containing round 1 data' % (i + 1)
+ if et == "2":
+ f_pick_msg += " for token starting with %s" % token[:4]
+ f_pick_msg += '. File extension has to be "%s"' % suffix
+ for attempt in range(2): # two chances to succeed
+ await ux_show_story(f_pick_msg)
+ fn = await file_picker(suffix=suffix, min_size=220, max_size=500,
+ force_vdisk=force_vdisk)
+ if not fn: return
+
+ dis.fullscreen("Wait...")
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ dis.progress_bar_show(0.1)
+ with open(fn, mode) as fd:
+ data = fd.read()
+ dis.progress_bar_show(0.3)
+ if is_encrypted:
+ encryption_key = key_derivation_function(token)
+ dis.progress_bar_show(0.6)
+ data = bsms_decrypt(encryption_key, data)
+ if not data:
+ fail_msg = "Decryption failed for co-signer #%d" % (i + 1)
+ if et == "2":
+ fail_msg += " with token %s" % token[:4]
+ ch = await ux_show_story(title="FAILURE", msg=fail_msg +
+ (" Try again?" if attempt == 0 else fail_msg))
+
+ if ch == "y" and attempt == 0:
+ continue
+ else:
+ return
+
+ dis.progress_bar_show(0.9)
+ token_key_map[token] = encryption_key
+
+ r1_data.append(data)
+ dis.progress_bar_show(1)
+
+ break # break from "second chance loop"
+
+ if not r1_data:
+ return
+
+ keys = []
+ dis.fullscreen("Validating...")
+ for i, data in enumerate(r1_data):
+ # divided in the loop with number of in-loop occurences of 'dis.progress_bar_show' (currently 5)
+ i_div_N = (i+1) / N
+ token = get_token(i)
+ assert data.startswith(BSMS_VERSION), "Incompatible BSMS version. Need %s got %s" % (
+ BSMS_VERSION, data[:9]
+ )
+ version, tok, key_exp, description, sig = data.strip().split("\n")
+ assert tok == token, "Token mismatch saved %s, received from signer %s" % (token, tok)
+ key = Key.from_string(key_exp)
+ dis.progress_bar_show(i_div_N / 4)
+ msg = signer_data_round1(token, key_exp, description)
+ digest = chain.hash_message(msg.encode())
+ dis.progress_bar_show(i_div_N / 3)
+ _, recovered_pk = chains.verify_recover_pubkey(a2b_base64(sig), digest)
+ assert key.node.pubkey() == recovered_pk, "Recovered key from signature does not equal key provided. Wrong signature?"
+ dis.progress_bar_show(i_div_N / 2)
+ keys.append(key)
+ dis.progress_bar_show(i_div_N / 1)
+
+ dis.fullscreen("Generating...")
+ try:
+ dis.busy_bar(True)
+ miniscript = Sortedmulti(Number(M), *keys)
+ desc_obj = Descriptor(miniscript=miniscript, addr_fmt=addr_fmt)
+ desc = desc_obj.to_string(checksum=False)
+ desc = desc.replace("<0;1>/*", "**")
+ if not is_encrypted:
+ # append checksum for unencrypted BSMS
+ desc = append_checksum(desc)
+ # external address at index 0 -> 0/0
+ derived_desc = desc_obj.derive(0).derive(0)
+ addr = chain.render_address(derived_desc.script_pubkey())
+ # ==
+ r2_data = coordinator_data_round2(desc, addr)
+
+ finally:
+ dis.busy_bar(False)
+
+ force_vdisk = False
+ title = "BSMS descriptor template file(s)"
+ prompt, escape = export_prompt_builder(title)
+ if prompt:
+ ch = await ux_show_story(prompt, escape=escape)
+ if ch == (KEY_NFC if version_mod.has_qwerty else '3'):
+ if et == "2":
+ for i, token in enumerate(tokens):
+ ch = await ux_show_story("Exporting data for co-signer #%d with token %s"
+ % (i+1, token[:4]))
+ if ch != "y":
+ return
+ data = bsms_encrypt(token_key_map[token], token, r2_data)
+ await NFC.share_text(b2a_hex(data).decode())
+ elif et == "1":
+ token = get_token(0)
+ data = bsms_encrypt(token_key_map[token], token, r2_data)
+ await NFC.share_text(b2a_hex(data).decode())
+ else:
+ await NFC.share_text(r2_data)
+ await ux_show_story("All done.")
+ return
+ elif ch == "2":
+ force_vdisk = True
+ elif ch == '1':
+ force_vdisk = False
+ else:
+ return
+
+ def to_export_generator():
+ # save memory
+ if et == "3": # NO_ENCRYPTION
+ yield None, r2_data
+ elif et == "1": # STANDARD
+ token = get_token(0)
+ yield token, bsms_encrypt(token_key_map[token], token, r2_data)
+ else:
+ # EXTENDED
+ for token in tokens:
+ yield token, bsms_encrypt(token_key_map[token], token, r2_data)
+
+ dis.fullscreen("Saving...")
+ mode = "wb" if is_encrypted else "wt"
+ f_pattern = "bsms_cr2"
+ f_names = []
+ try:
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ for i, (token, data) in enumerate(to_export_generator(), start=1):
+ f_name = "%s%s%s" % (f_pattern, "_" + token[:4] if et == "2" else "", suffix)
+ fname, nice = card.pick_filename(f_name)
+ with open(fname, mode) as fd:
+ fd.write(data)
+ f_names.append(nice)
+ dis.progress_bar_show(i / (len(token_key_map) or 1))
+ except CardMissingError:
+ await needs_microsd()
+ return
+ except Exception as e:
+ await ux_show_story('Failed to write!\n\n\n' + str(e))
+ return
+ msg = '''%s written. Files:\n\n%s''' % (title, "\n\n".join(f_names))
+ await ux_show_story(msg)
+
+
+@exceptions_handler
+async def bsms_signer_round1(*a):
+ from glob import dis, NFC, VD, settings
+
+ shortcut = len(a) == 1
+ token_int = None
+ if not shortcut:
+ prompt = "Press (1) to import token file from SD Card, (2) to input token manually"
+ prompt += ", (3) for unencrypted BSMS."
+ escape = "123"
+ if version.has_qwerty:
+ prompt += "%s to scan QR. " % KEY_QR
+ escape += KEY_QR
+ if NFC is not None:
+ prompt += " %s to import via NFC" % (KEY_NFC if version.has_qwerty else "(4)")
+ escape += KEY_NFC if version.has_qwerty else "4"
+ if VD is not None:
+ prompt += ", (6) to import from Virtual Disk"
+ escape += "6"
+ prompt += "."
+
+ ch = await ux_show_story(prompt, escape=escape)
+
+ if ch == '3':
+ token_hex = "00"
+ elif ch in "4"+KEY_NFC:
+ token_hex = await NFC.read_bsms_token()
+ elif ch == "2":
+ prompt = "To input token as hex press (1), as decimal press (2)"
+ escape = "12"
+ ch = await ux_show_story(prompt, escape=escape)
+ if ch == "1":
+ token_hex = await ux_input_text("", hex_only=True, scan_ok=True,
+ prompt="Hex Token")
+ elif ch == "2":
+ if version.has_qwerty:
+ token_int = await ux_input_text("", scan_ok=True, prompt="Decimal Token")
+ else:
+ from ux_mk4 import ux_input_digits
+ token_int = await ux_input_digits("", prompt="Decimal Token")
+ token_hex = hex(int(token_int))
+ else:
+ return
+ elif ch in "16":
+ from actions import file_picker
+ force_vdisk = (ch == '6')
+
+ # pick a likely-looking file.
+ fn = await file_picker(suffix=".token", min_size=15, max_size=35,
+ force_vdisk=force_vdisk)
+ if not fn: return
+
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ with open(fn, 'rt') as fd:
+ token_hex = fd.read().strip()
+ else:
+ return
+ else:
+ token_hex = a[0]
+
+ # will raise, exc catched in decorator, FAILURE msg provided
+ validate_token(token_hex)
+ token_hex = normalize_token(token_hex)
+ is_extended = (len(token_hex) == 32)
+ entered_msg = "%s\n\nhex:\n%s" % (token_int, token_hex) if token_int else token_hex
+
+ if not shortcut:
+ ch = await ux_show_story("You have entered token:\n" + entered_msg + "\n\nIs token correct?")
+ if ch != "y":
+ return
+
+ xfp = xfp2str(settings.get('xfp', 0))
+ chain = chains.current_chain()
+ ch = await ux_show_story(
+"Choose co-signer address format for correct SLIP derivation path. Default is 'unknown' as this "
+"information may not be known at this point in BSMS. SLIP agnostic path will be chosen. "
+"Press (1) for P2WSH. Press (2) for P2SH-P2WSH. "
+"Correct SLIP path is completely unnecessary as descriptors (BIP-0380) are used.",
+ escape='12')
+ if ch == 'y':
+ pth_template = "m/129'/{coin}'/{acct_num}'"
+ af_str = ""
+ elif ch == '1':
+ pth_template = "m/48'/{coin}'/{acct_num}'/2'"
+ af_str = " P2WSH"
+ elif ch == '2':
+ pth_template = "m/48'/{coin}'/{acct_num}'/1'"
+ af_str = " P2SH-P2WSH"
+ else:
+ return
+
+ acct_num = await ux_enter_number('Account Number:', 9999) or 0
+
+ # textual key description
+ key_description = "Coldcard signer%s account %d" % (af_str, acct_num)
+ ch = await ux_show_story(
+"Choose key description. To continue with default, generated description: '%s' press OK."
+"\n\nPress (1) for custom key description." % key_description, escape="1")
+
+ if ch == "1":
+ key_description = await ux_input_text("", confirm_exit=False) or ""
+
+ key_description_len = len(key_description)
+ assert key_description_len <= 80, "Key Description: 80 char max (was %d)" % key_description_len
+
+ dis.fullscreen("Wait...")
+
+ with stash.SensitiveValues() as sv:
+ dis.progress_bar_show(0.1)
+
+ dd = pth_template.format(coin=chain.b44_cointype, acct_num=acct_num)
+ node = sv.derive_path(dd)
+ ext_key = chain.serialize_public(node)
+
+ dis.progress_bar_show(0.25)
+
+ desc_type_key = "[%s%s]%s" % (xfp, dd[1:], ext_key)
+ msg = signer_data_round1(token_hex, desc_type_key, key_description)
+ digest = chain.hash_message(msg.encode())
+ sk = node.privkey()
+ sv.register(sk)
+
+ dis.progress_bar_show(0.5)
+
+ sig = ngu.secp256k1.sign(sk, digest, 0).to_bytes()
+ result_data = signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=sig)
+
+ dis.progress_bar_show(.75)
+
+ encryption_key = key_derivation_function(token_hex)
+ if encryption_key:
+ result_data = bsms_encrypt(encryption_key, token_hex, result_data)
+
+ dis.progress_bar_show(1)
+
+ # export round 1 file
+ force_vdisk = False
+ title = "BSMS signer round 1 file"
+ prompt, escape = export_prompt_builder(title)
+ if prompt:
+ ch = await ux_show_story(prompt, escape=escape)
+ if ch == (KEY_NFC if version.has_qwerty else '3'):
+ force_vdisk = None
+ if isinstance(result_data, bytes):
+ result_data = b2a_hex(result_data).decode()
+ await NFC.share_text(result_data)
+ elif ch == "2":
+ force_vdisk = True
+ elif ch == '1':
+ force_vdisk = False
+ else:
+ return
+
+ msg = "Success. Signer round 1 saved."
+ if force_vdisk is not None:
+ basename = "bsms_sr1%s" % "_" + token_hex[:4] if is_extended else "bsms_sr1"
+ f_pattern = basename + ".txt" if encryption_key is None else basename + ".dat"
+ # choose a filename
+ try:
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ fname, nice = card.pick_filename(f_pattern)
+ with open(fname, 'wb') as fd:
+ if isinstance(result_data, str):
+ result_data = result_data.encode()
+ fd.write(result_data)
+ except CardMissingError:
+ await needs_microsd()
+ return
+ except Exception as e:
+ await ux_show_story('Failed to write!\n\n\n' + str(e))
+ return
+ msg = '''%s written:\n\n%s''' % (title, nice)
+ BSMSSettings.signer_add(token_hex)
+ await ux_show_story(msg)
+ if not shortcut:
+ restore_menu()
+
+
+@exceptions_handler
+async def bsms_signer_round2(menu, label, item):
+ import version
+ from glob import NFC, dis, settings
+ from actions import file_picker
+ from auth import maybe_enroll_xpub
+
+ chain = chains.current_chain()
+ force_vdisk = False
+
+ # choose correct values based on label (index in signer bsms settings)
+ bsms_settings_index = item.arg
+ token = BSMSSettings.get_signers()[bsms_settings_index]
+
+ decrypt_fail_msg = "Decryption with token %s failed." % token[:4]
+ is_encrypted = False if token == "00" else True
+ suffix = ".dat" if is_encrypted else ".txt"
+ mode = "rb" if is_encrypted else "rt"
+
+ prompt, escape = _import_prompt_builder("descriptor template file", False, False)
+ if prompt:
+ ch = await ux_show_story(prompt, escape=escape)
+
+ if ch == (KEY_NFC if version.has_qwerty else '3'):
+ force_vdisk = None
+ desc_template_data = await NFC.read_bsms_data()
+
+ if desc_template_data is None:
+ return
+
+ if is_encrypted:
+ data_bytes = a2b_hex(desc_template_data)
+ encryption_key = key_derivation_function(token)
+ desc_template_data = bsms_decrypt(encryption_key, data_bytes)
+ assert desc_template_data, decrypt_fail_msg
+ else:
+ if ch == "1":
+ force_vdisk = False
+ else:
+ force_vdisk = True
+
+ if force_vdisk is not None:
+ fn = await file_picker(suffix=suffix, min_size=200, max_size=10000,
+ force_vdisk=force_vdisk)
+ if not fn: return
+
+ with CardSlot(force_vdisk=force_vdisk) as card:
+ with open(fn, mode) as fd:
+ desc_template_data = fd.read()
+ if is_encrypted:
+ encryption_key = key_derivation_function(token)
+ desc_template_data = bsms_decrypt(encryption_key, desc_template_data)
+ assert desc_template_data, decrypt_fail_msg
+
+ dis.fullscreen("Validating...")
+ try:
+ dis.busy_bar(True)
+ assert desc_template_data.startswith(BSMS_VERSION), \
+ "Incompatible BSMS version. Need %s got %s" % (BSMS_VERSION, desc_template_data[:9])
+
+ version, desc_template, pth_restrictions, addr = desc_template_data.split("\n")
+ assert pth_restrictions == ALLOWED_PATH_RESTRICTIONS, \
+ "Only '%s' allowed as path restrictions. Got %s" % (
+ ALLOWED_PATH_RESTRICTIONS, pth_restrictions)
+
+ # if checksum is provided we better verify it before descriptor modification /**
+ # remove checksum as we need to replace /**
+ desc_template, csum = Descriptor.checksum_check(desc_template)
+ desc = desc_template.replace("/**", "/<0;1>/*")
+
+ desc_obj = Descriptor.from_string(desc)
+ desc_obj.validate()
+ assert desc_obj.is_sortedmulti, "sortedmulti required"
+
+ my_xfp = settings.get('xfp')
+ my_keys = 0
+
+ for key in desc_obj.keys:
+ if key.origin.cc_fp == my_xfp:
+ my_keys += 1
+
+ assert my_keys <= 1, "Multiple %s keys in descriptor (%d)" % (xfp2str(my_xfp), my_keys)
+
+ # check address is correct
+ calc_addr = chain.render_address(desc_obj.derive(0).derive(0).script_pubkey())
+ assert calc_addr == addr, "Address mismatch! Calculated %s, got %s" % (calc_addr, addr)
+
+ # name consists last 4 characters of the address at /0/0
+ ms_name = "bsms_" + addr[-4:]
+
+ try:
+ # at this point we have properly validated descriptor
+ maybe_enroll_xpub(desc_obj=desc_obj, name=ms_name, bsms_index=bsms_settings_index)
+ # bsms_settings_signer_delete(bsms_settings_index)
+ # moved to auth.py to only be done if actually approved
+ except Exception as e:
+ await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
+
+ finally:
+ dis.busy_bar(False)
+
+# EOF
\ No newline at end of file
diff --git a/shared/calc.py b/shared/calc.py
index f746e4a8e..3649634e1 100644
--- a/shared/calc.py
+++ b/shared/calc.py
@@ -1,6 +1,6 @@
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
-# calc.py - Simple python REPL before login
+# calc.py - Simple TOY calculator, before login. Not meant to be useful, just fun!
#
# Test with: ./simulator.py --q1 --eff -g --set calc=1
#
@@ -9,7 +9,7 @@
from ux_q1 import ux_input_text
async def login_repl():
- from glob import dis, settings
+ from glob import dis
from pincodes import pa
NUM_LINES = 7 # 10 - title - 2 for prompt
@@ -19,22 +19,25 @@ async def login_repl():
re_pin = re.compile(r'^(\d\d+)[-_ ](\d\d+)$')
# in decreasing order of hazard...
- blacklist = ['import', '__', 'exec', 'locals', 'globals', 'eval', 'input']
+ # - find these with: import builtins; help(builtins)
+ blacklist = ['import', '__', 'exec', 'locals', 'globals', 'eval', 'input',
+ 'getattr', 'setattr', 'delattr', 'open', 'execfile', 'compile' ]
lines = '''\
Example Commands:
>> 23 + 55 / 22
->> a = 4; b = 3;
->> a*b
->> sha256('123456123456')
->> cls() # clear screen\
+>> 1.020 * 45.88
+>> sha256('some message')
+>> cls # clear screen
+>> help\
'''.split('\n')
state = dict()
state['sha256'] = lambda x: B2A(ngu.hash.sha256s(x))
state['sha512'] = lambda x: B2A(ngu.hash.sha512(x).digest())
state['ripemd'] = lambda x: B2A(ngu.hash.ripemd160(x))
+ state['rand'] = lambda x=32: B2A(ngu.random.bytes(x))
state['cls'] = lambda: lines.clear()
state['help'] = lambda: 'Commands: ' + (', '.join(state))
@@ -56,17 +59,17 @@ async def login_repl():
try:
dis.busy_bar(1)
- if ln == None :
+ if ln is None :
# Cancel key - do nothing
ans = None
- elif ln in state and callable(state[ln]):
- # no needs for () in my world
+ elif ln in ('help', 'cls', 'rand'):
+ # no need for () for these commands
ans = state[ln]()
- elif re_pin.match(ln) and len(ln) <= 13:
+ elif pa.attempts_left and re_pin.match(ln) and (len(ln) <= 13):
# try login
m = re_pin.match(ln)
ln = m.group(1)+ '-' + m.group(2)
- print(ln)
+
try:
pa.setup(ln)
ok = pa.login()
@@ -80,16 +83,14 @@ async def login_repl():
else:
ans = 'Error: ' + repr(exc.args)
- elif re_prefix.match(ln) and len(ln) <= 7:
+ elif re_prefix.match(ln) and (len(ln) <= 7):
# show words
ans = pa.prefix_words(ln[:-1].encode())
else:
if any((b in ln) for b in blacklist):
ans = None
- elif '=' in ln:
- ans = exec(ln, state)
else:
- ans = eval(ln, state)
+ ans = eval(ln, state.copy())
except Exception as exc:
lines.extend(word_wrap(str(exc), 34))
diff --git a/shared/ccc.py b/shared/ccc.py
new file mode 100644
index 000000000..644edaff9
--- /dev/null
+++ b/shared/ccc.py
@@ -0,0 +1,1285 @@
+# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# ccc.py - ColdCard Co-sign feature. Be a leg in a 2-of-3 that is signed based on a policy.
+#
+# Rebranding/single-signer additions:
+#
+# - "CCC" (was "ColdCard Cosigning") will now be branded as "Spending Policy: Multisig"
+# - single singer policies will be called "Spending Policy: Single Sig"
+# - internally: CCC is the multisig stuff, vs SSSP: Single Signer Spending Policy
+# - "hobbled" refers to less-than full control over Coldcard, even though you have main PIN
+#
+import gc, chains, version, ngu, web2fa, bip39, re
+from chains import NLOCK_IS_TIME
+from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
+from glob import settings, dis
+from ux import ux_confirm, ux_show_story, the_ux, OK, ux_dramatic_pause, ux_enter_number, ux_aborted
+from menu import MenuSystem, MenuItem, start_chooser
+from seed import seed_words_to_encoded_secret
+from stash import SecretStash
+from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC
+from exceptions import SpendPolicyViolation
+
+
+# limit to number of addresses in list
+MAX_WHITELIST = const(25)
+
+class LastFailReason:
+ # We don't show the user the reason for policy fail (by design, so attacker
+ # cannot maximize their take against the policy), but during setup/experiments
+ # we offer to show the reason in the menu. Includes both SS and MS cases.
+ # - now holding this in a setting so they can power-cycle and bypass to view
+
+ @classmethod
+ def record(cls, msg):
+ settings.put('lfr', msg)
+
+ @classmethod
+ def get(cls):
+ return settings.get('lfr', None)
+
+ @classmethod
+ def clear(cls):
+ settings.remove_key('lfr')
+
+class SpendingPolicy(dict):
+ # Details of what is allowed or not. Same for single vs. multisig signing.
+ # - a dict() but with write-thru to setting value
+
+ def __init__(self, nvkey, pol_dict=None):
+ # deserialize and construct
+ #assert nvkey in { 'ccc', 'sssp' }
+ self.nvkey = nvkey
+ super().__init__()
+
+ if pol_dict is not None:
+ self.clear()
+ self.update(pol_dict.items())
+ else:
+ v = dict(settings.master_get(self.nvkey, {})).get('pol', None)
+ if v is not None:
+ self.update(v.items()) # mpy bugfix, when called with SpendingPolicy
+
+
+ def _save_policy(self):
+ # serialize the spending policy, save it
+ v = dict(settings.master_get(self.nvkey, {}))
+ v['pol'] = self.copy()
+ settings.master_set(self.nvkey, v, master_only=True)
+
+ def update_policy_key(self, _quiet=False, **kws):
+ # Update a few elements of the spending policy
+ # - all changes are saved immediately (which is a little slow/visible)
+ if not _quiet:
+ dis.fullscreen("Saving...")
+ self.update(kws)
+ self._save_policy()
+
+ def meets_policy(self, psbt):
+ # Does policy allow signing this? Else raise why. Return T if web2fa required.
+ pol = self
+
+ # not safe to sign any txn w/ warnings: might be complaining about
+ # massive miner fees, or weird OP_RETURN stuff
+ if psbt.warnings:
+ raise SpendPolicyViolation("has warnings")
+
+ # Magnitude: size limits for output side (non change)
+ magnitude = pol.get("mag", None)
+ if magnitude is not None:
+ if magnitude < 1000:
+ # it is a BTC, convert to sats
+ magnitude = magnitude * 100000000
+
+ outgoing = psbt.total_value_out - psbt.total_change_value
+ if outgoing > magnitude:
+ raise SpendPolicyViolation("magnitude")
+
+ # Velocity: if zero => no velocity checks
+ velocity = pol.get("vel", None)
+ if velocity:
+ if not psbt.lock_time:
+ raise SpendPolicyViolation("no nLockTime")
+
+ if psbt.lock_time >= NLOCK_IS_TIME:
+ # this is unix timestamp - not allowed - fail
+ raise SpendPolicyViolation("nLockTime not height")
+
+ block_h = pol.get("block_h", chains.current_chain().ccc_min_block)
+ if psbt.lock_time <= block_h:
+ raise SpendPolicyViolation("rewound (%d)" % psbt.lock_time)
+
+ # we won't sign txn unless old height + velocity >= new height
+ if psbt.lock_time < (block_h + velocity):
+ raise SpendPolicyViolation("velocity (%d)" % psbt.lock_time)
+
+ # Whitelist of outputs addresses
+ wl = pol.get("addrs", None)
+ if wl:
+ c = chains.current_chain()
+ wl = set(wl)
+ for idx, txo in psbt.output_iter():
+ out = psbt.outputs[idx]
+ if not out.is_change: # ignore change
+ addr = c.render_address(txo.scriptPubKey)
+ if addr not in wl:
+ raise SpendPolicyViolation("whitelist: " + addr)
+
+ # Web 2FA
+ # - slow, requires UX, and they might not achieve it...
+ # - wait until about to do signature
+ if pol.get('web2fa', False):
+ psbt.warnings.append((pol.nvkey.upper(), 'Web 2FA required.'))
+ return True
+
+ async def web2fa_challenge(self, msg):
+ # they are trying to sign something, so make them get out their phone
+ # - at this point they have already ok'ed the details of the txn
+ # - and we have approved other elements of the spending policy.
+ # - could show MS wallet name, or txn details but will not because that is
+ # an info leak to Coinkite... and we just don't want to know.
+ assert self.get('web2fa')
+
+ ok = await web2fa.perform_web2fa(msg, self.get('web2fa'))
+ if not ok:
+ LastFailReason.record('2FA Fail')
+ raise SpendPolicyViolation
+
+ def update_last_signed(self, psbt):
+ # Call after successfully signing a PSBT ... notes the height involved.
+ # - might add other things besides height here someday
+ LastFailReason.clear()
+
+ old_h = self.get('block_h', 1)
+
+ if old_h < psbt.lock_time < NLOCK_IS_TIME:
+ # always update last block height, even if velocity isn't enabled yet
+ # - attacker might have changed to testnet, but there is no
+ # reason to ever lower block height. strictly ascending
+ self.update_policy_key(_quiet=True, block_h=psbt.lock_time)
+
+class SSSPFeature:
+ # Using setting value "sssp"
+
+ @classmethod
+ def is_enabled(cls):
+ # can be test drive, or is feature enabled?
+ from pincodes import pa
+ return (pa.hobbled_mode == 2) or sssp_spending_policy('en')
+
+ @classmethod
+ def update_last_signed(cls, psbt):
+ # new PSBT has been completely signed successfully.
+ if not cls.is_enabled():
+ return
+ pol = cls.get_policy()
+ pol.update_last_signed(psbt)
+
+ @classmethod
+ def default_policy(cls):
+ # a very basic and permissive policy, but non-zero too.
+ # - 1BTC per day
+ chain = chains.current_chain()
+ return SpendingPolicy('sssp', dict(mag=1, vel=144,
+ block_h=chain.ccc_min_block, web2fa='', addrs=[]))
+
+ @classmethod
+ def get_policy(cls):
+ # de-serialize just the spending policy
+ return SpendingPolicy('sssp')
+
+ @classmethod
+ def can_allow(cls, psbt):
+ # We are looking at a PSBT: should we let user sign it, or block?
+ # - return (block_signing, needs_2fa_step)
+ if not cls.is_enabled():
+ exists = bool(settings.master_get('sssp', False))
+ if exists:
+ # this will not block CCC co-signing, because that test is already
+ # done before this call.
+ psbt.warnings.append(('SP', "Spending Policy defined but disabled."))
+ return False, False
+
+ try:
+ # check policy
+ pol = cls.get_policy()
+ needs_2fa = pol.meets_policy(psbt)
+ except SpendPolicyViolation as e:
+ LastFailReason.record(str(e))
+ # caller will show msg
+ return True, False
+
+ return False, needs_2fa
+
+ @classmethod
+ async def web2fa_challenge(cls):
+ # they are trying to sign something, so make them get out their phone
+ # - at this point they have already ok'ed the details of the txn
+ # - and we have approved other elements of the spending policy.
+ # - could show MS wallet name, or txn details but will not because that is
+ # an info leak to Coinkite... and we just don't want to know.
+ await cls.get_policy().web2fa_challenge('Approve Transaction')
+
+
+class CCCFeature:
+ # Using setting value "ccc"
+
+ @classmethod
+ def is_enabled(cls):
+ # Is the feature enabled right now?
+ return bool(settings.get('ccc', False))
+
+ @classmethod
+ def words_check(cls, words):
+ # Test if words provided are right
+ enc = seed_words_to_encoded_secret(words)
+ exp = cls.get_encoded_secret()
+ return enc == exp
+
+ @classmethod
+ def get_num_words(cls):
+ # return 12 or 24
+ return SecretStash.is_words(cls.get_encoded_secret())
+
+ @classmethod
+ def get_encoded_secret(cls):
+ # Gets the key C as encoded binary secret, compatible w/
+ # encodings used in stash.
+ return deserialize_secret(settings.get('ccc')['secret'])
+
+ @classmethod
+ def get_xfp(cls):
+ # Just the XFP value for our key C
+ ccc = settings.get('ccc')
+ return ccc['c_xfp'] if ccc else None
+
+ @classmethod
+ def get_master_xpub(cls):
+ ccc = settings.get('ccc')
+ return ccc['c_xpub'] if ccc else None
+
+ @classmethod
+ def init_setup(cls, words):
+ # Encode 12 or 24 words into the secret to held as key C.
+ # - also capture XFP and XPUB for key C
+ # TODO: move to "storage locker"?
+ assert len(words) in (12, 24)
+ enc = seed_words_to_encoded_secret(words)
+ _,_,node = SecretStash.decode(enc)
+
+ chain = chains.current_chain()
+ xfp = swab32(node.my_fp())
+ xpub = chain.serialize_public(node) # fully useless value tho
+
+ # NOTE: b_xfp and b_xpub still needed, but that's another step, not yet.
+
+ v = dict(secret=SecretStash.storage_serialize(enc),
+ c_xfp=xfp, c_xpub=xpub,
+ pol=CCCFeature.default_policy())
+
+ settings.put('ccc', v)
+ settings.save()
+
+ @classmethod
+ def default_policy(cls):
+ # a very basic and permissive policy, but non-zero too.
+ # - 1BTC per day
+ chain = chains.current_chain()
+ return SpendingPolicy('ccc', dict(mag=1, vel=144,
+ block_h=chain.ccc_min_block, web2fa='', addrs=[]))
+
+ @classmethod
+ def get_policy(cls):
+ # de-serialize just the spending policy
+ return SpendingPolicy('ccc')
+
+ @classmethod
+ def remove_ccc(cls):
+ # delete our settings complete; lose key C .. already confirmed
+ # - leave MS in place
+ settings.remove_key('ccc')
+ settings.save()
+
+ @classmethod
+ def could_cosign(cls, psbt):
+ # We are looking at a PSBT: can we sign it, and would we?
+ # - if we **could** but will not, due to policy, add warning msg
+ # - return (we could sign, needs 2fa step)
+ if not cls.is_enabled():
+ return False, False
+
+ ms = psbt.active_miniscript
+ if not ms:
+ # not multisig, so ignore/permit
+ return False, False
+
+ # TODO: if key B has already signed the PSBT, and so we don't need key C,
+ # don't try to sign; maybe show warning?
+
+ xfp = cls.get_xfp()
+ if xfp not in [i[0] for i in ms.to_descriptor().xfp_paths()]:
+ # does not involve us
+ return False, False
+
+ try:
+ # check policy
+ pol = cls.get_policy()
+ needs_2fa = pol.meets_policy(psbt)
+ except SpendPolicyViolation as e:
+ LastFailReason.record(str(e))
+ psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
+ return False, False
+
+ return True, needs_2fa
+
+ @classmethod
+ def sign_psbt(cls, psbt):
+ # do the math
+ psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp())
+ LastFailReason.clear()
+
+ pol = cls.get_policy()
+ pol.update_last_signed(psbt)
+
+ @classmethod
+ async def web2fa_challenge(cls):
+ # do UX for web2fa; user is given option to proceed even if it fails
+ # (without the co-signing)
+ await cls.get_policy().web2fa_challenge('Approve Transaction: Co-Sign')
+
+
+def render_mag_value(mag):
+ # handle integer bitcoins, and satoshis in same value
+ if mag < 1000:
+ return '%d BTC' % mag
+ else:
+ return '%d SATS' % mag
+
+
+class CCCConfigMenu(MenuSystem):
+ def __init__(self):
+ items = self.construct()
+ super().__init__(items)
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+ def construct(self):
+ from wallet import MiniScriptWallet, make_miniscript_wallet_menu
+
+ my_xfp = CCCFeature.get_xfp()
+ items = [
+ MenuItem(('[%s] Co-Signing' if version.has_qwerty else '[%s]')
+ % xfp2str(my_xfp), f=self.show_ident),
+ MenuItem('Spending Policy',
+ menu=lambda *a: SpendingPolicyMenu.be_a_submenu(CCCFeature.get_policy())),
+ MenuItem('Export CCC XPUBs', f=self.export_xpub_c),
+ MenuItem('Multisig Wallets'),
+ ]
+
+ # look for wallets that are defined related to CCC feature, shortcut to them
+ count = 0
+ for i, ms in enumerate(MiniScriptWallet.iter_wallets()):
+ if not ms.m_n: # basic multisig check
+ continue
+ if my_xfp in [i[0] for i in ms.xfp_paths()]:
+ M, N = ms.m_n
+ items.append(MenuItem('↳ %d/%d: %s' % (M, N, ms.name),
+ menu=make_miniscript_wallet_menu, arg=(i,ms)))
+ count += 1
+
+ items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
+
+ if LastFailReason.get():
+ # xxxxxxxxxxxxxxxx
+ items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
+
+ items.append(MenuItem('Load Key C', f=self.enter_temp_mode))
+ items.append(MenuItem('Remove CCC', f=self.remove_ccc))
+
+ return items
+
+ async def debug_last_fail(self, *a):
+ # debug for customers: why did we reject that last txn?
+ pol = CCCFeature.get_policy()
+ bh = pol.get('block_h', None)
+ msg = ''
+ if bh:
+ msg += "CCC height:\n\n%s\n\n" % bh
+
+ lfr = LastFailReason.get()
+ msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
+ % lfr
+ ch = await ux_show_story(msg, escape='4')
+
+ if ch == '4':
+ LastFailReason.clear()
+ self.update_contents()
+
+ async def remove_ccc(self, *a):
+ # disable and remove feature
+ if not await ux_confirm('Key C will be lost, and policy settings forgotten.'
+ ' This unit will only be able to partly sign transactions.'
+ ' To completely remove this wallet, proceed to the multisig'
+ ' menu and remove related wallet entries.'):
+ return
+
+ if not await ux_confirm("Funds in related wallet/s may be impacted.", confirm_key='4'):
+ return await ux_aborted()
+
+ CCCFeature.remove_ccc()
+ the_ux.pop()
+
+ async def on_cancel(self):
+ # trying to exit from CCCConfigMenu
+ from seed import in_seed_vault
+
+ enc = CCCFeature.get_encoded_secret()
+
+ if in_seed_vault(enc):
+ # remind them to clear the seed-vault copy of Key C because it defeats feature
+ await ux_show_story("Key C is in your Seed Vault. If you are done with setup, "
+ "you MUST delete it from the Vault!", title='REMINDER')
+
+ the_ux.pop()
+
+ async def export_xpub_c(self, *a):
+ # do standard Coldcard export for multisig setups
+ xfp = CCCFeature.get_xfp()
+ enc = CCCFeature.get_encoded_secret()
+
+ from wallet import export_miniscript_xpubs
+ await export_miniscript_xpubs(xfp=xfp, alt_secret=enc, skip_prompt=True)
+
+ async def build_2ofN(self, m, l, i):
+ count = i.arg
+ # ask for a key B, assume A and C are defined => export MS config and import into self.
+ # - like the airgap setup, but assume A and C are this Coldcard
+ m = '''Builds simple 2-of-N multisig wallet, with this Coldcard's main secret (key A), \
+the CCC policy-controlled key C, and at least one other device, as key B. \
+\nYou will need to export the XPUB from another Coldcard and place it on an SD Card, or \
+be ready to show it as a QR, before proceeding.'''
+ if await ux_show_story(m) != 'y':
+ return
+
+ from multisig import create_ms_step1
+
+ # picks addr fmt, QR or not, gets at least one file, then...
+ await create_ms_step1(for_ccc=(CCCFeature.get_encoded_secret(), count))
+
+ # prompt for file, prompt for our acct number, unless already exported to this card?
+
+ async def show_ident(self, *a):
+ # give some background? or just KISS for now?
+ xfp = xfp2str(CCCFeature.get_xfp())
+ xpub = CCCFeature.get_master_xpub()
+ await ux_show_story(
+ "Key C:\n\n"
+ "XFP (Master Fingerprint):\n\n %s\n\n"
+ "Master Extended Public Key:\n\n %s " % (xfp, xpub))
+
+ async def enter_temp_mode(self, *a):
+ # apply key C as temp seed, so you can do anything with it
+ # - just a shortcut, since they have the words, and could enter them
+ # - one-way trip because the CCC feature won't be enabled inside the temp seed settings
+ if await ux_show_story(
+ 'Loads the CCC controlled seed (key C) as a Temporary Seed and allows '
+ 'easy use of all Coldcard features on that key.\n\nIf you save into Seed Vault, '
+ 'access to CCC Config menu is quick and easy.') != 'y':
+ return
+
+ from seed import set_ephemeral_seed
+ from actions import goto_top_menu
+
+ enc = CCCFeature.get_encoded_secret()
+ await set_ephemeral_seed(enc, origin='Key C from CCC')
+
+ goto_top_menu()
+
+
+class SPAddrWhitelist(MenuSystem):
+ # simulator arg: --seq tcENTERENTERsENTERwENTER
+ def __init__(self, pol):
+ self.policy = pol
+ items = self.construct()
+ super().__init__(items)
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+ @classmethod
+ async def be_a_submenu(cls, pol, *a):
+ return cls(pol)
+
+ def construct(self):
+ # list of addresses
+ addrs = self.policy.get('addrs', [])
+ maxxed = (len(addrs) >= MAX_WHITELIST)
+
+ items = []
+ # better to show usability options at the top, as we can have up to 25 addresses in the menu
+ if version.has_qr:
+ items.append(MenuItem('Scan QR', f=(self.maxed_out if maxxed else self.scan_qr),
+ shortcut=KEY_QR))
+
+ items.append(MenuItem('Import from File',
+ f=(self.maxed_out if maxxed else self.import_file)))
+
+ # show most recent added addresses at the top of the menu list
+ a_items = [MenuItem(truncate_address(a), f=self.edit_addr, arg=a) for a in addrs[::-1]]
+
+ if a_items:
+ items += a_items
+ if len(a_items) > 1:
+ items.append(MenuItem("Clear Whitelist", f=self.clear_all))
+ else:
+ items.append(MenuItem("(none yet)"))
+
+ return items
+
+ async def edit_addr(self, menu, idx, item):
+ # show detail and offer delete
+ addr = item.arg
+ msg = ('Spends to this address will be permitted:\n\n%s'
+ '\n\nPress (4) to delete.' % show_single_address(addr))
+ ch = await ux_show_story(msg, escape='4')
+ if ch == '4':
+ self.delete_addr(addr)
+
+ def delete_addr(self, addr):
+ # no confirm, stakes are low
+ addrs = self.policy.get('addrs', [])
+ addrs.remove(addr)
+ self.policy.update_policy_key(addrs=addrs)
+ self.update_contents()
+
+ async def clear_all(self, *a):
+ if await ux_confirm("Remove all addresses from the whitelist?", confirm_key='4'):
+ self.policy.update_policy_key(addrs=[])
+ self.update_contents()
+
+ async def import_file(self, *a):
+ # Import from a file, or NFC.
+ # - simulator: --seq tcENTERENTERsENTERwENTERiENTER1
+ # - very forgiving, does not care about file format
+ # - but also silent on all errors
+ from ux import import_export_prompt
+ from glob import NFC
+ from actions import file_picker
+ from files import CardSlot
+ from utils import cleanup_payment_address
+
+ choice = await import_export_prompt("List of addresses", is_import=True, no_qr=True)
+
+ if choice == KEY_CANCEL:
+ return
+ elif choice == KEY_NFC:
+ addr = await NFC.read_address()
+ if not addr:
+ # error already displayed in nfc.py
+ return
+
+ await self.add_addresses([addr])
+ return
+
+ # loose RE to match any group of chars that could be addresses
+ # - really just removing whitespace and punctuation
+ # - lacking re.findall(), so using re.split() on negatives
+ pat = re.compile(r'[^A-Za-z0-9]')
+
+ # pick a likely-looking file: just looking at size and extension
+ fn = await file_picker(suffix=['.csv', '.txt'],
+ min_size=20, max_size=20000,
+ none_msg="Must contain payment addresses", **choice)
+
+ if not fn: return
+
+ results = []
+ with CardSlot(readonly=True, **choice) as card:
+ with open(fn, 'rt') as fd:
+ for ln in fd.readlines():
+ if len(results) >= MAX_WHITELIST:
+ # no need to clog memory and parse more, we're done
+ break
+ for here in pat.split(ln):
+ if len(here) >= 4:
+ try:
+ addr = cleanup_payment_address(here)
+ results.append(addr)
+ except: pass
+
+ if not results:
+ await ux_show_story("Unable to find any payment addresses in that file.")
+ else:
+ # silently limit to first 25 results; lets them use addresses.csv easily
+ await self.add_addresses(results[:MAX_WHITELIST])
+
+
+ async def scan_qr(self, *a):
+ # Scan and return a text string. For things like BIP-39 passphrase
+ # and perhaps they are re-using a QR from something else. Don't act on contents.
+ from ux_q1 import QRScannerInteraction
+ q = QRScannerInteraction()
+
+ got = []
+ ln = ''
+ while 1:
+ here = await q.scan_for_addresses("Bitcoin Address(es) to Whitelist", line2=ln)
+ if not here: break
+ for addr in here:
+ if addr not in got:
+ got.append(addr)
+ ln = 'Got %d so far. ENTER to apply.' % len(got)
+
+ if got:
+ # import them
+ await self.add_addresses(got)
+
+ async def maxed_out(self, *a):
+ await ux_show_story("Max %d items in whitelist. Please make room first." % MAX_WHITELIST)
+
+ async def add_addresses(self, more_addrs):
+ # add new entries, if unique; preserve ordering
+ addrs = self.policy.get('addrs', [])
+ new = []
+ for a in more_addrs:
+ if a not in addrs:
+ addrs.append(a)
+ new.append(a)
+
+ if not new:
+ await ux_show_story("Already in whitelist:\n\n" +
+ '\n\n'.join(show_single_address(a) for a in more_addrs))
+ return
+
+ if len(addrs) > MAX_WHITELIST:
+ return await self.maxed_out()
+
+ self.policy.update_policy_key(addrs=addrs)
+ self.update_contents()
+
+ if len(new) > 1:
+ await ux_show_story("Added %d new addresses to whitelist:\n\n%s" %
+ (len(new), '\n\n'.join(show_single_address(a) for a in new)))
+ else:
+ await ux_show_story("Added new address to whitelist:\n\n%s" %
+ show_single_address(new[0]))
+
+class SPCheckedMenuItem(MenuItem):
+ # Show a checkmark if **policy** setting is defined and not the default
+ # - only works inside SpendingPolicyMenu
+ def __init__(self, label, polkey, **kws):
+ super().__init__(label, **kws)
+ self.polkey = polkey
+
+ def is_chosen(self):
+ # should we show a check in parent menu? check the policy
+ m = the_ux.top_of_stack()
+ #assert isinstance(m, SpendingPolicyMenu)
+ return bool(m.policy.get(self.polkey, False))
+
+class SpendingPolicyMenu(MenuSystem):
+ # Build menu stack that allows edit of all features of the spending
+ # policy.
+ # - supports both CCC and SSSP modes w/ same policies
+ # - Key C is set already at this point.
+ # - and delete/cancel CCC (clears setting?)
+ # - be a sticky menu that's hard to exit (ie. SAVE choice and no cancel out)
+
+ def __init__(self, pol):
+ self.policy = pol
+ items = self.construct()
+ super().__init__(items)
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+ @classmethod
+ async def be_a_submenu(cls, pol, *a):
+ return cls(pol)
+
+ def construct(self):
+ items = [
+ # xxxxxxxxxxxxxxxx
+ SPCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude),
+ SPCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity),
+ SPCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''),
+ 'addrs',
+ menu=lambda *a: SPAddrWhitelist.be_a_submenu(self.policy)),
+ SPCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa),
+ ]
+
+ if self.policy.get('web2fa'):
+ items.extend([
+ MenuItem('↳ Test 2FA', f=self.test_2fa),
+ MenuItem('↳ Enroll More', f=self.enroll_more_2fa),
+ ])
+
+ return items
+
+ async def test_2fa(self, *a):
+ ss = self.policy.get('web2fa')
+ assert ss
+ ok = await web2fa.perform_web2fa('Testing Only', ss)
+
+ await ux_show_story('Correct code was given.' if ok else 'Failed or aborted.')
+
+ async def enroll_more_2fa(self, *a):
+ # let more phones in on the party, but they get same shared secret
+ ss = self.policy.get('web2fa')
+ assert ss
+ await web2fa.web2fa_enroll(ss)
+
+ async def set_magnitude(self, *a):
+ # Looks decent on both Q and Mk4...
+ was = self.policy.get('mag', 0)
+ val = await ux_enter_number('Transaction Max:', max_value=int(1e8),
+ can_cancel=True, value=(was or ''))
+
+ args = dict(mag=val)
+ if (val is None) or (val == was):
+ msg = "Did not change"
+ val = was
+ else:
+ msg = "You have set the"
+ unchanged = False
+
+ if not val:
+ msg = "No check for maximum transaction size will be done. "
+ if self.policy.get('vel', 0):
+ msg += 'Velocity check also disabled. '
+ args['vel'] = 0
+ else:
+ msg += " maximum per-transaction: \n\n %s" % render_mag_value(val)
+
+ self.policy.update_policy_key(**args)
+
+ await ux_show_story(msg, title="TX Magnitude")
+
+ async def set_velocity(self, *a):
+ mag = self.policy.get('mag', 0) or 0
+
+ if not mag:
+ msg = 'Velocity limit requires a per-transaction magnitude to be set.'\
+ ' This has been set to 1BTC as a starting value.'
+ self.policy.update_policy_key(mag=1)
+
+ await ux_show_story(msg)
+
+ start_chooser(self.velocity_chooser)
+
+
+ def velocity_chooser(self):
+ # offer some useful values from a menu
+ vel = self.policy.get('vel', 0) # in blocks
+
+ # xxxxxxxxxxxxxxxx
+ ch = [ 'Unlimited',
+ '6 blocks (hour)',
+ '24 blocks (4h)',
+ '48 blocks (8h)',
+ '72 blocks (12h)',
+ '144 blocks (day)',
+ '288 blocks (2d)',
+ '432 blocks (3d)',
+ '720 blocks (5d)',
+ '1008 blocks (1w)',
+ '2016 blocks (2w)',
+ '3024 blocks (3w)',
+ '4032 blocks (4w)',
+ ]
+ va = [0] + [int(x.split()[0]) for x in ch[1:]]
+
+ try:
+ which = va.index(vel)
+ except ValueError:
+ which = 0
+
+ def set(idx, text):
+ self.policy.update_policy_key(vel=va[idx])
+
+ return which, ch, set
+
+ async def toggle_2fa(self, *a):
+ if self.policy.get('web2fa'):
+ # enabled already
+
+ if not await ux_confirm("Disable web 2FA check? Effect is immediate."):
+ return
+
+ self.policy.update_policy_key(web2fa='')
+ self.update_contents()
+
+ await ux_show_story("Web 2FA has been disabled. If you re-enable it, a new "
+ "secret will be generated, so it is safe to remove it from your "
+ "phone at this point.")
+
+ return
+
+ ch = await ux_show_story('''When enabled, any spend (signing) requires \
+use of mobile 2FA application (TOTP RFC-6238). Shared-secret is picked now, \
+and loaded on your phone via QR code.
+
+WARNING: You will not be able to sign transactions if you do not have an NFC-enabled \
+phone with Internet access and 2FA app holding correct shared-secret.''',
+ title="Web 2FA")
+ if ch != 'y':
+ return
+
+ # challenge them, and don't set unless it works
+ ss = await web2fa.web2fa_enroll()
+ if not ss:
+ return
+
+ # update state
+ self.policy.update_policy_key(web2fa=ss)
+ self.update_contents()
+
+async def gen_or_import():
+ # returns 12 words, or None to abort
+ from seed import WordNestMenu, generate_seed, approve_word_list, SeedVaultChooserMenu
+
+ msg = "Press %s to generate a new 12-word seed phrase to be used "\
+ "as the Coldcard Co-Signing Secret (key C).\n\nOr press (1) to import existing "\
+ "12-words or (2) for 24-words import." % OK
+
+ if settings.master_get("seedvault", False):
+ msg += ' Press (6) to import from Seed Vault.'
+
+ ch = await ux_show_story(msg, escape='126', title="CCC Key C")
+
+ if ch in '12':
+ nwords = 24 if ch == '2' else 12
+
+ async def done_key_C_import(words):
+ if not version.has_qwerty:
+ WordNestMenu.pop_all()
+ await enable_step1(words)
+
+ if version.has_qwerty:
+ from ux_q1 import seed_word_entry
+ await seed_word_entry('Key C Seed Words', nwords, done_cb=done_key_C_import)
+ else:
+ nxt = WordNestMenu(nwords, done_cb=done_key_C_import)
+ the_ux.push(nxt)
+
+ return None # will call parent again
+
+ elif ch == '6':
+ # pick existing from Seed Vault
+ picked = await SeedVaultChooserMenu.pick(words_only=True)
+ if picked:
+ words = SecretStash.decode_words(deserialize_secret(picked.encoded))
+ await enable_step1(words)
+
+ return None
+
+ elif ch == 'y':
+ # normal path: pick 12 words, quiz them
+ await ux_dramatic_pause('Generating...', 3)
+ seed = generate_seed()
+ words = await approve_word_list(seed, 12)
+ else:
+ return None
+
+ return words
+
+
+async def toggle_ccc_feature(*a):
+ # The only menu item show to user!
+ if settings.get('ccc'):
+ return await modify_ccc_settings()
+
+ # enable the feature -- not simple!
+ # - create C key (maybe import?)
+ # - collect a policy setup, maybe 2FA enrol too
+ # - lock that down
+ # - TODO copy
+ ch = await ux_show_story('''\
+Adds an additional seed to your Coldcard, and enforces a "spending policy" whenever \
+it signs with that key. Spending policies can restrict: magnitude (BTC out), \
+velocity (blocks between txn), address whitelisting, and/or require confirmation by 2FA phone app.
+
+Assuming the use of a 2-of-3 multisig wallet, keys are as follows:\n
+A=Coldcard (master seed), B=Backup Key (offline/recovery), C=Spending Policy Key.
+
+Spending policy cannot be viewed or changed without knowledge of key C.\
+''',
+ title="Coldcard Co-Signing" if version.has_qwerty else 'CC Co-Sign')
+
+ if ch != 'y':
+ # just a tourist
+ return
+
+ await enable_step1(None)
+
+async def enable_step1(words):
+ if not words:
+ words = await gen_or_import()
+ if not words: return
+
+ dis.fullscreen("Wait...")
+ dis.busy_bar(True)
+ try:
+ # do BIP-32 basics: capture XFP and XPUB and encoded version of the secret
+ CCCFeature.init_setup(words)
+ finally:
+ dis.busy_bar(False)
+
+ # continue into config menu
+ m = CCCConfigMenu()
+
+ the_ux.push(m)
+
+async def modify_ccc_settings():
+ # Generally not expecting changes to policy on the fly because
+ # that's the whole point. Use the B key to override individual spends
+ # but if you can prove you have C key, then it's harmless to allow changes
+ # since you could just spend as needed.
+
+ enc = CCCFeature.get_encoded_secret()
+ bypass = False
+
+ from seed import in_seed_vault
+ if in_seed_vault(enc):
+ # If seed vault enabled and they have the key C in there already, just go
+ # directly into menu (super helpful for debug/setup/testing time). We do warn tho.
+ await ux_show_story('''You have a copy of the CCC key C in the Seed Vault, so \
+you may proceed to change settings now.\n\nYou must delete that key from the vault once \
+setup and debug is finished, or all benefit of this feature is lost!''', title='REMINDER')
+
+ bypass = True
+
+ else:
+ ch = await ux_show_story(
+ "Spending policy cannot be viewed, changed nor disabled, "
+ "unless you have the seed words for key C.",
+ title="CCC Enabled")
+
+ if ch != 'y': return
+
+ if bypass:
+ # doing full decode cycle here for better testing
+ chk, raw, _ = SecretStash.decode(enc)
+ assert chk == 'words'
+ words = bip39.b2a_words(raw).split(' ')
+ await key_c_challenge(words)
+ return
+
+ # small info-leak here: exposing 12 vs 24 words, but we expect most to be 12 anyway
+ nwords = CCCFeature.get_num_words()
+
+ import seed
+ if version.has_qwerty:
+ from ux_q1 import seed_word_entry
+ await seed_word_entry('Enter Seed Words', nwords, done_cb=key_c_challenge)
+ else:
+ return seed.WordNestMenu(nwords, done_cb=key_c_challenge)
+
+NUM_CHALLENGE_FAILS = 0
+
+async def key_c_challenge(words):
+ # They entered some words, if they match our key C then allow edit of policy
+
+ if not version.has_qwerty:
+ from seed import WordNestMenu
+ WordNestMenu.pop_all()
+
+ dis.fullscreen('Verifying...')
+
+ if not CCCFeature.words_check(words):
+ # keep an in-memory counter, and after 3 fails, reboot
+ global NUM_CHALLENGE_FAILS
+ NUM_CHALLENGE_FAILS += 1
+ if NUM_CHALLENGE_FAILS >= 3:
+ from utils import clean_shutdown
+ clean_shutdown()
+
+ await ux_show_story("Sorry, those words are incorrect.")
+ return
+
+ # success. they are in.
+
+ # got to config menu
+ m = CCCConfigMenu()
+ the_ux.push(m)
+
+def sssp_spending_policy(key, default=False, set_value=None):
+ # This function can be used to check if feature(s) are enabled in
+ # the single-signer policy settings. Might be used while hobbled.
+ # keys:
+ # 'en' = feature enabled; hobble on next boot
+ # 'notes' = allow access to knows
+ # 'words' = add first/last seed words to challenge to unlock
+ # 'okeys' = allow BIP-39 and/or seed vault
+
+ v = settings.master_get('sssp', dict())
+
+ if key in { 'en', 'notes', 'words', 'okeys' }:
+ # booleans: present or removed from dict
+ if set_value is not None:
+ if set_value:
+ v[key] = True
+ else:
+ v.pop(key, None)
+
+ settings.master_set('sssp', v, master_only=True)
+
+ return (key in v) or default
+
+ raise KeyError(key)
+
+
+async def sssp_feature_menu(*a):
+ # Show the top menu for SSSP feature, or enable access first time.
+ from pincodes import pa
+ from actions import goto_top_menu
+
+ if pa.hobbled_mode == 2:
+ # allow exit from test-drive mode, directly into editing settings
+ pa.hobbled_mode = False
+ goto_top_menu()
+ elif settings.master_get('sssp'):
+ # normal entry into menu system, after the first time
+ assert not pa.hobbled_mode
+ else:
+ # tell them a story, and maybe enable feature
+ en = await sssp_enable()
+ if not en: return
+
+ m = SSSPConfigMenu()
+ the_ux.push(m)
+
+async def sssp_enable():
+ # enabling the feature
+ # - collect and setup a new trick pin
+ # - set sssp settings w/ something non-empty but still disabled.
+ # - return T if they completed enabling process
+
+ from login import LoginUX
+ from trick_pins import tp
+ from pincodes import pa
+
+ # enable the feature -- not simple!
+ # - pick new (trick pin) that lets you back here.
+ # - collect a policy setup, maybe 2FA enrol too
+ # - lock that down
+ ch = await ux_show_story('''\
+You can define a "spending policy" which stops you from signing \
+transactions unless conditions are met.
+Spending policies can restrict: magnitude (BTC out), \
+velocity (blocks between txn), address whitelisting, \
+and/or require confirmation by 2FA phone app.
+
+When active, your COLDCARD \
+is locked into a special mode that restricts seed access, backups, settings and other features.
+
+First step is to define a new PIN code that is used when you want to bypass or \
+disable this feature.
+''',
+ title="Spending Policy")
+
+ if ch != 'y':
+ # just a tourist
+ return
+
+ # re-use existing PIN if there for some reason
+ new_pin = tp.has_sp_unlock()
+
+ if not new_pin:
+ have = tp.all_tricks()
+ main_pin = pa.pin.decode()
+ while 1:
+ lll = LoginUX()
+ lll.is_setting = True
+ lll.subtitle = "Spending Policy" + (" Unlock" if version.has_qwerty else '')
+
+ new_pin = await lll.get_new_pin()
+ if new_pin is None:
+ return
+
+ dis.fullscreen("Saving...")
+
+ # quick checks - does not spot hidden trick pins
+ if (new_pin != main_pin) and (new_pin not in have):
+ # verify uniqueness within SE2
+ b, slot = tp.get_by_pin(new_pin)
+ if slot is None:
+ tp.define_unlock_pin(new_pin)
+ break
+
+ await tp.err_unique_pin(new_pin)
+
+ # all features disabled to start
+ settings.master_set('sssp', dict(en=False, pol={}), master_only=True)
+
+ # continue into config menu
+ return True
+
+async def sssp_word_challenge(*a):
+ # Ask for first/last seed word and verify. Return if correct answers given.
+ # Reboots on failure.
+ from stash import SensitiveValues
+
+ with SensitiveValues() as sv:
+ if sv.mode != 'words':
+ # they are using XPRV or something, skip test entirely
+ return
+
+ words = bip39.b2a_words(sv.raw).split(' ')
+ want_words = words[:1] + words[-1:]
+ assert len(want_words) == 2
+
+ for retry in range(2):
+ if version.has_qwerty:
+ # see special rendering code for this case in ux_q1.py:ux_draw_words(num_words=2)
+ from ux_q1 import seed_word_entry
+ got_words = await seed_word_entry('First and Last Seed Words', 2, has_checksum=False)
+ else:
+ from seed import WordNestMenu
+ got_words = await WordNestMenu.get_n_words(2)
+
+ if got_words == want_words:
+ # success - done
+ return
+
+ await ux_show_story("Sorry, those words are incorrect.")
+
+ # they failed; log them out ... they can just try login again
+ from actions import login_now
+ await login_now()
+
+ # NOT-REACHED
+
+class SSSPCheckedMenuItem(MenuItem):
+ # Show a checkmark if **top level** security setting is defined and not the default
+ # - only works inside SSSPPolicyMenu?
+ # - similar to menu.py:ToggleMenuItem
+
+ def __init__(self, label, polkey, story, **kws):
+ super().__init__(label, **kws)
+ self.polkey = polkey
+ self.story = story
+
+ def is_chosen(self):
+ # should we show a check in menu? check the current SSSP settings
+ return sssp_spending_policy(self.polkey)
+
+ async def activate(self, menu, idx):
+ # do simple toggle on request
+ was = sssp_spending_policy(self.polkey)
+
+ msg = self.story + "\n\n%s?" % ('Disable' if was else 'Enable')
+
+ ch = await ux_show_story(msg)
+ if ch == 'x': return
+
+ # this can be slow, so show something
+ dis.fullscreen("Saving...")
+ sssp_spending_policy(self.polkey, set_value=(not was))
+
+
+class SSSPConfigMenu(MenuSystem):
+ def __init__(self):
+ items = self.construct()
+ super().__init__(items)
+
+ def update_contents(self):
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+ def construct(self):
+ items = [
+ # xxxxxxxxxxxxxxxx
+ MenuItem('Edit Policy...',
+ menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())),
+ SSSPCheckedMenuItem('Word Check', 'words', 'To change Spending Policy, in addition to special PIN, you must provide the first and last seed words.'),
+ SSSPCheckedMenuItem('Allow Notes', 'notes', 'Allow (read-only) access to secure notes and passwords? Otherwise, they are inaccessible.', predicate=version.has_qwerty),
+ SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, and Seed Vault (read-only). Single Spending Policy applies to all.'),
+ #MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
+ ]
+
+ if LastFailReason.get():
+ # xxxxxxxxxxxxxxxx
+ items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
+
+ items.append(MenuItem('Remove Policy', f=self.remove_sssp))
+ items.append(MenuItem('Test Drive', f=self.test_drive))
+ items.append(MenuItem('ACTIVATE', f=self.activate_feature))
+
+ return items
+
+ async def activate_feature(self, *a):
+ # Policy is being set in stone now; confirm and switch to hobble mode, etc.
+ from trick_pins import tp
+
+ bypass_pin = tp.has_sp_unlock()
+
+ if not bypass_pin:
+ msg = "You have no Spending Policy bypass PIN defined, so changes to this COLDCARD cannot be made past this point. Only option will be to destroy seed and reload everything."
+ else:
+ msg = "To return to normal unlimited spending mode, you will need to enter the special pin (%s), then the Main PIN" % bypass_pin
+ if sssp_spending_policy('words'):
+ msg += ', followed by the first and last seed words'
+ msg += '.'
+
+ if not await ux_confirm(msg, 'CONTINUE?'):
+ return
+
+ # set it for next login
+ dis.fullscreen("Saving...")
+ sssp_spending_policy('en', set_value=True)
+
+ # make it real ... could reboot here instead, but no need.
+ from pincodes import pa
+ from actions import goto_top_menu
+
+ pa.hobbled_mode = True
+ goto_top_menu()
+
+ async def test_drive(self, *a):
+ # allow test drive of feature
+ if not await ux_confirm("See what COLDCARD operation will look like with Spending Policy enabled.", 'CONTINUE?'):
+ return
+
+ from pincodes import pa
+ from actions import goto_top_menu
+
+ pa.hobbled_mode = 2 # Truthy value to indicate they can escape easily
+ goto_top_menu()
+
+ async def debug_last_fail(self, *a):
+ # debug for customers: why did we reject that last txn?
+ pol = SSSPFeature.get_policy()
+ bh = pol.get('block_h', None)
+ msg = ''
+ if bh:
+ msg += "Last height:\n\n%s\n\n" % bh
+
+ lfr = LastFailReason.get()
+ msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
+ % lfr
+ ch = await ux_show_story(msg, escape='4')
+
+ if ch == '4':
+ LastFailReason.clear()
+ self.update_contents()
+
+ async def remove_sssp(self, *a):
+ # disable and remove feature
+ if not await ux_confirm('Bypass PIN will be removed, and all spending policy settings forgotten.'):
+ return
+
+ settings.remove_key('sssp')
+ settings.save()
+
+ from trick_pins import tp
+ tp.delete_sp_unlock_pins()
+
+ the_ux.pop()
+
+
+# EOF
diff --git a/shared/chains.py b/shared/chains.py
index 26af1410f..10d65d025 100644
--- a/shared/chains.py
+++ b/shared/chains.py
@@ -5,12 +5,17 @@
import ngu
from uhashlib import sha256
from ubinascii import hexlify as b2a_hex
-from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR, AF_BARE_PK
from public_constants import AF_P2SH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH
-from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT
-from serializations import hash160, ser_compact_size, disassemble
+from public_constants import AFC_PUBKEY, AFC_BECH32, AFC_SCRIPT
+from public_constants import TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK
+from serializations import hash160, ser_compact_size, disassemble, ser_string
from ucollections import namedtuple
from opcodes import OP_RETURN, OP_1, OP_16
+from precomp_tag_hash import TAP_TWEAK_H, TAP_LEAF_H
+
+
+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.
@@ -19,18 +24,40 @@
# See also:
# -
# - defines ypub/zpub/Xprc variants
-# -
-# - nice bech32 encoded scheme for going forward
# -
# - mailing list post proposed ypub, etc.
# - from
# - also electrum source: electrum/lib/constants.py
+# nLockTime in transaction equal or above this value is a unix timestamp (time_t) not block height.
+NLOCK_IS_TIME = const(500000000)
+
+
+def taptweak(internal_key, tweak=None):
+ # BIP 341 states: "If the spending conditions do not require a script path,
+ # the output key should commit to an unspendable script path instead of having no script path.
+ # This can be achieved by computing the output key point as:
+ # Q = P + int(hashTapTweak(bytes(P)))G."
+ actual_tweak = internal_key if tweak is None else internal_key + tweak
+ tweak = ngu.hash.sha256t(TAP_TWEAK_H, actual_tweak, True)
+ xo_pubkey = ngu.secp256k1.xonly_pubkey(internal_key)
+ xo_pubkey_tweaked = xo_pubkey.tweak_add(tweak)
+ return xo_pubkey_tweaked.to_bytes()
+
+def tapscript_serialize(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT):
+ # leaf version is only 7 msb
+ lv = leaf_version % TAPROOT_LEAF_MASK
+ return bytes([lv]) + ser_string(script)
+
+def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT):
+ return ngu.hash.sha256t(TAP_LEAF_H, tapscript_serialize(script, leaf_version), True)
+
+
class ChainsBase:
curve = 'secp256k1'
menu_name = None # use 'name' if this isn't defined
- core_name = None # name of chain's "core" p2p software
+ ccc_min_block = 0
# b44_cointype comes from
#
@@ -65,68 +92,57 @@ def serialize_public(cls, node, addr_fmt=AF_CLASSIC):
return node.serialize(cls.slip132[addr_fmt].pub, False)
@classmethod
- def deserialize_node(cls, text, addr_fmt):
- # xpub/xprv to object
- addr_fmt = AF_CLASSIC if addr_fmt == AF_P2SH else addr_fmt
- node = ngu.hdnode.HDNode()
- version = node.deserialize(text)
- assert (version == cls.slip132[addr_fmt].pub) \
- or (version == cls.slip132[addr_fmt].priv)
- return node
+ def script_pubkey(cls, addr_fmt, pubkey=None, script=None):
+ digest = None
+ if addr_fmt & AFC_SCRIPT:
+ assert script, "need witness/redeem script"
- @classmethod
- def p2sh_address(cls, addr_fmt, witdeem_script):
- # Multisig and general P2SH support
- # - witdeem => witness script for segwit, or redeem script otherwise
- # - redeem script can be generated from witness script if needed.
- # - this function needs a witdeem script to be provided, not simple to make
- # - more verification needed to prove it's change/included address (NOT HERE)
- # - reference:
- # - returns: str(address)
-
- assert addr_fmt & AFC_SCRIPT, 'for p2sh only'
- assert witdeem_script, "need witness/redeem script"
-
- if addr_fmt & AFC_SEGWIT:
- digest = ngu.hash.sha256s(witdeem_script)
- else:
- digest = hash160(witdeem_script)
+ if addr_fmt in [AF_P2WSH, AF_P2WSH_P2SH]:
+ digest = ngu.hash.sha256s(script)
+ # bech32 encoded segwit p2sh
+ spk = b'\x00\x20' + digest
+ if addr_fmt == AF_P2WSH_P2SH:
+ # segwit p2wsh encoded as classic P2SH
+ digest = hash160(spk)
+ spk = b'\xA9\x14' + digest + b'\x87'
+
+ else:
+ assert addr_fmt == AF_P2SH
+ digest = hash160(script)
+ spk = b'\xA9\x14' + digest + b'\x87'
- if addr_fmt & AFC_BECH32:
- # bech32 encoded segwit p2sh
- addr = ngu.codecs.segwit_encode(cls.bech32_hrp, 0, digest)
- elif addr_fmt == AF_P2WSH_P2SH:
- # segwit p2wsh encoded as classic P2SH
- addr = ngu.codecs.b58_encode(cls.b58_script + hash160(b'\x00\x20' + digest))
else:
- # P2SH classic
- addr = ngu.codecs.b58_encode(cls.b58_script + digest)
+ assert pubkey
+ keyhash = ngu.hash.hash160(pubkey)
+ if addr_fmt == AF_P2TR:
+ assert len(pubkey) == 32 # internal
+ spk = b'\x51\x20' + taptweak(pubkey)
+ elif addr_fmt == AF_CLASSIC:
+ spk = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
+ elif addr_fmt == AF_P2WPKH_P2SH:
+ redeem_script = b'\x00\x14' + keyhash
+ spk = b'\xA9\x14' + ngu.hash.hash160(redeem_script) + b'\x87'
+ elif addr_fmt == AF_P2WPKH:
+ spk = b'\x00\x14' + keyhash
+ else:
+ raise ValueError('bad address template: %s' % addr_fmt)
- return addr
+ return spk, digest
@classmethod
def pubkey_to_address(cls, pubkey, addr_fmt):
# - renders a pubkey to an address
# - works only with single-key addresses
assert not addr_fmt & AFC_SCRIPT
-
- keyhash = ngu.hash.hash160(pubkey)
- if addr_fmt == AF_CLASSIC:
- script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
- elif addr_fmt == AF_P2WPKH_P2SH:
- redeem_script = b'\x00\x14' + keyhash
- scripthash = ngu.hash.hash160(redeem_script)
- script = b'\xA9\x14' + scripthash + b'\x87'
- elif addr_fmt == AF_P2WPKH:
- script = b'\x00\x14' + keyhash
- else:
- raise ValueError('bad address template: %s' % addr_fmt)
-
- return cls.render_address(script)
+ spk, _ = cls.script_pubkey(addr_fmt, pubkey=pubkey)
+ return cls.render_address(spk)
@classmethod
def address(cls, node, addr_fmt):
# return a human-readable, properly formatted address
+ if addr_fmt == AF_P2TR:
+ xo_pk = node.pubkey()[1:]
+ return ngu.codecs.segwit_encode(cls.bech32_hrp, 1, taptweak(xo_pk))
if addr_fmt == AF_CLASSIC:
# olde fashioned P2PKH
@@ -134,7 +150,7 @@ def address(cls, node, addr_fmt):
return node.addr_help(cls.b58_addr[0])
if addr_fmt & AFC_SCRIPT:
- # use p2sh_address() instead.
+ # use chain.render_address
raise ValueError(hex(addr_fmt))
# so must be P2PKH, fetch it.
@@ -161,7 +177,7 @@ def privkey(cls, node):
@classmethod
def hash_message(cls, msg=None, msg_len=0):
# Perform sha256 for message-signing purposes (only)
- # - or get setup for that, if msg == None
+ # - or get setup for that, if msg is None
s = sha256()
s.update(cls.msg_signing_prefix())
@@ -242,37 +258,37 @@ def render_address(cls, script):
@classmethod
def op_return(cls, script):
- """Returns decoded string op return data if script is op return otherwise None"""
+ # returns decoded string op return data if script is op return otherwise None
gen = disassemble(script)
script_type = next(gen)
- if OP_RETURN in script_type:
- try:
- data = next(gen)[0]
- if data is None: raise RuntimeError
- except (RuntimeError, StopIteration):
- return "null-data", ""
- data_hex = b2a_hex(data).decode()
- data_ascii = None
- if min(data) >= 32 and max(data) < 127: # printable
- try:
- data_ascii = data.decode("ascii")
- except:
- pass
- return data_hex, data_ascii
- return None
+ if OP_RETURN not in script_type:
+ return
+
+ try:
+ data = next(gen)[0]
+ if data:
+ return data
+ except StopIteration:
+ pass
+
+ return b""
@classmethod
def possible_address_fmt(cls, addr):
# Given a text (serialized) address, return what
# address format applies to the address, but
# for AF_P2SH case, could be: AF_P2SH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH. .. we don't know
- if addr.startswith(cls.bech32_hrp):
- if addr.startswith(cls.bech32_hrp+'1p'):
- # really any ver=1 script or address, but for now...
+ hrp = cls.bech32_hrp + "1"
+ if addr.startswith(hrp):
+ if addr.startswith(hrp+'p'):
+ # segwit v1 (any ver=1 script or address, but for now just taproot...)
return AF_P2TR
- else:
+ elif addr.startswith(hrp+'q'):
+ # segwit v0
return AF_P2WPKH if len(addr) < 55 else AF_P2WSH
+ return 0
+
try:
raw = ngu.codecs.b58_decode(addr)
except ValueError:
@@ -290,8 +306,8 @@ def possible_address_fmt(cls, addr):
class BitcoinMain(ChainsBase):
# see
ctype = 'BTC'
- name = 'Bitcoin'
- core_name = 'Bitcoin Core'
+ name = 'Bitcoin Mainnet'
+ ccc_min_block = 922061 # Nov 3/2025
slip132 = {
AF_CLASSIC: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
@@ -299,6 +315,7 @@ class BitcoinMain(ChainsBase):
AF_P2WPKH: Slip132Version(0x04b24746, 0x04b2430c, 'z'),
AF_P2WSH_P2SH: Slip132Version(0x0295b43f, 0x0295b005, 'Y'),
AF_P2WSH: Slip132Version(0x02aa7ed3, 0x02aa7a99, 'Z'),
+ AF_P2TR: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
}
bech32_hrp = 'bc'
@@ -309,10 +326,10 @@ class BitcoinMain(ChainsBase):
b44_cointype = 0
-class BitcoinTestnet(BitcoinMain):
+class BitcoinTestnet(ChainsBase):
+ # 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'),
@@ -320,6 +337,7 @@ class BitcoinTestnet(BitcoinMain):
AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'),
AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'),
AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'),
+ AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'),
}
bech32_hrp = 'tb'
@@ -331,10 +349,9 @@ class BitcoinTestnet(BitcoinMain):
b44_cointype = 1
-class BitcoinRegtest(BitcoinMain):
+class BitcoinRegtest(ChainsBase):
ctype = 'XRT'
name = 'Bitcoin Regtest'
- menu_name = 'Regtest: BTC'
slip132 = {
AF_CLASSIC: Slip132Version(0x043587cf, 0x04358394, 't'),
@@ -342,6 +359,7 @@ class BitcoinRegtest(BitcoinMain):
AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'),
AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'),
AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'),
+ AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'),
}
bech32_hrp = 'bcrt'
@@ -376,10 +394,17 @@ def current_chain():
return get_chain(chain)
+def current_key_chain():
+ c = current_chain()
+ if c == BitcoinRegtest:
+ # regtest has same extended keys as testnet
+ c = BitcoinTestnet
+ return c
+
# Overbuilt: will only be testnet and mainchain.
AllChains = [BitcoinMain, BitcoinTestnet, BitcoinRegtest]
-def slip32_deserialize(xp):
+def slip132_deserialize(xp):
# .. and classify chain and addr-type, as implied by prefix
node = ngu.hdnode.HDNode()
version = node.deserialize(xp)
@@ -403,8 +428,82 @@ def slip32_deserialize(xp):
AF_P2WPKH_P2SH ), # generates 3xxx/2xxx p2sh-looking addresses
( 'BIP-84 (Native Segwit P2WPKH)', "m/84h/{coin_type}h/{account}h/{change}/{idx}",
AF_P2WPKH ), # generates bc1 bech32 addresses
+ ('BIP-86 (Taproot Segwit P2TR)', "m/86h/{coin_type}h/{account}h/{change}/{idx}",
+ 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],
+ "p2tr": CommonDerivations[3][1],
+}
+
+MS_STD_DERIVATIONS = {
+ ("p2sh", "m/45h", AF_P2SH),
+ ("p2sh_p2wsh", "m/48h/{coin}h/{acct_num}h/1h", AF_P2WSH_P2SH),
+ ("p2wsh", "m/48h/{coin}h/{acct_num}h/2h", AF_P2WSH),
+ ('p2tr', "m/48h/{coin}h/{acct_num}h/3h", AF_P2TR),
+}
+
+AF_TO_STR_AF = {
+ AF_BARE_PK: "p2pk",
+ AF_CLASSIC: "p2pkh",
+ AF_P2TR: "p2tr",
+ AF_P2WPKH: "p2wpkh",
+ AF_P2WPKH_P2SH: "p2sh-p2wpkh",
+ AF_P2SH: "p2sh",
+ AF_P2WSH: "p2wsh",
+ AF_P2WSH_P2SH: "p2sh-p2wsh",
+}
+
+def parse_addr_fmt_str(addr_fmt):
+ # accepts strings and also integers if already parsed
+ # integers are coming from USB
+ try:
+ if isinstance(addr_fmt, int):
+ if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]:
+ return addr_fmt
+ else:
+ try:
+ addr_fmt = AF_TO_STR_AF[addr_fmt] # just for error msg
+ except: pass
+ 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):
+ # Address format to BIP-44 "purpose" number
+ # - 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",
+ AF_P2SH: "Legacy P2SH",
+ }[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 06d829c0e..f1242afd5 100644
--- a/shared/charcodes.py
+++ b/shared/charcodes.py
@@ -107,4 +107,11 @@
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.
+# - must be first char in line
+OUT_CTRL_TITLE = '\x01' # be a title line
+OUT_CTRL_ADDRESS = '\x02' # it's a payment address
+OUT_CTRL_NOWRAP = '\x03' # do not word wrap this line
+
# EOF
diff --git a/shared/compat7z.py b/shared/compat7z.py
index e475c4f6f..3034d1ce3 100644
--- a/shared/compat7z.py
+++ b/shared/compat7z.py
@@ -198,7 +198,7 @@ def read_iter(cls, f, expect_crc=None):
# read only next one; ftell has to be on first byte already
rv = cls.read(f)
- if expect_crc != None:
+ if expect_crc is not None:
assert rv # read past end
assert masked_crc(rv.bits) == expect_crc
@@ -315,7 +315,7 @@ def add_data(self, raw):
padded_len = (here + 15) & ~15
if padded_len != here:
- if self.padding != None:
+ if self.padding is not None:
raise ValueError() # "can't do less than a block except at end"
self.padding = (padded_len - here)
raw += bytes(self.padding)
diff --git a/shared/decoders.py b/shared/decoders.py
index 6903e37c7..a71483fb9 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
@@ -101,7 +101,7 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
try:
ty, final_size, got = got.storage.finalize()
except BaseException as exc:
- import sys; sys.print_exception(exc)
+ #import sys; sys.print_exception(exc)
raise QRDecodeExplained("BBQr decode failed: " + str(exc))
if expect_bbqr:
@@ -131,7 +131,23 @@ 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,)
+
+ elif ty in 'RSE':
+ # key-teleport related
+
+ from pincodes import pa
+ if pa.hobbled_mode and ty != 'E':
+ raise QRDecodeExplained("KT Blocked")
+
+ if ty == 'R' and len(got) != 33:
+ raise QRDecodeExplained("Truncated KT RX")
+
+ return 'teleport', (ty, got)
else:
msg = TYPE_LABELS.get(ty, 'Unknown FileType')
raise QRDecodeExplained("Sorry, %s not useful." % msg)
@@ -159,6 +175,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 +204,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))
@@ -194,25 +223,9 @@ def decode_short_text(got):
# was something else.
pass
- # multisig descriptor
- # multi( catches both multi( and sortedmulti(
- if ("multi(" in got):
- return 'multi', (got,)
-
- if ("\n" in got) and ('pub' in got):
- # legacy multisig import/export format
- # [0-9a-fA-F]{8}\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]{107}
- # above is more precise BUT counted repetitions not supported in mpy
- cc_ms_pat = r"[0-9a-fA-F]+\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]+"
- rgx = ure.compile(cc_ms_pat)
- # go line by line and match above, once 2 matches observed - considered multisig
- # important to not use ure.search for big strings (can run out of stack)
- c = 0 # match count
- for l in got.split("\n"):
- if rgx.search(l):
- c += 1
- if c > 1:
- return 'multi', (got,)
+ from descriptor import Descriptor
+ if Descriptor.is_descriptor(got):
+ return 'minisc', (got,)
# Things with newlines in them are not URL's
# - working URLs are not >4k
diff --git a/shared/desc_utils.py b/shared/desc_utils.py
new file mode 100644
index 000000000..e2f7c870b
--- /dev/null
+++ b/shared/desc_utils.py
@@ -0,0 +1,477 @@
+# (c) Copyright 2020 by Stepan Snigirev, see
+#
+# Changes (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+import ngu, chains, ustruct, stash
+from io import BytesIO
+from public_constants import MAX_PATH_DEPTH
+from binascii import unhexlify as a2b_hex
+from binascii import hexlify as b2a_hex
+from utils import keypath_to_str, str_to_keypath, swab32, xfp2str
+from serializations import ser_compact_size
+
+
+WILDCARD = "*"
+PROVABLY_UNSPENDABLE = b'\x02P\x92\x9bt\xc1\xa0IT\xb7\x8bK`5\xe9z^\x07\x8aZ\x0f(\xec\x96\xd5G\xbf\xee\x9a\xce\x80:\xc0'
+
+INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
+CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+
+
+def polymod(c, val):
+ c0 = c >> 35
+ c = ((c & 0x7ffffffff) << 5) ^ val
+ if (c0 & 1):
+ c ^= 0xf5dee51989
+ if (c0 & 2):
+ c ^= 0xa9fdca3312
+ if (c0 & 4):
+ c ^= 0x1bab10e32d
+ if (c0 & 8):
+ c ^= 0x3706b1677a
+ if (c0 & 16):
+ c ^= 0x644d626ffd
+
+ return c
+
+def descriptor_checksum(desc):
+ c = 1
+ cls = 0
+ clscount = 0
+ for ch in desc:
+ pos = INPUT_CHARSET.find(ch)
+ if pos == -1:
+ raise ValueError(ch)
+
+ c = polymod(c, pos & 31)
+ cls = cls * 3 + (pos >> 5)
+ clscount += 1
+ if clscount == 3:
+ c = polymod(c, cls)
+ cls = 0
+ clscount = 0
+
+ if clscount > 0:
+ c = polymod(c, cls)
+ for j in range(0, 8):
+ c = polymod(c, 0)
+ c ^= 1
+
+ rv = ''
+ for j in range(0, 8):
+ rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
+
+ return rv
+
+def append_checksum(desc):
+ return desc + "#" + descriptor_checksum(desc)
+
+
+def parse_desc_str(string):
+ """Remove comments, empty lines and strip line. Produce single line string"""
+ res = ""
+ for l in string.split("\n"):
+ strip_l = l.strip()
+ if not strip_l:
+ continue
+ if strip_l.startswith("#"):
+ continue
+ res += strip_l
+ return res
+
+
+def read_until(s, chars=b",)(#"):
+ res = b""
+ while True:
+ chunk = s.read(1)
+ if len(chunk) == 0:
+ return res, None
+ if chunk in chars:
+ return res, chunk
+ res += chunk
+
+
+class KeyOriginInfo:
+ def __init__(self, fingerprint: bytes, derivation: list, cc_fp=None):
+ self.fingerprint = fingerprint
+ self.derivation = derivation
+ self._cc_fp = cc_fp
+
+ def __eq__(self, other):
+ return self.psbt_derivation() == other.psbt_derivation()
+
+ def __hash__(self):
+ return hash(tuple(self.psbt_derivation()))
+
+ @property
+ def cc_fp(self):
+ if self._cc_fp is None:
+ self._cc_fp = ustruct.unpack('")
+ assert char, err
+ assert b";" not in int_num, "Solved cardinality > 2"
+ cls.not_hardened(int_num)
+
+ assert int_num != ext_num # cannot be the same
+ multi_i = len(idxs)
+ idxs.append((int(ext_num.decode()), int(int_num.decode())))
+
+ else:
+ # char in "/),"
+ if got == b"*":
+ # every derivation has to end with wildcard (only ranged keys allowed)
+ idxs.append(WILDCARD)
+ break
+ elif got:
+ cls.not_hardened(got)
+ idxs.append(int(got.decode()))
+
+ # comma and parenthesis not allowed in subderivation, marker of the end
+ if char in b",)": break
+
+ assert idxs[-1] == WILDCARD, "All keys must be ranged"
+ if idxs == [0, WILDCARD]:
+ # normalize and instead save as <0;1> as change derivation was not provided
+ obj = cls()
+ else:
+
+ assert multi_i is not None, "need multipath"
+ assert len(idxs[multi_i]) == 2, "wrong multipath"
+
+ obj = cls(tuple(idxs))
+ obj.multi_path_index = multi_i
+
+ return obj
+
+ def to_string(self, external=True, internal=True):
+ res = []
+ for i in self.indexes:
+ if isinstance(i, tuple):
+ if internal is True and external is False:
+ i = str(i[1])
+ elif internal is False and external is True:
+ i = str(i[0])
+ else:
+ i = "<%d;%d>" % (i[0], i[1])
+ else:
+ i = str(i)
+ res.append(i)
+ return "/".join(res)
+
+
+class Key:
+ def __init__(self, node, origin, derivation=None, taproot=False, chain_type=None):
+ self.origin = origin
+ self.node = node
+ self.derivation = derivation or KeyDerivationInfo()
+ self.taproot = taproot
+ self.chain_type = chain_type
+
+ def __eq__(self, other):
+ return hash(self) == hash(other)
+
+ def __hash__(self):
+ # return hash(self.to_string())
+ return hash(self.node.pubkey()) + hash(self.derivation)
+
+ def __len__(self):
+ return 34 - int(self.taproot) # <33:sec> or <32:xonly>
+
+ @property
+ def fingerprint(self):
+ return self.origin.fingerprint
+
+ def serialize(self):
+ return self.key_bytes()
+
+ def compile(self):
+ d = self.serialize()
+ return ser_compact_size(len(d)) + d
+
+ @classmethod
+ def parse(cls, s):
+ first = s.read(1)
+ origin = None
+
+ if first == b"[":
+ prefix, char = read_until(s, b"]")
+ if char != b"]":
+ raise ValueError("Invalid key - missing ] in key origin info")
+ origin = KeyOriginInfo.from_string(prefix.decode())
+ else:
+ s.seek(-1, 1)
+
+ k, char = read_until(s, b",)/")
+ der = None
+ if char == b"/":
+ der = KeyDerivationInfo.parse(s)
+ if char is not None:
+ s.seek(-1, 1)
+
+ # parse key
+ node, chain_type = cls.parse_key(k)
+ if origin is None:
+ cc_fp = swab32(node.my_fp())
+ origin = KeyOriginInfo(ustruct.pack('/*")
+
+
+def bip388_validate_policy(desc_tmplt, keys_info):
+ from uio import BytesIO
+
+ s = BytesIO(desc_tmplt)
+ r = []
+ while True:
+ got, char = read_until(s, b"@")
+ if not char:
+ # no more - done
+ break
+
+ # key derivation info required for policy
+ got, char = read_until(s, b"/")
+ assert char, "key derivation missing"
+ num = int(got.decode())
+ if num not in r:
+ r.append(num)
+
+ assert s.read(1) in b"<*", "need multipath"
+
+
+ assert len(r) == len(keys_info), "Invalid policy"
+ assert r == list(range(len(r))), "Out of order"
diff --git a/shared/descriptor.py b/shared/descriptor.py
index e8cf6835c..9ab0c1a1b 100644
--- a/shared/descriptor.py
+++ b/shared/descriptor.py
@@ -1,252 +1,319 @@
-# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+# (c) Copyright 2020 by Stepan Snigirev, see
#
-# descriptor.py - Bitcoin Core's descriptors and their specialized checksums.
+# Changes (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
-# Based on: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
-#
-from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH
-
-MULTI_FMT_TO_SCRIPT = {
- AF_P2SH: "sh(%s)",
- AF_P2WSH_P2SH: "sh(wsh(%s))",
- AF_P2WSH: "wsh(%s)",
- None: "wsh(%s)",
- # hack for tests
- "p2sh": "sh(%s)",
- "p2sh-p2wsh": "sh(wsh(%s))",
- "p2wsh-p2sh": "sh(wsh(%s))",
- "p2wsh": "wsh(%s)",
-}
-
-SINGLE_FMT_TO_SCRIPT = {
- AF_P2WPKH: "wpkh(%s)",
- AF_CLASSIC: "pkh(%s)",
- AF_P2WPKH_P2SH: "sh(wpkh(%s))",
- None: "wpkh(%s)",
- "p2pkh": "pkh(%s)",
- "p2wpkh": "wpkh(%s)",
- "p2sh-p2wpkh": "sh(wpkh(%s))",
- "p2wpkh-p2sh": "sh(wpkh(%s))",
-}
-
-INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
-CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
-
-try:
- from utils import xfp2str, str2xfp
-except ModuleNotFoundError:
- import struct
- from binascii import unhexlify as a2b_hex
- from binascii import hexlify as b2a_hex
- # assuming not micro python
- def xfp2str(xfp):
- # Standardized way to show an xpub's fingerprint... it's a 4-byte string
- # and not really an integer. Used to show as '0x%08x' but that's wrong endian.
- return b2a_hex(struct.pack('> 35
- c = ((c & 0x7ffffffff) << 5) ^ val
- if (c0 & 1):
- c ^= 0xf5dee51989
- if (c0 & 2):
- c ^= 0xa9fdca3312
- if (c0 & 4):
- c ^= 0x1bab10e32d
- if (c0 & 8):
- c ^= 0x3706b1677a
- if (c0 & 16):
- c ^= 0x644d626ffd
-
- return c
-
-def descriptor_checksum(desc):
- c = 1
- cls = 0
- clscount = 0
- for ch in desc:
- pos = INPUT_CHARSET.find(ch)
- if pos == -1:
- raise ValueError(ch)
-
- c = polymod(c, pos & 31)
- cls = cls * 3 + (pos >> 5)
- clscount += 1
- if clscount == 3:
- c = polymod(c, cls)
- cls = 0
- clscount = 0
-
- if clscount > 0:
- c = polymod(c, cls)
- for j in range(0, 8):
- c = polymod(c, 0)
- c ^= 1
-
- rv = ''
- for j in range(0, 8):
- rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
-
- return rv
-
-def append_checksum(desc):
- return desc + "#" + descriptor_checksum(desc)
-
-
-def parse_desc_str(string):
- """Remove comments, empty lines and strip line. Produce single line string"""
- res = ""
- for l in string.split("\n"):
- strip_l = l.strip()
- if not strip_l:
- continue
- if strip_l.startswith("#"):
- continue
- res += strip_l
- return res
-
-
-def multisig_descriptor_template(xpub, path, xfp, addr_fmt):
- key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub)
- if addr_fmt == AF_P2WSH_P2SH:
- descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))"
- elif addr_fmt == AF_P2WSH:
- descriptor_template = "wsh(sortedmulti(M,%s,...))"
- elif addr_fmt == AF_P2SH:
- descriptor_template = "sh(sortedmulti(M,%s,...))"
- else:
- return None
- descriptor_template = descriptor_template % key_exp
- return descriptor_template
+import ngu, chains
+from io import BytesIO
+from collections import OrderedDict
+from binascii import hexlify as b2a_hex
+from utils import xfp2str
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
+from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, MAX_TR_SIGNERS
+from desc_utils import parse_desc_str, append_checksum, descriptor_checksum, Key
+from miniscript import Miniscript
+from precomp_tag_hash import TAP_BRANCH_H
+
+
+class Tapscript:
+ def __init__(self, tree):
+ self.tree = tree # miniscript or (tapscript, tapscript)
+ self._merkle_root = None
+ self._processed_tree = None
+
+ def iter_leaves(self):
+ if isinstance(self.tree, Miniscript):
+ yield self.tree
+ else:
+ for ts in self.tree:
+ yield from ts.iter_leaves()
+
+ @property
+ def merkle_root(self):
+ if not self._merkle_root:
+ self._processed_tree, self._merkle_root = self.process_tree()
+ return self._merkle_root
+
+ def derive(self, idx, key_map, change=False):
+ if isinstance(self.tree, Miniscript):
+ tree = self.tree.derive(idx, key_map, change=change)
+ else:
+ l, r = self.tree
+ tree = [l.derive(idx, key_map, change=change),
+ r.derive(idx, key_map, change=change)]
+
+ return type(self)(tree)
+
+ def process_tree(self):
+ if isinstance(self.tree, Miniscript):
+ script = self.tree.compile()
+ h = chains.tapleaf_hash(script)
+ return [(chains.TAPROOT_LEAF_TAPSCRIPT, script, bytes())], h
+
+ l, r = self.tree
+ left, left_h = l.process_tree()
+ right, right_h = r.process_tree()
+ left = [(version, script, control + right_h) for version, script, control in left]
+ right = [(version, script, control + left_h) for version, script, control in right]
+ if right_h < left_h:
+ right_h, left_h = left_h, right_h
+
+ h = ngu.hash.sha256t(TAP_BRANCH_H, left_h + right_h, True)
+ return left + right, h
+
+ # UNUSED - using above proces tree cached result to dump scripts to CSV
+ # def script_tree(self):
+ # if isinstance(self.tree, Miniscript):
+ # return b2a_hex(chains.tapscript_serialize(self.tree.compile())).decode()
+ #
+ # l, r = self.tree
+ # return "{" + l.script_tree() + "," +r.script_tree() + "}"
+
+ @classmethod
+ def read_from(cls, s):
+ c = s.read(1)
+ assert len(c)
+ if c == b"{": # more than one miniscript
+ left = cls.read_from(s)
+ c = s.read(1)
+ if c == b"}":
+ return left
+ if c != b",":
+ raise ValueError("Invalid tapscript: expected ','")
+
+ right = cls.read_from(s)
+ if s.read(1) != b"}":
+ raise ValueError("Invalid tapscript: expected '}'")
+
+ return cls((left, right))
+
+ s.seek(-1, 1)
+ ms = Miniscript.read_from(s, taproot=True)
+ return cls(ms)
+
+ def to_string(self, external=True, internal=True):
+ if isinstance(self.tree, Miniscript):
+ return self.tree.to_string(external, internal)
+
+ l, r = self.tree
+ return ("{" + l.to_string(external,internal) + ","
+ + r.to_string(external, internal) + "}")
class Descriptor:
- __slots__ = (
- "keys",
- "addr_fmt",
- )
+ def __init__(self, key=None, miniscript=None, tapscript=None, addr_fmt=None, keys=None):
+ if addr_fmt in [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH]:
+ assert miniscript
+ assert not key
+ else:
+ # single-sig + taproot/tapscript
+ assert miniscript is None
+ assert key
- def __init__(self, keys, addr_fmt):
- self.keys = keys
+ self.key = key
+ self.miniscript = miniscript
+ self.tapscript = tapscript
self.addr_fmt = addr_fmt
+ # cached keys
+ self._keys = keys
+
+ def validate(self, disable_checks=False):
+ # should only be run once while importing wallet
+ from glob import settings
+
+ c = 0
+ has_mine = 0
+ err_top_B = "Top level miniscript should be 'B'"
+ max_signers = 20
+
+ if self.tapscript:
+ assert self.key # internal key (would fail during parse)
+ max_signers = MAX_TR_SIGNERS
+ for l in self.tapscript.iter_leaves():
+ assert l.type == "B", err_top_B
+ l.verify()
+ l.is_sane(taproot=True)
+ # cannot have same keys in single miniscript
+ # provably unspendable taproot internal key is not covered here
+ assert len(l.keys) == len(set(l.keys)), "Insane"
+
+ elif self.miniscript:
+ assert self.key is None
+ assert self.miniscript.type == "B", err_top_B
+ self.miniscript.verify()
+ self.miniscript.is_sane(taproot=False)
+ # cannot have same keys in single miniscript
+ assert len(self.miniscript.keys) == len(set(self.miniscript.keys)), "Insane"
+
+ my_xfp = settings.get('xfp', 0)
+ ext_nums = set()
+ int_nums = set()
+ for k in self.keys:
+ has_mine += k.validate(my_xfp, disable_checks)
+ ext, int = k.derivation.get_ext_int()
+ ext_nums.add(ext)
+ int_nums.add(int)
+ c += 1
+
+ if not self.tapscript and not self.is_basic_multisig:
+ # this is non-taproot Miniscript
+ # Miniscript expressions can only be used in wsh or tr.
+ assert self.addr_fmt != AF_P2SH, "Miniscript in legacy P2SH not allowed"
+
+ assert ext_nums.isdisjoint(int_nums), "Non-disjoint multipath"
+ assert c <= max_signers, "max signers"
+
+ assert has_mine > 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper()
+
+ def bip388_wallet_policy(self):
+ # Return compact descriptor (BIP-388 style) template and key info
+ # - only same origin keys
+ keys_info = OrderedDict()
+
+ for k in self.keys:
+ pk = k.node.pubkey()
+ if pk not in keys_info:
+ keys_info[pk] = k.to_string(external=False, internal=False)
+
+ desc_tmplt = self.to_string(checksum=False).replace("/<0;1>/*", "/**")
+
+ keys_info = list(keys_info.values())
+ for i, k_str in enumerate(keys_info):
+ desc_tmplt = desc_tmplt.replace(k_str, '@%d' % i)
+
+ return desc_tmplt, keys_info
+
+ @property
+ def script_len(self):
+ if self.is_taproot:
+ return 34 # OP_1 <32:xonly>
+ if self.miniscript:
+ return len(self.miniscript)
+ if self.addr_fmt == AF_P2WPKH:
+ return 22 # 00 <20:pkh>
+ return 25 # OP_DUP OP_HASH160 <20:pkh> OP_EQUALVERIFY OP_CHECKSIG
+
+ def xfp_paths(self, skip_unspend_ik=False):
+ res = []
+ for k in self.keys:
+ if self.is_taproot and k.is_provably_unspendable and skip_unspend_ik:
+ continue
- @staticmethod
- def checksum_check(desc_w_checksum , csum_required=False):
- try:
- desc, checksum = desc_w_checksum.split("#")
- except ValueError:
- if csum_required:
- raise ValueError("Missing descriptor checksum")
- return desc_w_checksum, None
- calc_checksum = descriptor_checksum(desc)
- if calc_checksum != checksum:
- raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
- return desc, checksum
+ res.append(k.origin.psbt_derivation())
- @staticmethod
- def parse_key_orig_info(key):
- # key origin info is required for our MultisigWallet
- close_index = key.find("]")
- if key[0] != "[" or close_index == -1:
- raise ValueError("Key origin info is required for %s" % (key))
- key_orig_info = key[1:close_index] # remove brackets
- key = key[close_index + 1:]
- assert "/" in key_orig_info, "Malformed key derivation info"
- return key_orig_info, key
+ return res
- @staticmethod
- def parse_key_derivation_info(key):
- invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed"
- slash_split = key.split("/")
- assert len(slash_split) > 1, invalid_subderiv_msg
- if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]):
- assert slash_split[-1] == "*", invalid_subderiv_msg
- assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg
- assert len(slash_split[1:]) == 2, invalid_subderiv_msg
- return slash_split[0]
- else:
- raise ValueError("Cannot use hardened sub derivation path")
-
- def checksum(self):
- return descriptor_checksum(self._serialize())
-
- def serialize_keys(self, internal=False, int_ext=False):
- result = []
- for xfp, deriv, xpub in self.keys:
- if deriv[0] == "m":
- # get rid of 'm'
- deriv = deriv[1:]
- elif deriv[0] != "/":
- # input "84'/0'/0'" would lack slash separtor with xfp
- deriv = "/" + deriv
- if not isinstance(xfp, str):
- xfp = xfp2str(xfp)
- koi = xfp + deriv
- # normalize xpub to use h for hardened instead of '
- key_str = "[%s]%s" % (koi.lower(), xpub)
- if int_ext:
- key_str = key_str + "/" + "<0;1>" + "/" + "*"
- else:
- key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"])
- result.append(key_str.replace("'", "h"))
- return result
+ @property
+ def is_segwit_v0(self):
+ return self.addr_fmt in [AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH]
- def _serialize(self, internal=False, int_ext=False):
- """Serialize without checksum"""
- assert len(self.keys) == 1 # "Multiple keys for single signature script"
- desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt]
- inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0]
- return desc_base % (inner)
+ @property
+ def is_segwit(self):
+ return self.is_taproot or self.is_segwit_v0
- def serialize(self, internal=False, int_ext=False):
- """Serialize with checksum"""
- return append_checksum(self._serialize(internal=internal, int_ext=int_ext))
+ @property
+ def is_taproot(self):
+ return self.addr_fmt == AF_P2TR
- @classmethod
- def parse(cls, desc_w_checksum):
- # remove garbage
- desc_w_checksum = parse_desc_str(desc_w_checksum)
- # check correct checksum
- desc, checksum = cls.checksum_check(desc_w_checksum)
- # legacy
- if desc.startswith("pkh("):
- addr_fmt = AF_CLASSIC
- tmp_desc = desc.replace("pkh(", "")
- tmp_desc = tmp_desc.rstrip(")")
-
- # native segwit
- elif desc.startswith("wpkh("):
- addr_fmt = AF_P2WPKH
- tmp_desc = desc.replace("wpkh(", "")
- tmp_desc = tmp_desc.rstrip(")")
-
- # wrapped segwit
- elif desc.startswith("sh(wpkh("):
- addr_fmt = AF_P2WPKH_P2SH
- tmp_desc = desc.replace("sh(wpkh(", "")
- tmp_desc = tmp_desc.rstrip("))")
+ @property
+ def is_legacy_sh(self):
+ return self.addr_fmt in [AF_P2SH, AF_P2WSH_P2SH, AF_P2WPKH_P2SH]
+
+ @property
+ def is_basic_multisig(self):
+ return self.miniscript and self.miniscript.NAME in ["multi", "sortedmulti"]
+
+ @property
+ def is_sortedmulti(self):
+ return self.is_basic_multisig and self.miniscript.NAME == "sortedmulti"
+
+ @property
+ def keys(self):
+ if self._keys:
+ return self._keys
+
+ if self.tapscript:
+ # internal is always first
+ # use ordered dict as order preserving set
+ keys = OrderedDict()
+ # add internal key
+ keys[self.key] = None
+ # taptree keys
+ for lv in self.tapscript.iter_leaves():
+ for k in lv.keys:
+ keys[k] = None
+
+ self._keys = list(keys)
+
+ elif self.miniscript:
+ self._keys = self.miniscript.keys
else:
- raise ValueError("Unsupported descriptor. Supported: pkh(), wpkh(), sh(wpkh()).")
+ # single-sig
+ self._keys = [self.key]
+
+ return self._keys
+
+ def derive(self, idx=None, change=False):
+ if self.is_taproot:
+ # derive keys first
+ # duplicate keys can be may be found in different leaves
+ # use map to derive each key just once
+ derived_keys = OrderedDict()
+ ikd = None
+ for i, k in enumerate(self.keys):
+ dk = k.derive(idx, change=change)
+ dk.taproot = self.is_taproot
+ derived_keys[k] = dk
+ if not i:
+ # internal key is always at index 0 in self.keys
+ ikd = dk
+
+ return type(self)(
+ ikd,
+ tapscript=self.tapscript.derive(idx, derived_keys, change=change),
+ addr_fmt=self.addr_fmt,
+ keys=list(derived_keys.values()),
+ )
+ if self.miniscript:
+ return type(self)(
+ None,
+ self.miniscript.derive(idx, change=change),
+ addr_fmt=self.addr_fmt,
+ )
+
+ # single-sig
+ return type(self)(self.key.derive(idx, change=change))
+
+ def script_pubkey(self, compiled_scr=None):
+ if self.is_taproot:
+ tweak = None
+ if self.tapscript:
+ tweak = self.tapscript.merkle_root
+ output_pubkey = chains.taptweak(self.key.serialize(), tweak)
+ return b"\x51\x20" + output_pubkey
+
+ if self.is_legacy_sh:
+ if self.miniscript:
+ # caller may have already built a script
+ scr = compiled_scr or self.miniscript.compile()
+ redeem_scr = scr
+ if self.addr_fmt == AF_P2WSH_P2SH:
+ redeem_scr = b"\x00\x20" + ngu.hash.sha256s(scr)
+ else:
+ redeem_scr = b"\x00\x14" + ngu.hash.hash160(self.key.node.pubkey())
+
+ return b"\xa9\x14" + ngu.hash.hash160(redeem_scr) + b"\x87"
- koi, key = cls.parse_key_orig_info(tmp_desc)
- if key[0:4] not in ["tpub", "xpub"]:
- raise ValueError("Only extended public keys are supported")
+ if self.addr_fmt == AF_P2WSH:
+ # witness script p2wsh only
+ return b"\x00\x20" + ngu.hash.sha256s(compiled_scr or self.miniscript.compile())
- xpub = cls.parse_key_derivation_info(key)
- xfp = str2xfp(koi[:8])
- origin_deriv = "m" + koi[8:]
+ if self.addr_fmt == AF_P2WPKH:
+ return b"\x00\x14" + ngu.hash.hash160(self.key.serialize())
- return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt)
+ # p2pkh
+ assert self.addr_fmt == AF_CLASSIC
+ return b"\x76\xa9\x14" + ngu.hash.hash160(self.key.serialize()) + b"\x88\xac"
@classmethod
def is_descriptor(cls, desc_str):
@@ -267,142 +334,132 @@ def is_descriptor(cls, desc_str):
return True
return False
- def bitcoin_core_serialize(self, external_label=None):
+ @staticmethod
+ def checksum_check(desc_w_checksum, csum_required=False):
+ try:
+ desc, checksum = desc_w_checksum.split("#")
+ except ValueError:
+ if csum_required:
+ raise ValueError("Missing descriptor checksum")
+ return desc_w_checksum, None
+ calc_checksum = descriptor_checksum(desc)
+ if calc_checksum != checksum:
+ raise ValueError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
+ return desc, checksum
+
+ @classmethod
+ def from_string(cls, desc, checksum=False):
+ desc = parse_desc_str(desc)
+ desc, cs = cls.checksum_check(desc)
+ s = BytesIO(desc.encode())
+ res = cls.read_from(s)
+ left = s.read()
+ if len(left) > 0:
+ raise ValueError("Unexpected characters after descriptor: %r" % left)
+ if checksum:
+ if cs is None:
+ _, cs = res.to_string().split("#")
+ return res, cs
+ return res
+
+ @classmethod
+ def read_from(cls, s):
+ start = s.read(8)
+ af = AF_CLASSIC
+ internal_key = None
+ tapscript = None
+ if start.startswith(b"tr("):
+ af = AF_P2TR
+ s.seek(-5, 1)
+ internal_key = Key.parse(s)
+ internal_key.taproot = True
+ sep = s.read(1)
+ if sep == b")":
+ s.seek(-1, 1)
+ else:
+ assert sep == b","
+ tapscript = Tapscript.read_from(s)
+
+ elif start.startswith(b"sh(wsh("):
+ af = AF_P2WSH_P2SH
+ s.seek(-1, 1)
+ elif start.startswith(b"wsh("):
+ af = AF_P2WSH
+ s.seek(-4, 1)
+ elif start.startswith(b"sh(wpkh("):
+ af = AF_P2WPKH_P2SH
+ elif start.startswith(b"wpkh("):
+ af = AF_P2WPKH
+ s.seek(-3, 1)
+ elif start.startswith(b"pkh("):
+ s.seek(-4, 1)
+ elif start.startswith(b"sh("):
+ af = AF_P2SH
+ s.seek(-5, 1)
+ else:
+ raise ValueError("Invalid descriptor")
+
+ miniscript = None
+ if af == AF_P2TR:
+ key = internal_key
+ nbrackets = 1
+ elif af in [AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH]:
+ miniscript = Miniscript.read_from(s)
+ key = internal_key
+ nbrackets = 1 + int(af == AF_P2WSH_P2SH)
+ else:
+ key = Key.parse(s)
+ nbrackets = 1 + int(af == AF_P2WPKH_P2SH)
+
+ end = s.read(nbrackets)
+ if end != b")" * nbrackets:
+ raise ValueError("Invalid descriptor")
+
+ desc = cls(key, miniscript, tapscript, af)
+ return desc
+
+ def to_string(self, external=True, internal=True, checksum=True):
+ if self.is_taproot:
+ desc = "tr(%s" % self.key.to_string(external, internal)
+ if self.tapscript:
+ desc += ","
+ tree = self.tapscript.to_string(external, internal)
+ desc += tree
+
+ res = desc + ")"
+
+ else:
+ if self.miniscript is not None:
+ res = self.miniscript.to_string(external, internal)
+ if self.addr_fmt in [AF_P2WSH, AF_P2WSH_P2SH]:
+ res = "wsh(%s)" % res
+ else:
+ if self.addr_fmt in [AF_P2WPKH, AF_P2WPKH_P2SH]:
+ res = "wpkh(%s)" % self.key.to_string(external, internal)
+ else:
+ res = "pkh(%s)" % self.key.to_string(external, internal)
+
+ if self.is_legacy_sh:
+ res = "sh(%s)" % res
+
+ if checksum:
+ res = append_checksum(res)
+ return res
+
+ def bitcoin_core_serialize(self):
# this will become legacy one day
# instead use <0;1> descriptor format
res = []
- for internal in [False, True]:
+ for external in (True, False):
desc_obj = {
- "desc": self.serialize(internal=internal),
+ "desc": self.to_string(external, not external),
"active": True,
"timestamp": "now",
- "internal": internal,
+ "internal": not external,
"range": [0, 100],
}
- if internal is False and external_label:
- desc_obj["label"] = external_label
res.append(desc_obj)
return res
-
-class MultisigDescriptor(Descriptor):
- # only supprt with key derivation info
- # only xpubs
- # can be extended when needed
- __slots__ = (
- "M",
- "N",
- "keys",
- "addr_fmt",
- "is_sorted" # whether to use sortedmulti() or multi()
- )
-
- def __init__(self, M, N, keys, addr_fmt, is_sorted=True):
- self.M = M
- self.N = N
- self.is_sorted = is_sorted
- super().__init__(keys, addr_fmt)
-
- @classmethod
- def parse(cls, desc_w_checksum):
- # remove garbage
- desc_w_checksum = parse_desc_str(desc_w_checksum)
- # check correct checksum
- desc, checksum = cls.checksum_check(desc_w_checksum)
- is_sorted = "sortedmulti(" in desc
- rplc = "sortedmulti(" if is_sorted else "multi("
-
- # wrapped segwit
- if desc.startswith("sh(wsh("+rplc):
- addr_fmt = AF_P2WSH_P2SH
- tmp_desc = desc.replace("sh(wsh("+rplc, "")
- tmp_desc = tmp_desc.rstrip(")))")
-
- # native segwit
- elif desc.startswith("wsh("+rplc):
- addr_fmt = AF_P2WSH
- tmp_desc = desc.replace("wsh("+rplc, "")
- tmp_desc = tmp_desc.rstrip("))")
-
- # legacy
- elif desc.startswith("sh("+rplc):
- addr_fmt = AF_P2SH
- tmp_desc = desc.replace("sh("+rplc, "")
- tmp_desc = tmp_desc.rstrip("))")
-
- else:
- raise ValueError("Unsupported descriptor. Supported: sh(), sh(wsh()), wsh().")
-
- splitted = tmp_desc.split(",")
- M, keys = int(splitted[0]), splitted[1:]
- N = int(len(keys))
- if M > N:
- raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N))
-
- res_keys = []
- for key in keys:
- koi, key = cls.parse_key_orig_info(key)
- if key[0:4] not in ["tpub", "xpub"]:
- raise ValueError("Only extended public keys are supported")
-
- xpub = cls.parse_key_derivation_info(key)
- xfp = str2xfp(koi[:8])
- origin_deriv = "m" + koi[8:]
- res_keys.append((xfp, origin_deriv, xpub))
-
- return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, is_sorted=is_sorted)
-
- def _serialize(self, internal=False, int_ext=False):
- """Serialize without checksum"""
- desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt]
- _type = "sortedmulti" if self.is_sorted else "multi"
- _type += "(%s)"
- desc_base = desc_base % _type
- assert len(self.keys) == self.N
- inner = str(self.M) + "," + ",".join(
- self.serialize_keys(internal=internal, int_ext=int_ext))
-
- return desc_base % (inner)
-
- def pretty_serialize(self):
- """Serialize in pretty and human-readable format"""
- _type = "sortedmulti" if self.is_sorted else "multi"
- res = "# Coldcard descriptor export\n"
- if self.is_sorted:
- res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n"
- else:
- res += ("# !!! DANGER: order of keys in descriptor MUST be preserved. "
- "Correct order of keys is required to compose valid redeem/witness script.\n")
- if self.addr_fmt == AF_P2SH:
- res += "# bare multisig - p2sh\n"
- res += "sh("+_type+"(\n%s\n))"
- # native segwit
- elif self.addr_fmt == AF_P2WSH:
- res += "# native segwit - p2wsh\n"
- res += "wsh("+_type+"(\n%s\n))"
-
- # wrapped segwit
- elif self.addr_fmt == AF_P2WSH_P2SH:
- res += "# wrapped segwit - p2sh-p2wsh\n"
- res += "sh(wsh(" + _type + "(\n%s\n)))"
- else:
- raise ValueError("Malformed descriptor")
-
- assert len(self.keys) == self.N
- inner = "\t" + "# %d of %d (%s)\n" % (
- self.M, self.N,
- "requires all participants to sign" if self.M == self.N else "threshold")
- inner += "\t" + str(self.M) + ",\n"
- ser_keys = self.serialize_keys()
- for i, key_str in enumerate(ser_keys, start=1):
- if i == self.N:
- inner += "\t" + key_str
- else:
- inner += "\t" + key_str + ",\n"
-
- checksum = self.serialize().split("#")[1]
-
- return (res % inner) + "#" + checksum
-
# EOF
diff --git a/shared/display.py b/shared/display.py
index bf8196834..787e117d9 100644
--- a/shared/display.py
+++ b/shared/display.py
@@ -4,9 +4,10 @@
#
import machine, uzlib, ckcc, utime
from ssd1306 import SSD1306_SPI
-from version import is_devmode
+from version import is_devmode, is_edge
import framebuf
from graphics_mk4 import Graphics
+from charcodes import OUT_CTRL_TITLE, OUT_CTRL_ADDRESS
# we support 4 fonts
from zevvpeep import FontSmall, FontLarge, FontTiny
@@ -75,7 +76,7 @@ def text(self, x,y, msg, font=FontSmall, invert=0):
if x is None or x < 0:
# center/rjust
w = self.width(msg, font)
- if x == None:
+ if x is None:
x = max(0, (self.WIDTH - w) // 2)
else:
# measure from right edge (right justify)
@@ -146,14 +147,23 @@ def scroll_bar(self, offset, count, per_page):
self.text(-2, 21, 'D', font=FontTiny, invert=1)
self.text(-2, 28, 'E', font=FontTiny, invert=1)
self.text(-2, 35, 'V', font=FontTiny, invert=1)
+ elif is_edge:
+ self.dis.fill_rect(128 - 6, 19, 5, 26, 1)
+ self.text(-2, 20, 'E', font=FontTiny, invert=1)
+ self.text(-2, 27, 'D', font=FontTiny, invert=1)
+ self.text(-2, 33, 'G', font=FontTiny, invert=1)
+ self.text(-2, 39, 'E', font=FontTiny, invert=1)
def fullscreen(self, msg, percent=None, line2=None):
# show a simple message "fullscreen".
- # - 'line2' not supported on smaller screen sizes, ignore
self.clear()
y = 14
self.text(None, y, msg, font=FontLarge)
+ if line2:
+ # 21 + 6 ie. FontLarge.height of above text + FontTiny.height as space between
+ self.text(None, y + 27, line2, font=FontSmall)
+
if percent is not None:
self.progress_bar(percent)
self.show()
@@ -265,17 +275,18 @@ def menu_draw(self, ry, msg, is_sel, is_checked, space_indicators):
if is_sel:
self.dis.fill_rect(0, y, Display.WIDTH, h-1, 1)
self.icon(2, y, 'wedge', invert=1)
- self.text(x, y, msg, invert=1)
+ nx = self.text(x, y, msg, invert=1)
else:
- self.text(x, y, msg)
+ nx = self.text(x, y, msg)
# LATER: removed because caused confusion w/ underscore
#if msg[0] == ' ' and space_indicators:
# see also graphics/mono/space.txt
#self.icon(x-2, y+9, 'space', invert=is_sel)
- if is_checked:
- self.icon(108, y, 'selected', invert=is_sel)
+ if is_checked and nx <= 113:
+ # omit checkmark if it doesn't fit
+ self.icon(113, y, 'selected', invert=is_sel)
def menu_show(self, *a):
self.show()
@@ -304,9 +315,14 @@ def draw_story(self, lines, top, num_lines, is_sensitive, **ignored):
for ln in lines:
if ln == 'EOT':
self.hline(y+3)
- elif ln and ln[0] == '\x01':
+ elif ln and ln[0] == OUT_CTRL_TITLE:
self.text(0, y, ln[1:], FontLarge)
y += 21
+ elif ln and ln[0] == OUT_CTRL_ADDRESS:
+ from utils import chunk_address
+ fmt = '\u2009'.join(chunk_address(ln[1:]))
+ self.text(14, y, fmt) # fixed indent, to be centered
+ y += 15 # a bit extra vertical line height
else:
self.text(0, y, ln)
@@ -322,13 +338,25 @@ def draw_status(self, **k):
# no status bar on Mk4
return
- def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert):
+ def draw_qr_error(self, idx_hint, msg):
+ self.clear()
+ lm = 4
+ bw = 54
+ y = (self.HEIGHT - bw) // 2
+ # empty rectangle
+ self.dis.fill_rect(lm, y, bw, bw, 1)
+ self.dis.fill_rect(lm+1, y+1, bw-2, bw-2, 0)
+ # error in rectangle - handpicked position
+ self.text(lm+5,y+10, "QR too")
+ self.text(lm+16,y+24, "big")
+ self._draw_qr_display(bw, lm, msg, False, None, idx_hint, False)
+
+ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert,
+ is_addr=False, force_msg=False, is_change=False):
# 'sidebar' is a pre-formated obj to show to right of QR -- oled life
# - 'msg' will appear to right if very short, else under in tiny
- from utils import word_wrap
-
+ # - ignores "is_addr" because exactly zero space to do anything special
self.clear()
-
w = qr_data.width()
if w <= 29:
# version 1,2,3 => we can double-up the pixels
@@ -368,13 +396,23 @@ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert):
gly = framebuf.FrameBuffer(bytearray(packed), w, w, framebuf.MONO_HLSB)
self.dis.blit(gly, XO, YO, 1)
+ self._draw_qr_display(bw, lm, msg, is_alnum, sidebar, idx_hint, invert, is_addr, is_change)
+
+ def _draw_qr_display(self, bw, lm, msg, is_alnum, sidebar, idx_hint, invert,
+ is_addr=False, is_change=False):
+ # does not draw actual QR, but all other things in the screen
+ from utils import word_wrap
+
if not sidebar and not msg:
pass
- elif not sidebar and len(msg) > (5*7):
+ elif not sidebar and ((len(msg) > (5*7)) or is_change):
# use FontTiny and word wrap (will just split if no spaces)
+ # native segwit addresses and taproot
+ # if is_change=True also p2pkh and p2sh fall into this category as space is needed for "CHANGE"
x = bw + lm + 4
ww = ((128 - x)//4) - 1 # char width avail
y = 1
+
parts = list(word_wrap(msg, ww))
if len(parts) > 8:
parts = parts[:8]
@@ -385,9 +423,13 @@ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert):
for line in parts:
self.text(x, y, line, FontTiny)
y += 8
+
+ if is_addr and is_change:
+ self.text(x+4, y+8, "CHANGE BACK", FontTiny)
else:
# hand-positioned for known cases
# - sidebar = (text, #of char per line)
+ # p2pkh and p2sh addresses (if is_change=False)
x, y = 73, (0 if is_alnum else 2)
dy = 10 if is_alnum else 12
sidebar, ll = sidebar if sidebar else (msg, 7)
diff --git a/shared/drv_entro.py b/shared/drv_entro.py
index 3fad9dae4..8f52d94e7 100644
--- a/shared/drv_entro.py
+++ b/shared/drv_entro.py
@@ -11,8 +11,8 @@
from menu import MenuItem, MenuSystem
from ubinascii import hexlify as b2a_hex
from ubinascii import b2a_base64
-from auth import write_sig_file
-from utils import chunk_writer, xfp2str, swab32
+from msgsign import write_sig_file
+from utils import xfp2str, swab32
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
BIP85_PWD_LEN = 21
@@ -56,32 +56,32 @@ async def drv_entro_start(*a):
def bip85_derive(picked, index):
# implement the core step of BIP85 from our master secret
-
+ path = "m/83696968h/"
if picked in (0,1,2):
# BIP-39 seed phrases (we only support English)
num_words = stash.SEED_LEN_OPTS[picked]
width = (16, 24, 32)[picked] # of bytes
- path = "m/83696968h/39h/0h/{num_words}h/{index}h".format(num_words=num_words, index=index)
+ path += "39h/0h/%dh/%dh" % (num_words, index)
s_mode = 'words'
elif picked == 3:
- # HDSeed for Bitcoin Core: but really a WIF of a private key, can be used anywhere
+ # HDSeed for Bitcoin Core: but really a WIF of a private key
s_mode = 'wif'
- path = "m/83696968h/2h/{index}h".format(index=index)
+ path += "2h/%dh" % index
width = 32
elif picked == 4:
# New XPRV
- path = "m/83696968h/32h/{index}h".format(index=index)
+ path += "32h/%dh" % index
s_mode = 'xprv'
width = 64
elif picked in (5, 6):
width = 32 if picked == 5 else 64
- path = "m/83696968h/128169h/{width}h/{index}h".format(width=width, index=index)
+ path += "128169h/%dh/%dh" % (width, index)
s_mode = 'hex'
elif picked == 7:
width = 64
# hardcoded width for now
# b"pwd".hex() --> 707764
- path = "m/83696968h/707764h/{pwd_len}h/{index}h".format(pwd_len=BIP85_PWD_LEN, index=index)
+ path += "707764h/%dh/%dh" % (BIP85_PWD_LEN, index)
s_mode = 'pw'
else:
raise ValueError(picked)
@@ -161,7 +161,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
qr_alnum = True
msg = 'Seed words (%d):\n' % len(words)
- msg += ux_render_words(words)
+ msg += ux_render_words(words, leading_blanks=1)
encoded = stash.SecretStash.encode(seed_phrase=new_secret)
@@ -226,12 +226,13 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
choice = import_export_prompt_decode(ch)
if isinstance(choice, dict):
# write to SD card or Virtual Disk: simple text file
+ dis.fullscreen("Saving...")
try:
with CardSlot(**choice) as card:
fname, out_fn = card.pick_filename('drv-%s-idx%d.txt' % (s_mode, index))
body = msg + "\n"
with open(fname, 'wt') as fp:
- chunk_writer(fp, body)
+ fp.write(body)
h = ngu.hash.sha256s(body.encode())
sig_nice = write_sig_file([(h, fname)], derive=path)
@@ -240,7 +241,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
await needs_microsd()
continue
except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
+ await ux_show_story('Failed to write!\n\n'+str(e))
continue
story = "Filename is:\n\n%s" % out_fn
@@ -250,7 +251,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
break
elif choice == KEY_QR:
from ux import show_qr_code
- await show_qr_code(qr, qr_alnum)
+ await show_qr_code(qr, qr_alnum, is_secret=True)
elif choice == '0':
if s_mode == 'pw':
# gets confirmation then types it
@@ -263,14 +264,14 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False):
xfp_str = xfp2str(settings.get("xfp", 0))
await seed.set_ephemeral_seed(
encoded,
- meta='BIP85 Derived from [%s], index=%d' % (xfp_str, index)
+ origin='BIP85 Derived from [%s], index=%d' % (xfp_str, index)
)
goto_top_menu()
break
elif NFC and choice == KEY_NFC:
# Share any of these over NFC
- await NFC.share_text(qr)
+ await NFC.share_text(qr, is_secret=True)
stash.blank_object(msg)
stash.blank_object(new_secret)
diff --git a/shared/exceptions.py b/shared/exceptions.py
index 2d92d1366..6f84242dd 100644
--- a/shared/exceptions.py
+++ b/shared/exceptions.py
@@ -19,10 +19,10 @@ class CCBusyError(RuntimeError):
# HSM is blocking your action
class HSMDenied(RuntimeError):
pass
-
class HSMCMDDisabled(RuntimeError):
pass
+
# PSBT / transaction related
class FatalPSBTIssue(RuntimeError):
pass
@@ -51,4 +51,12 @@ class QRDecodeExplained(ValueError):
class UnknownAddressExplained(ValueError):
pass
+# We're not going to (co-)sign using spending policy features
+class SpendPolicyViolation(RuntimeError):
+ pass
+
+# data too big for simple QR
+class QRTooBigError(ValueError):
+ pass
+
# EOF
diff --git a/shared/export.py b/shared/export.py
index 5760cc9ff..74e466dad 100644
--- a/shared/export.py
+++ b/shared/export.py
@@ -5,23 +5,25 @@
import stash, chains, version, ujson, ngu
from uio import StringIO
from ucollections import OrderedDict
-from utils import xfp2str, swab32, chunk_writer
-from ux import ux_show_story
+from utils import xfp2str, swab32, problem_file_line
+from ux import ux_show_story, import_export_prompt
from glob import settings
-from auth import write_sig_file
-from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
+from msgsign import write_sig_file
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR
from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
from ownership import OWNERSHIP
+from exceptions import QRTooBigError
-async def export_by_qr(body, label, type_code):
+async def export_by_qr(body, label, type_code, force_bbqr=False):
# render as QR and show on-screen
from ux import show_qr_code
try:
- # ignore label/title - provides no useful info
- # makes qr smaller and harder to read
+ if force_bbqr or len(body) > 2000:
+ raise QRTooBigError
+
await show_qr_code(body)
- except (ValueError, RuntimeError, TypeError):
+ except QRTooBigError:
if version.has_qwerty:
# do BBQr on Q
from ux_q1 import show_bbqr_codes
@@ -31,6 +33,81 @@ async def export_by_qr(body, label, type_code):
return
+
+async def export_contents(title, contents, fname_pattern, derive=None, addr_fmt=None,
+ is_json=False, force_bbqr=False, force_prompt=False, direct_way=None):
+ # export text and json files while offering NFC, QR & Vdisk
+ # produces signed export in case of SD/Vdisk (signed with key at deriv and addr_fmt)
+ # checks if suitable to offer QR export on Mk4
+ # argument contents can support function that generates content
+ # argument direct way can be KEY_{NFC,QR}, any other truth value is SD/Vdisk,
+ # if None ask for way via UX story
+ from glob import dis, NFC, VD
+ from files import CardSlot, CardMissingError, needs_microsd
+ from qrs import MAX_V11_CHAR_LIMIT
+
+ if callable(contents):
+ dis.fullscreen('Generating...')
+ contents, derive, addr_fmt = contents()
+
+ # figure out if offering QR code export make sense given HW
+ # len() is O(1)
+ no_qr = not version.has_qwerty and (len(contents) >= MAX_V11_CHAR_LIMIT)
+
+ if addr_fmt == AF_P2TR:
+ sig = None
+ else:
+ sig = not (derive is None and addr_fmt is None)
+
+ ch = direct_way # set it to direct way only once, outside the loop
+ while True:
+ if direct_way is None:
+ ch = await import_export_prompt("%s file" % title,
+ force_prompt=force_prompt, no_qr=no_qr)
+ if ch == KEY_CANCEL:
+ break
+ elif ch == KEY_QR:
+ await export_by_qr(contents, title, "J" if is_json else "U", force_bbqr=force_bbqr)
+ elif ch == KEY_NFC:
+ if is_json:
+ await NFC.share_json(contents)
+ else:
+ await NFC.share_text(contents)
+ else:
+ # SD/VDisk
+ # choose a filename
+ try:
+ dis.fullscreen("Saving...")
+ with CardSlot(**ch) as card:
+ fname, nice = card.pick_filename(fname_pattern)
+
+ # do actual write
+ with open(fname, 'wt' if is_json else 'wb') as fd:
+ fd.write(contents)
+
+ if sig:
+ h = ngu.hash.sha256s(contents.encode())
+ sig_nice = write_sig_file([(h, fname)], derive, addr_fmt)
+
+ msg = '%s file written:\n\n%s' % (title, nice)
+ if sig:
+ msg += "\n\n%s signature file written:\n\n%s" % (title, sig_nice)
+
+ await ux_show_story(msg)
+
+ except CardMissingError:
+ await needs_microsd()
+ except Exception as e:
+ await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e)))
+
+ # both exceptions & success gets here
+ if no_qr and (NFC is None) and (VD is None) and not force_prompt:
+ # user has no other ways enabled, we already exported to SD - done
+ return
+
+ if direct_way:
+ return
+
def generate_public_contents():
# Generate public details about wallet.
#
@@ -73,14 +150,7 @@ def generate_public_contents():
sym=chain.ctype, ct=chain.b44_cointype, xfp=xfp))
for name, path, addr_fmt in chains.CommonDerivations:
-
- if '{coin_type}' in path:
- path = path.replace('{coin_type}', str(chain.b44_cointype))
-
- if '{' in name:
- name = name.format(core_name=chain.core_name)
-
- show_slip132 = ('Core' not in name)
+ path = path.replace('{coin_type}', str(chain.b44_cointype))
yield ('''## For {name}: {path}\n\n'''.format(name=name, path=path))
yield ('''First %d receive addresses (account=0, change=0):\n\n''' % num_rx)
@@ -103,7 +173,7 @@ def generate_public_contents():
node = sv.derive_path(hard_sub, register=False)
yield ("%s => %s\n" % (hard_sub, chain.serialize_public(node)))
- if show_slip132 and addr_fmt != AF_CLASSIC 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)))
@@ -120,59 +190,13 @@ def generate_public_contents():
yield ('\n\n')
- from multisig import MultisigWallet
- if MultisigWallet.exists():
- yield '\n# Your Multisig Wallets\n\n'
-
- for ms in MultisigWallet.get_all():
- fp = StringIO()
-
- ms.render_export(fp)
- print("\n---\n", file=fp)
-
- yield fp.getvalue()
- del fp
-
-async def write_text_file(fname_pattern, body, title, derive, addr_fmt):
- # Export data as a text file.
- from glob import dis, NFC
- from files import CardSlot, CardMissingError, needs_microsd
- from ux import import_export_prompt
-
- choice = await import_export_prompt("%s file" % title, is_import=False,
- no_qr=(not version.has_qwerty))
- if choice == KEY_CANCEL:
- return
- elif choice == KEY_QR:
- await export_by_qr(body, title, "U")
- return
- elif choice == KEY_NFC:
- await NFC.share_text(body)
- return
-
- # choose a filename
- try:
- dis.fullscreen("Saving...")
- with CardSlot(**choice) as card:
- fname, nice = card.pick_filename(fname_pattern)
-
- # do actual write
- with open(fname, 'wb') as fd:
- chunk_writer(fd, body)
-
- h = ngu.hash.sha256s(body.encode())
- sig_nice = write_sig_file([(h, fname)], derive, addr_fmt)
+ from wallet import MiniScriptWallet
+ if MiniScriptWallet.exists():
+ yield '\n# Your Multisig/Miniscript Wallets\n\n'
- except CardMissingError:
- await needs_microsd()
- return
- except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
- return
+ for msc in MiniScriptWallet.iter_wallets():
+ yield msc.to_string() + "\n---\n"
- msg = '%s file written:\n\n%s\n\n%s signature file written:\n\n%s' % (title, nice, title,
- sig_nice)
- await ux_show_story(msg)
async def make_summary_file(fname_pattern='public.txt'):
from glob import dis
@@ -183,7 +207,7 @@ async def make_summary_file(fname_pattern='public.txt'):
# generator function:
body = "".join(list(generate_public_contents()))
ch = chains.current_chain()
- await write_text_file(fname_pattern, body, 'Summary',
+ await export_contents('Summary', body, fname_pattern,
"m/44h/%dh/0h/0/0" % ch.b44_cointype,
AF_CLASSIC)
@@ -195,10 +219,11 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx
# make the data
examples = []
- imp_multi, imp_desc = generate_bitcoin_core_wallet(account_num, examples)
+ imp_multi, imp_desc, imp_desc_tr = generate_bitcoin_core_wallet(account_num, examples)
imp_multi = ujson.dumps(imp_multi)
imp_desc = ujson.dumps(imp_desc)
+ imp_desc_tr = ujson.dumps(imp_desc_tr)
body = '''\
# Bitcoin Core Wallet Import File
@@ -214,7 +239,10 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx
The following command can be entered after opening Window -> Console
in Bitcoin Core, or using bitcoin-cli:
-importdescriptors '{imp_desc}'
+p2wpkh:
+ importdescriptors '{imp_desc}'
+p2tr:
+ importdescriptors '{imp_desc_tr}'
> **NOTE** If your UTXO was created before generating `importdescriptors` command, you should adjust the value of `timestamp` before executing command in bitcoin core.
By default it is set to `now` meaning do not rescan the blockchain. If approximate time of UTXO creation is known - adjust `timestamp` from `now` to UNIX epoch time.
@@ -229,59 +257,80 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx
## Resulting Addresses (first 3)
-'''.format(imp_multi=imp_multi, imp_desc=imp_desc, xfp=xfp, nb=chains.current_chain().name)
+'''.format(imp_multi=imp_multi, imp_desc=imp_desc, imp_desc_tr=imp_desc_tr,
+ xfp=xfp, nb=chains.current_chain().name)
body += '\n'.join('%s => %s' % t for t in examples)
body += '\n'
OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num)
+ OWNERSHIP.note_wallet_used(AF_P2TR, account_num)
ch = chains.current_chain()
derive = "84h/{coin_type}h/{account}h".format(account=account_num, coin_type=ch.b44_cointype)
- await write_text_file(fname_pattern, body, 'Bitcoin Core', derive + "/0/0", AF_P2WPKH)
+ await export_contents('Bitcoin Core', body, fname_pattern, derive + "/0/0", AF_P2WPKH)
def generate_bitcoin_core_wallet(account_num, example_addrs):
# Generate the data for an RPC command to import keys into Bitcoin Core
# - yields dicts for json purposes
- from descriptor import Descriptor
+ from descriptor import Descriptor, Key
chain = chains.current_chain()
- derive = "84h/{coin_type}h/{account}h".format(account=account_num,
- coin_type=chain.b44_cointype)
-
+ derive_v0 = "84h/{coin_type}h/{account}h".format(
+ account=account_num, coin_type=chain.b44_cointype
+ )
+ derive_v1 = "86h/{coin_type}h/{account}h".format(
+ account=account_num, coin_type=chain.b44_cointype
+ )
with stash.SensitiveValues() as sv:
- prefix = sv.derive_path(derive)
- xpub = chain.serialize_public(prefix)
+ prefix = sv.derive_path(derive_v0)
+ xpub_v0 = chain.serialize_public(prefix)
for i in range(3):
sp = '0/%d' % i
node = sv.derive_path(sp, master=prefix)
a = chain.address(node, AF_P2WPKH)
- example_addrs.append( ('m/%s/%s' % (derive, sp), a) )
+ example_addrs.append(('m/%s/%s' % (derive_v0, sp), a))
+
+ with stash.SensitiveValues() as sv:
+ prefix = sv.derive_path(derive_v1)
+ xpub_v1 = chain.serialize_public(prefix)
+
+ for i in range(3):
+ sp = '0/%d' % i
+ node = sv.derive_path(sp, master=prefix)
+ a = chain.address(node, AF_P2TR)
+ example_addrs.append(('m/%s/%s' % (derive_v1, sp), a))
xfp = settings.get('xfp')
- _, vers, _ = version.get_mpy_version()
+ key0 = Key.from_cc_data(xfp, derive_v0, xpub_v0)
+ desc_v0 = Descriptor(key=key0, addr_fmt=AF_P2WPKH)
+
+ key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1)
+ desc_v1 = Descriptor(key=key1, addr_fmt=AF_P2TR)
OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num)
+ OWNERSHIP.note_wallet_used(AF_P2TR, account_num)
- desc_obj = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=AF_P2WPKH)
# for importmulti
imm_list = [
{
- 'desc': desc_obj.serialize(internal=internal),
+ 'desc': desc_v0.to_string(external, internal),
'range': [0, 1000],
'timestamp': 'now',
'internal': internal,
'keypool': True,
'watchonly': True
}
- for internal in [False, True]
+ for external, internal in [(True, False), (False, True)]
]
# for importdescriptors
- imd_list = desc_obj.bitcoin_core_serialize()
- return imm_list, imd_list
+ imd_list = desc_v0.bitcoin_core_serialize()
+ imd_list_v1 = desc_v1.bitcoin_core_serialize()
+ return imm_list, imd_list, imd_list_v1
+
def generate_wasabi_wallet():
# Generate the data for a JSON file which Wasabi can open directly as a new wallet.
@@ -319,20 +368,16 @@ def generate_unchained_export(account_num=0):
# - no account numbers (at this level)
chain = chains.current_chain()
- todo = [
- ( "m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH ),
- ( "m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH),
- ( "m/45h", 'p2sh', AF_P2SH), # if acct_num == 0
- ]
-
xfp = xfp2str(settings.get('xfp', 0))
rv = OrderedDict(xfp=xfp, account=account_num)
-
+ sign_der = None
with stash.SensitiveValues() as sv:
- for deriv, name, fmt in todo:
+ for name, deriv, fmt in chains.MS_STD_DERIVATIONS:
if fmt == AF_P2SH and account_num:
continue
dd = deriv.format(coin=chain.b44_cointype, acct_num=account_num)
+ if fmt == AF_P2WSH:
+ sign_der = dd + "/0/0"
node = sv.derive_path(dd)
xp = chain.serialize_public(node, fmt)
@@ -341,13 +386,11 @@ def generate_unchained_export(account_num=0):
rv['%s_deriv' % name] = dd
rv[name] = xp
- # sig_deriv = "m/44'/{ct}'/{acc}'".format(ct=chain.b44_cointype, acc=account_num) + "/0/0"
- # return ujson.dumps(rv), sig_deriv, AF_CLASSIC
- return ujson.dumps(rv), False, False
+ return ujson.dumps(rv), sign_der, AF_CLASSIC
def generate_generic_export(account_num=0):
# Generate data that other programers will use to import Coldcard (single-signer)
- from descriptor import Descriptor, multisig_descriptor_template
+ from descriptor import Descriptor, Key
chain = chains.current_chain()
master_xfp = settings.get("xfp")
@@ -361,12 +404,14 @@ def generate_generic_export(account_num=0):
with stash.SensitiveValues() as sv:
# each of these paths would have /{change}/{idx} in usage (not hardened)
for name, deriv, fmt, atype, is_ms in [
- ( 'bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False ),
- ( 'bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False ), # was "p2wpkh-p2sh"
- ( 'bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False ),
- ( 'bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True ),
- ( 'bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True ),
- ( 'bip45', "m/45h", AF_P2SH, 'p2sh', True ),
+ ('bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False),
+ ('bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False), # was "p2wpkh-p2sh"
+ ('bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False),
+ ('bip86', "m/86h/{ct}h/{acc}h", AF_P2TR, 'p2tr', False),
+ ('bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True),
+ ('bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True),
+ ('bip48_3', "m/48h/{ct}h/{acc}h/3h", AF_P2TR, 'p2tr', True),
+ ('bip45', "m/45h", AF_P2SH, 'p2sh', True),
]:
if fmt == AF_P2SH and account_num:
continue
@@ -375,24 +420,25 @@ def generate_generic_export(account_num=0):
node = sv.derive_path(dd)
xfp = xfp2str(swab32(node.my_fp()))
xp = chain.serialize_public(node, AF_CLASSIC)
- zp = chain.serialize_public(node, fmt) if fmt != AF_CLASSIC else None
- if is_ms:
- desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt)
- else:
- desc = Descriptor(keys=[(master_xfp, dd, xp)], addr_fmt=fmt).serialize(int_ext=True)
-
- OWNERSHIP.note_wallet_used(fmt, account_num)
+ zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None
+ key = Key.from_cc_data(master_xfp, dd, xp)
+ key_exp = key.to_string(external=False, internal=False)
rv[name] = OrderedDict(name=atype,
xfp=xfp,
deriv=dd,
xpub=xp,
- desc=desc)
+ key_exp=key_exp)
if zp and zp != xp:
rv[name]['_pub'] = zp
if not is_ms:
+ desc_obj = Descriptor(key=key, addr_fmt=fmt)
+ rv[name]['desc'] = desc_obj.to_string()
+
+ OWNERSHIP.note_wallet_used(fmt, account_num)
+
# bonus/check: first non-change address: 0/0
node.derive(0, False).derive(0, False)
rv[name]['first'] = chain.address(node, fmt)
@@ -403,22 +449,15 @@ def generate_generic_export(account_num=0):
def generate_electrum_wallet(addr_type, account_num):
# Generate line-by-line JSON details about wallet.
#
- # Much reverse enginerring of Electrum here. It's a complex
+ # Much reverse engineering of Electrum here. It's a complex
# legacy file format.
chain = chains.current_chain()
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)
+ # Must get the derivation path, and the SLIP132 version bytes right!
+ mode = chains.af_to_bip44_purpose(addr_type)
OWNERSHIP.note_wallet_used(addr_type, account_num)
@@ -448,106 +487,65 @@ def generate_electrum_wallet(addr_type, account_num):
return ujson.dumps(rv), derive + "/0/0", addr_type
-async def make_json_wallet(label, func, fname_pattern='new-wallet.json'):
- # Record **public** values and helpful data into a JSON file
- # - OWNERSHIP.note_wallet_used(..) should be called already by our caller or func
-
- from glob import dis, NFC
- from files import CardSlot, CardMissingError, needs_microsd
- from ux import import_export_prompt
- from qrs import MAX_V11_CHAR_LIMIT
-
- dis.fullscreen('Generating...')
- json_str, derive, addr_fmt = func()
- skip_sig = derive is False and addr_fmt is False
-
- choice = await import_export_prompt("%s file" % label, is_import=False,
- no_qr=(not version.has_qwerty and len(json_str) >= MAX_V11_CHAR_LIMIT))
-
- if choice == KEY_CANCEL:
- return
- elif choice == KEY_NFC:
- await NFC.share_json(json_str)
- return
- elif choice == KEY_QR:
- # render as QR and show on-screen
- # - on mk4, this isn't offered if more than about 300 bytes because we can't
- # show that as a single QR
- await export_by_qr(json_str, label, "J")
- return
-
- # choose a filename and save
- try:
- with CardSlot(**choice) as card:
- fname, nice = card.pick_filename(fname_pattern)
-
- # do actual write
- with open(fname, 'wt') as fd:
- chunk_writer(fd, json_str)
-
- if not skip_sig:
- h = ngu.hash.sha256s(json_str.encode())
- sig_nice = write_sig_file([(h, fname)], derive, addr_fmt)
-
- except CardMissingError:
- await needs_microsd()
- return
- except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
- return
-
- msg = '%s file written:\n\n%s' % (label, nice)
- if not skip_sig:
- msg += '\n\n%s signature file written:\n\n%s' % (label, sig_nice)
-
- await ux_show_story(msg)
-
async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int_ext=True,
- fname_pattern="descriptor.txt"):
- from descriptor import Descriptor
+ fname_pattern="descriptor.txt", direct_way=None):
+ from descriptor import Descriptor, Key
from glob import dis
dis.fullscreen('Generating...')
chain = chains.current_chain()
- xfp = settings.get('xfp')
+ xfp = settings.get('xfp', 0)
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
- else:
- raise ValueError(addr_type)
+ mode = chains.af_to_bip44_purpose(addr_type)
OWNERSHIP.note_wallet_used(addr_type, account_num)
- derive = "m/{mode}h/{coin_type}h/{account}h".format(mode=mode,
- account=account_num, coin_type=chain.b44_cointype)
+ derive = "m/{mode}h/{coin_type}h/{account}h".format(
+ mode=mode, account=account_num, coin_type=chain.b44_cointype
+ )
dis.progress_bar_show(0.2)
with stash.SensitiveValues() as sv:
dis.progress_bar_show(0.3)
xpub = chain.serialize_public(sv.derive_path(derive))
dis.progress_bar_show(0.7)
- desc = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=addr_type)
+
+ key = Key.from_cc_data(xfp, derive, xpub)
+ desc = Descriptor(key=key, addr_fmt=addr_type)
dis.progress_bar_show(0.8)
if int_ext:
# with <0;1> notation
- body = desc.serialize(int_ext=True)
+ body = desc.to_string()
else:
# external descriptor
# internal descriptor
body = "%s\n%s" % (
- desc.serialize(internal=False),
- desc.serialize(internal=True),
+ desc.to_string(internal=False),
+ desc.to_string(external=False),
)
dis.progress_bar_show(1)
- await write_text_file(fname_pattern, body, "Descriptor", derive + "/0/0", addr_type)
+ await export_contents("Descriptor", body, fname_pattern, derive + "/0/0",
+ addr_type, force_prompt=True, direct_way=direct_way)
+
+
+async def make_key_expression_export(orig_der, fname_pattern="key_expr.txt"):
+ from glob import dis
+
+ dis.fullscreen('Generating...')
+
+ xfp = xfp2str(settings.get('xfp', 0)).lower()
+
+ with stash.SensitiveValues() as sv:
+ ek = chains.current_chain().serialize_public(sv.derive_path(orig_der))
+
+ body = "[%s/%s]%s" % (xfp, orig_der.replace("m/", ""), ek)
+
+ await export_contents("Key Expression", body, fname_pattern,
+ None, None, force_prompt=True)
# EOF
diff --git a/shared/files.py b/shared/files.py
index 321a2aaad..5b0f3e20f 100644
--- a/shared/files.py
+++ b/shared/files.py
@@ -264,7 +264,7 @@ def __init__(self, force_vdisk=False, readonly=False, slot_b=None):
self.active_led = self.active_led2 if use_b_slot else self.active_led1
def __enter__(self):
- # Mk4: maybe use our virtual disk in preference to SD Card
+ # maybe use our virtual disk in preference to SD Card
if glob.VD and (self.force_vdisk or not self.is_inserted()):
self.mountpt = glob.VD.mount(self.readonly)
return self
diff --git a/shared/flow.py b/shared/flow.py
index e14745a0f..f39e9a5c3 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -9,7 +9,7 @@
from actions import *
from choosers import *
from mk4 import dev_enable_repl
-from multisig import make_multisig_menu, import_multisig_nfc
+from wallet import make_miniscript_menu, import_miniscript_nfc
from seed import make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw
from address_explorer import address_explore
from drv_entro import drv_entro_start, password_entry
@@ -19,9 +19,11 @@
from paper import make_paper_wallet
from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file
+from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu
# useful shortcut keys
from charcodes import KEY_QR, KEY_NFC
+from public_constants import AF_P2WPKH_P2SH, AF_P2WPKH
# Optional feature: HSM, depends on hardware
@@ -38,12 +40,14 @@
from battery import battery_idle_timeout_chooser, brightness_chooser
from q1 import scan_and_bag
from notes import make_notes_menu
+ from teleport import kt_start_rx, kt_send_file_psbt
else:
battery_idle_timeout_chooser = None
brightness_chooser = None
scan_and_bag = None
make_notes_menu = None
-
+ kt_start_rx = None
+ kt_send_file_psbt = None
#
# NOTE: "Always In Title Case"
@@ -69,6 +73,8 @@ def has_secrets():
from pincodes import pa
return pa.has_secrets()
+qr_and_has_secrets = has_secrets if version.has_qr else False
+
def nfc_enabled():
from glob import NFC
return bool(NFC)
@@ -95,6 +101,33 @@ def hsm_available():
# contains hsm feature + can it be used (needs se2 secret and no tmp active)
return version.supports_hsm and has_real_secret()
+def qr_and_ms():
+ # has QR scanner, and at least one MS wallet
+ if not version.has_qr: return False
+ return bool(settings.get('miniscript', False))
+
+def has_pushtx_url():
+ # they want to use PushTX feature
+ return bool(settings.get("ptxurl", False))
+
+# Spending Policy (Hobbled mode) predicates.
+#
+def is_hobble_testdrive():
+ from pincodes import pa
+ return (pa.hobbled_mode == 2)
+
+def sssp_related_keys():
+ return sssp_spending_policy('okeys')
+
+def sssp_allow_passphrase():
+ return word_based_seed() and sssp_related_keys()
+
+def sssp_allow_notes():
+ return settings.get("secnap", False) and sssp_spending_policy('notes')
+
+def sssp_allow_vault():
+ return settings.master_get('seedvault') and sssp_related_keys()
+
async def goto_home(*a):
goto_top_menu()
@@ -136,8 +169,8 @@ async def goto_home(*a):
# xxxxxxxxxxxxxxxx
MenuItem('Login Settings', menu=LoginPrefsMenu),
MenuItem('Hardware On/Off', menu=HWTogglesMenu),
- NonDefaultMenuItem('Multisig Wallets', 'multisig',
- menu=make_multisig_menu, predicate=has_secrets),
+ NonDefaultMenuItem('Multisig/Miniscript', 'miniscript',
+ menu=make_miniscript_menu, predicate=has_secrets, shortcut="m"),
NonDefaultMenuItem('NFC Push Tx', 'ptxurl', menu=pushtx_setup_menu),
MenuItem('Display Units', chooser=value_resolution_chooser),
MenuItem('Max Network Fee', chooser=max_fee_chooser),
@@ -154,9 +187,9 @@ async def goto_home(*a):
MS-DOS tools should not be able to find the PSBT data (ie. undelete), but forensic tools \
which take apart the flash chips of the SDCard may still be able to find the \
data or filenames.'''),
- ToggleMenuItem('Menu Wrapping', 'wa', ['Default Off', 'Enable'],
+ ToggleMenuItem('Menu Wrapping', 'wa', ['Default', 'Always Wrap'],
story='''When enabled, allows scrolling past menu top/bottom \
-(wrap around). By default, this is only happens in very large menus.'''),
+(wrap around). By default, this only happens in menus whose length is greater than 10.'''),
ToggleMenuItem('Home Menu XFP', 'hmx', ['Only Tmp', 'Always Show'],
story=('Forces display of XFP (seed fingerprint) '
'at top of main menu. Normally, XFP is shown only when '
@@ -176,30 +209,35 @@ async def goto_home(*a):
# xxxxxxxxxxxxxxxx
MenuItem("Segwit (BIP-84)", f=export_xpub, arg=84),
MenuItem("Classic (BIP-44)", f=export_xpub, arg=44),
- 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),
]
WalletExportMenu = [
# xxxxxxxxxxxxxxxx
+ MenuItem("Sparrow", f=named_generic_skeleton, arg="Sparrow"),
+ MenuItem("Cove", f=named_generic_skeleton, arg="Cove"),
MenuItem("Bitcoin Core", f=bitcoin_core_skeleton),
- MenuItem("Fully Noded", f=named_generic_skeleton, arg="Fully Noded"),
- MenuItem("Sparrow Wallet", f=named_generic_skeleton, arg="Sparrow"),
MenuItem("Nunchuk", f=named_generic_skeleton, arg="Nunchuk"),
+ MenuItem("Bull Bitcoin", f=ss_descriptor_skeleton,
+ arg=(True, [AF_P2WPKH], "", "bull-bitcoin.txt", KEY_QR)),
MenuItem("Zeus", f=ss_descriptor_skeleton,
- arg=(True, [AF_P2WPKH, AF_P2WPKH_P2SH], "Zeus Wallet", "zeus-export.txt")),
+ arg=(True, [AF_P2WPKH, AF_P2WPKH_P2SH], "Zeus Wallet", "zeus-export.txt", None)),
MenuItem("Electrum Wallet", f=electrum_skeleton),
- MenuItem("Theya", f=named_generic_skeleton, arg="Theya"),
MenuItem("Wasabi Wallet", f=wasabi_skeleton),
+ MenuItem("Fully Noded", f=named_generic_skeleton, arg="Fully Noded"),
MenuItem("Unchained", f=unchained_capital_export),
- MenuItem("Lily Wallet", f=named_generic_skeleton, arg="Lily"),
+ MenuItem("Theya", f=named_generic_skeleton, arg="Theya"),
+ MenuItem("Bitcoin Safe", f=named_generic_skeleton, arg="Bitcoin Safe"),
MenuItem("Samourai Postmix", f=samourai_post_mix_descriptor_export),
MenuItem("Samourai Premix", f=samourai_pre_mix_descriptor_export),
# MenuItem("Samourai BadBank", f=samourai_bad_bank_descriptor_export), # not released yet
MenuItem("Descriptor", f=ss_descriptor_skeleton),
MenuItem("Generic JSON", f=generic_skeleton),
MenuItem("Export XPUB", menu=XpubExportMenu),
+ MenuItem("Key Expression", f=key_expression_skeleton),
MenuItem("Dump Summary", predicate=has_secrets, f=dump_summary),
]
@@ -211,9 +249,11 @@ async def goto_home(*a):
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere
MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd),
MenuItem('Batch Sign PSBT', predicate=has_secrets, f=batch_sign),
+ MenuItem('Teleport Multisig/Miniscript PSBT', predicate=qr_and_has_secrets, f=kt_send_file_psbt),
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),
@@ -231,7 +271,9 @@ async def goto_home(*a):
# xxxxxxxxxxxxxxxx
MenuItem("Serial REPL", f=dev_enable_repl),
MenuItem('Warm Reset', f=reset_self),
- MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
+ MenuItem("Restore Bkup", f=restore_backup_dev),
+ MenuItem("BKPW Override", menu=bkpw_override, predicate=has_secrets),
+ MenuItem('Reflash GPU', f=reflash_gpu, predicate=version.has_qwerty),
]
AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh)
@@ -248,6 +290,7 @@ async def goto_home(*a):
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
MenuItem("File Management", menu=FileMgmtMenu),
+ MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('Perform Selftest', f=start_selftest),
MenuItem("I Am Developer.", menu=maybe_dev_menu),
@@ -291,7 +334,7 @@ async def goto_home(*a):
"WARNING: Seed Vault is encrypted (AES-256-CTR) by your seed,"
" but not held directly inside secure elements. Backups are required"
" after any change to vault! Recommended for experiments or temporary use."),
- predicate=has_se_secrets),
+ predicate=has_real_secret),
MenuItem('Perform Selftest', f=start_selftest), # little harmful
MenuItem("Set High-Water", f=set_highwater),
MenuItem('Wipe HSM Policy', f=wipe_hsm_policy, predicate=hsm_policy_available),
@@ -304,7 +347,7 @@ async def goto_home(*a):
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 \
@@ -323,7 +366,6 @@ async def goto_home(*a):
MenuItem('Settings Space', f=show_settings_space),
MenuItem('MCU Key Slots', f=show_mcu_keys_left),
MenuItem('Bless Firmware', f=bless_flash), # no need for this anymore?
- MenuItem('Reflash GPU', f=reflash_gpu, predicate=version.has_qwerty),
MenuItem("Wipe LFS", f=wipe_filesystem), # kills other-seed settings, HSM stuff, addr cache
]
@@ -331,7 +373,7 @@ async def goto_home(*a):
# xxxxxxxxxxxxxxxx
MenuItem("Backup System", f=backup_everything),
MenuItem("Verify Backup", f=verify_backup),
- MenuItem("Restore Backup", f=restore_everything), # just a redirect really
+ MenuItem("Restore Backup", f=need_clear_seed), # just a UX msg really
MenuItem('Clone Coldcard', predicate=has_secrets, f=clone_write_data),
]
@@ -342,8 +384,21 @@ async def goto_home(*a):
MenuItem('Verify Sig File', f=nfc_sign_verify),
MenuItem('Verify Address', f=nfc_address_verify),
MenuItem('File Share', f=nfc_share_file),
- MenuItem('Import Multisig', f=import_multisig_nfc),
- MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=lambda: settings.get("ptxurl", False)),
+ MenuItem('Import Miniscript', f=import_miniscript_nfc),
+ MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=has_pushtx_url),
+]
+
+
+SpendingPolicySubMenu = [
+ NonDefaultMenuItem('Single-Signer', 'sssp', f=sssp_feature_menu, predicate=has_real_secret),
+ NonDefaultMenuItem('Co-Sign Multi.' if not version.has_qwerty else 'Co-Sign Multisig (CCC)',
+ 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
+ ToggleMenuItem('HSM Mode', 'hsmcmd', ['Default Off', 'Enable'],
+ story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
+ "By default these commands are disabled."),
+ predicate=hsm_available),
+ MenuItem('User Management', menu=make_users_menu,
+ predicate=lambda: hsm_available() and settings.get('hsmcmd', False)),
]
AdvancedNormalMenu = [
@@ -352,19 +407,15 @@ async def goto_home(*a):
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
MenuItem("File Management", menu=FileMgmtMenu),
- NonDefaultMenuItem('Secure Notes & Passwords', 'notes', menu=make_notes_menu,
+ NonDefaultMenuItem('Secure Notes & Passwords', 'secnap', menu=make_notes_menu,
predicate=version.has_qwerty),
MenuItem('Derive Seed B85' if not version.has_qwerty else 'Derive Seeds (BIP-85)',
f=drv_entro_start),
MenuItem("View Identity", f=view_ident),
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
+ MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
+ MenuItem("Spending Policy", menu=SpendingPolicySubMenu,shortcut='s',predicate=has_real_secret),
MenuItem('Paper Wallets', f=make_paper_wallet),
- ToggleMenuItem('Enable HSM', 'hsmcmd', ['Default Off', 'Enable'],
- story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
- "By default these commands are disabled."),
- predicate=hsm_available),
- MenuItem('User Management', menu=make_users_menu,
- predicate=hsm_available),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
]
@@ -373,7 +424,7 @@ async def goto_home(*a):
VirginSystem = [
# xxxxxxxxxxxxxxxx
MenuItem('Choose PIN Code', f=initial_pin_setup),
- MenuItem('Advanced/Tools', menu=AdvancedVirginMenu),
+ MenuItem('Advanced/Tools', menu=AdvancedVirginMenu, shortcut='t'),
MenuItem('Bag Number', f=show_bag_number),
MenuItem('Help', f=virgin_help, predicate=not version.has_qwerty),
]
@@ -384,7 +435,7 @@ async def goto_home(*a):
MenuItem("24 Words", menu=start_seed_import, arg=24),
MenuItem('Scan QR Code', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(True, False)),
- MenuItem("Restore Backup", f=restore_everything),
+ MenuItem("Restore Backup", f=restore_backup, arg=False), # tmp=False
MenuItem("Clone Coldcard", menu=clone_start),
MenuItem("Import XPRV", f=import_xprv, arg=False), # ephemeral=False
MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=False),
@@ -408,9 +459,11 @@ async def goto_home(*a):
MenuItem('New Seed Words', menu=NewSeedMenu),
MenuItem('Import Existing', menu=ImportWallet),
MenuItem("Migrate Coldcard", menu=clone_start),
+ MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
MenuItem('Help', f=virgin_help, predicate=not version.has_qwerty),
- MenuItem('Advanced/Tools', menu=AdvancedPinnedVirginMenu),
+ MenuItem('Advanced/Tools', menu=AdvancedPinnedVirginMenu, shortcut='t'),
MenuItem('Settings', menu=SettingsMenu),
+ ShortcutItem(KEY_QR, predicate=version.has_qr, f=scan_any_qr, arg=(True, False)),
]
# In operation, normal system, after a good PIN received.
@@ -424,7 +477,7 @@ async def goto_home(*a):
MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available),
MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
- predicate=lambda: version.has_qwerty and (settings.get("notes", False) != False)),
+ predicate=lambda: version.has_qwerty and settings.get("secnap", False)),
MenuItem('Type Passwords', f=password_entry, shortcut='t',
predicate=lambda: settings.get("emu", False) and has_secrets()),
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',
@@ -437,10 +490,78 @@ async def goto_home(*a):
# Shown until unit is put into a numbered bag
FactoryMenu = [
- MenuItem('Version: ' + version.get_mpy_version()[1], f=show_version),
MenuItem('Bag Me Now', f=scan_and_bag),
+ MenuItem('Version: ' + version.get_mpy_version()[1], f=show_version),
MenuItem('DFU Upgrade', f=start_dfu, shortcut='u'),
MenuItem('Ship W/O Bag', f=ship_wo_bag),
MenuItem("Debug Functions", menu=DebugFunctionsMenu, shortcut='f'),
MenuItem("Perform Selftest", f=start_selftest, shortcut='s'),
]
+
+# Special menus for hobbled mode where we have a (single signer) spending policy in effect.
+# - no access to secrets, backups, firmware up/downgrades.
+# - secure notes, but readonly; can be disabled completely.
+# - key teleport, but only for PSBT & multisig purposes.
+# - can only be enabled after we have secrets, so no need for has_secrets tests here
+#
+
+# Slightly limited file menu when hobbled.
+# - no backup/restore
+HobbledFileMgmtMenu = [
+ # xxxxxxxxxxxxxxxx
+ MenuItem('Sign Text File', f=sign_message_on_sd),
+ MenuItem('Batch Sign PSBT', f=batch_sign),
+ MenuItem('List Files', f=list_files),
+ MenuItem('Export Wallet', menu=WalletExportMenu), # dup under Adv/Tools
+ 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('Format SD Card', f=wipe_sd_card),
+ MenuItem('Format RAM Disk', predicate=vdisk_enabled, f=wipe_vdisk),
+]
+
+# NFC tools when hobbled: not much different.
+HobbledNFCToolsMenu = [
+ MenuItem('Sign PSBT', f=nfc_sign_psbt),
+ MenuItem('Show Address', f=nfc_show_address),
+ MenuItem('Sign Message', f=nfc_sign_msg),
+ MenuItem('Verify Sig File', f=nfc_sign_verify),
+ MenuItem('Verify Address', f=nfc_address_verify),
+ MenuItem('File Share', f=nfc_share_file),
+ MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=has_pushtx_url),
+]
+
+# Very limited advanced menu when hobbled.
+HobbledAdvancedMenu = [
+ # xxxxxxxxxxxxxxxx
+ MenuItem("File Management", menu=HobbledFileMgmtMenu),
+ MenuItem('Export Wallet', menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt
+ MenuItem('Teleport Multisig/Miniscript PSBT', predicate=qr_and_ms, f=kt_send_file_psbt),
+ MenuItem("View Identity", f=view_ident),
+ MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
+ MenuItem('Paper Wallets', f=make_paper_wallet),
+ MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
+ MenuItem('Show %s Version' % ("Firmware" if version.has_qwerty else "FW"), f=show_version),
+ MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
+]
+
+# Main menu when a spending policy (hobbled) is in effect.
+HobbledTopMenu = [
+ # xxxxxxxxxxxxxxxx
+ MenuItem('Ready To Sign', f=ready2sign, shortcut='r'),
+ MenuItem('Passphrase', menu=start_b39_pw, predicate=sssp_allow_passphrase, shortcut='p'),
+ MenuItem('Scan Any QR Code', predicate=version.has_qr, f=scan_any_qr, arg=(False, True),
+ shortcut=KEY_QR),
+ MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
+ MenuItem('Secure Notes & Passwords', menu=make_notes_menu, predicate=sssp_allow_notes,
+ shortcut='n'),
+ MenuItem('Type Passwords', f=password_entry, shortcut='t',
+ predicate=lambda: settings.get("emu", False) and sssp_related_keys()),
+ MenuItem('Seed Vault', menu=make_seed_vault_menu, predicate=sssp_allow_vault,
+ shortcut='v'),
+ MenuItem('Advanced/Tools', menu=HobbledAdvancedMenu, shortcut='t'),
+ MenuItem('Secure Logout', f=logout_now, predicate=not version.has_battery),
+ MenuItem('EXIT TEST DRIVE', f=sssp_feature_menu, predicate=is_hobble_testdrive),
+ ShortcutItem(KEY_NFC, predicate=nfc_enabled, menu=HobbledNFCToolsMenu),
+]
diff --git a/shared/glob.py b/shared/glob.py
index 0c30e97df..44d9ed524 100644
--- a/shared/glob.py
+++ b/shared/glob.py
@@ -29,4 +29,9 @@
# QR scanner (Q1 only)
SCAN = None
+# Multisig/Miniscript descriptor cache
+# mapping from unique wallet name to Descriptor object
+# cache size = 1
+DESC_CACHE = {}
+
# EOF
diff --git a/shared/gpu.py b/shared/gpu.py
index 4e0924683..35da5b1ce 100644
--- a/shared/gpu.py
+++ b/shared/gpu.py
@@ -8,7 +8,6 @@
#
import utime, struct
import uasyncio as asyncio
-from utils import B2A
from machine import Pin
from ustruct import pack
diff --git a/shared/history.py b/shared/history.py
index bccccde4f..479d25807 100644
--- a/shared/history.py
+++ b/shared/history.py
@@ -18,7 +18,7 @@
# - 8 bytes exact satoshi value => base64 (pad trimmed) => 11 chars
# - stored satoshi value is XOR'ed with LSB from prevout txn hash, which isn't stored
# - result is a 31 character string for each history entry, plus 4 overhead => 35 each
-# - if we store 30 of those it's about 25% of total setting space
+# - if we store 30 of those it's about 25% of total setting space (Mk3)
#
HISTORY_SAVED = const(30)
HISTORY_MAX_MEM = const(128)
@@ -132,7 +132,7 @@ def add(cls, prevout, amount):
# save new addition
assert len(key) == ENCKEY_LEN
- assert amount > 0
+ # assert amount > 0
entry = key + cls.encode_value(prevout, amount)
cls.runtime_cache.append(entry)
diff --git a/shared/hsm.py b/shared/hsm.py
index db538668c..4f153f5b5 100644
--- a/shared/hsm.py
+++ b/shared/hsm.py
@@ -4,16 +4,15 @@
#
# Unattended signing of transactions and messages, subject to a set of rules.
#
-import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version
-from sffile import SFFile
+import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
+from utils import cleanup_payment_address
from pincodes import AE_LONG_SECRET_LEN
from stash import blank_object
from users import Users, MAX_NUMBER_USERS, calc_local_pincode
from public_constants import MAX_USERNAME_LEN
-from multisig import MultisigWallet
+from wallet import MiniScriptWallet
from ubinascii import hexlify as b2a_hex
-from ubinascii import unhexlify as a2b_hex
from uhashlib import sha256
from ucollections import OrderedDict
from files import CardSlot, CardMissingError
@@ -70,9 +69,9 @@ def restore_backup(s):
with open(POLICY_FNAME, 'wt') as f:
f.write(s)
- except BaseException as exc:
+ except:
# keep going, we don't want to brick
- sys.print_exception(exc)
+ # sys.print_exception(exc)
pass
def pop_list(j, fld_name, cleanup_fcn=None):
@@ -88,13 +87,13 @@ def pop_list(j, fld_name, cleanup_fcn=None):
else:
return []
-def pop_deriv_list(j, fld_name, extra_val=None):
+def pop_deriv_list(j, fld_name, extra_vals=None):
# expect a list of derivation paths, but also 'any' meaning accept all
# - maybe also 'p2sh' as special value
# - also, path can have n
def cu(s):
- if s.lower() == 'any': return s.lower()
- if extra_val and s.lower() == extra_val: return s.lower()
+ if extra_vals and s.lower() in extra_vals:
+ return s.lower()
try:
return cleanup_deriv_path(s, allow_star=True)
except:
@@ -149,22 +148,6 @@ def assert_empty_dict(j):
if extra:
raise ValueError("Unknown item: " + ', '.join(extra))
-def cleanup_whitelist_value(s):
- # one element in a list of addresses or paths or descriptors?
- # - later matching is string-based, so just doing basic syntax check here
- # - must be checksumed-base58 or bech32
- try:
- ngu.codecs.b58_decode(s)
- return s
- except: pass
-
- try:
- ngu.codecs.segwit_decode(s)
- return s
- except: pass
-
- raise ValueError('bad whitelist value: ' + s)
-
class WhitelistOpts:
# contains various options related to whitelisting
@@ -195,7 +178,7 @@ class ApprovalRule:
# - users: list of authorized users
# - min_users: how many of those are needed to approve
# - local_conf: local user must also confirm w/ code
- # - wallet: which multisig wallet to restrict to, or '1' for single signer only
+ # - wallet: which miniscript wallet to restrict to, or '1' for single signer only
# - min_pct_self_transfer: minimum percentage of own input value that must go back to self
# - patterns: list of transaction patterns to check for. Valid values:
# * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal
@@ -215,7 +198,7 @@ def check_user(u):
self.per_period = pop_int(j, 'per_period', 0, MAX_SATS)
self.max_amount = pop_int(j, 'max_amount', 0, MAX_SATS)
self.users = pop_list(j, 'users', check_user)
- self.whitelist = pop_list(j, 'whitelist', cleanup_whitelist_value)
+ self.whitelist = pop_list(j, 'whitelist', cleanup_payment_address)
self.whitelist_opts = pop_dict(j, 'whitelist_opts', False, WhitelistOpts)
self.min_users = pop_int(j, 'min_users', 1, len(self.users))
self.local_conf = pop_bool(j, 'local_conf')
@@ -236,10 +219,10 @@ def check_user(u):
# redundant w/ code in pop_int() above
assert 1 <= self.min_users <= len(self.users), "range"
- # if specified, 'wallet' must be an existing multisig wallet's name
+ # if specified, 'wallet' must be an existing miniscript wallet's name
if self.wallet and self.wallet != '1':
- names = [ms.name for ms in MultisigWallet.get_all()]
- assert self.wallet in names, "unknown MS wallet: "+self.wallet
+ msc_names = [msc.name for msc in MiniScriptWallet.iter_wallets()]
+ assert self.wallet in msc_names, "unknown wallet: " + self.wallet
# patterns must be valid
for p in self.patterns:
@@ -283,9 +266,9 @@ def render(n):
rv = 'Any amount'
if self.wallet == '1':
- rv += ' (non multisig)'
+ rv += ' (singlesig only)'
elif self.wallet:
- rv += ' from multisig wallet "%s"' % self.wallet
+ rv += ' from miniscript wallet "%s"' % self.wallet
if self.users:
rv += ' may be authorized by '
@@ -326,12 +309,11 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain):
# Does this rule apply to this PSBT file?
if self.wallet:
# rule limited to one wallet
- if psbt.active_multisig:
- # if multisig signing, might need to match specific wallet name
- assert self.wallet == psbt.active_multisig.name, 'wrong wallet'
+ if psbt.active_miniscript:
+ assert self.wallet == psbt.active_miniscript.name, 'wrong miniscript wallet'
else:
- # non multisig, but does this rule apply to all wallets or single-singers
- assert self.wallet == '1', 'not multisig'
+ # not miniscript, but does this rule apply to all wallets or single-singers
+ assert self.wallet == '1', 'singlesig only'
if self.max_amount is not None:
assert total_out <= self.max_amount, 'amount exceeded'
@@ -367,7 +349,7 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain):
# we are verifying the whole consensus-encoded txout
txo_bytes = CTxOut(txo.nValue, txo.scriptPubKey).serialize()
digest = chain.hash_message(txo_bytes)
- addr_fmt, pubkey = chains.verify_recover_pubkey(o.attestation, digest)
+ addr_fmt, pubkey = chains.verify_recover_pubkey(psbt.get(o.attestation), digest)
# we have extracted a valid pubkey from the sig, but is it
# a whitelisted pubkey or something else?
ver_addr = chain.pubkey_to_address(pubkey, addr_fmt)
@@ -390,11 +372,11 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain):
# check the self-transfer percentage
if self.min_pct_self_transfer:
- own_in_value = sum([i.amount for i in psbt.inputs if i.num_our_keys])
+ own_in_value = sum([i.amount for i in psbt.inputs if i.sp_idxs])
own_out_value = 0
for idx, txo in psbt.output_iter():
o = psbt.outputs[idx]
- if o.num_our_keys:
+ if o.sp_idxs:
own_out_value += txo.nValue
percentage = (float(own_out_value) / own_in_value) * 100.0
assert percentage >= self.min_pct_self_transfer, 'does not meet self transfer threshold, expected: %.2f, actual: %.2f' % (self.min_pct_self_transfer, percentage)
@@ -405,8 +387,8 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain):
assert len(psbt.inputs) == len(psbt.outputs), 'unequal number of inputs and outputs'
if "EQ_NUM_OWN_INS_OUTS" in self.patterns:
- own_ins = sum([1 for i in psbt.inputs if i.num_our_keys])
- own_outs = sum([1 for o in psbt.outputs if o.num_our_keys])
+ own_ins = sum([1 for i in psbt.inputs if i.sp_idxs])
+ own_outs = sum([1 for o in psbt.outputs if o.sp_idxs])
assert own_ins == own_outs, 'unequal number of own inputs and outputs'
if "EQ_OUT_AMOUNTS" in self.patterns:
@@ -504,9 +486,9 @@ def load(self, j):
self.warnings_ok = pop_bool(j, 'warnings_ok')
# a list of paths we can accept for signing
- self.msg_paths = pop_deriv_list(j, 'msg_paths')
- self.share_xpubs = pop_deriv_list(j, 'share_xpubs')
- self.share_addrs = pop_deriv_list(j, 'share_addrs', 'p2sh')
+ self.msg_paths = pop_deriv_list(j, 'msg_paths', ['any'])
+ self.share_xpubs = pop_deriv_list(j, 'share_xpubs', ['any'])
+ self.share_addrs = pop_deriv_list(j, 'share_addrs', ['any', 'msas'])
# free text shown at top
self.notes = pop_string(j, 'notes', 1, 80)
@@ -591,7 +573,7 @@ def explain(self, fd):
fd.write('\n')
def plist(pl):
- remap = {'any': '(any path)', 'p2sh': '(any P2SH)' }
+ remap = {'any': '(any path)', 'msas': '(any miniscript)' }
return ' OR '.join(remap.get(i, i) for i in pl)
fd.write('\nMessage signing:\n')
@@ -621,7 +603,7 @@ def plist(pl):
fd.write('- XPUB values will be shared, if path matches: m OR %s.\n'
% plist(self.share_xpubs))
if self.share_addrs:
- fd.write('- Address values values will be shared, if path matches: %s.\n'
+ fd.write('- Address values will be shared, if path matches: %s.\n'
% plist(self.share_addrs))
if self.priv_over_ux:
fd.write('- Status responses optimized for privacy.\n')
@@ -814,14 +796,14 @@ def approve_xpub_share(self, subpath):
return match_deriv_path(self.share_xpubs, subpath)
- def approve_address_share(self, subpath=None, is_p2sh=False):
+ def approve_address_share(self, subpath=None, miniscript=False):
# Are we allowing "show address" requests over USB?
if not self.share_addrs:
return False
- if is_p2sh:
- return ('p2sh' in self.share_addrs)
+ if miniscript:
+ return ('msas' in self.share_addrs)
return match_deriv_path(self.share_addrs, subpath)
@@ -894,6 +876,7 @@ async def approve_transaction(self, psbt, psbt_sha, story):
# reject anything with warning, probably
if psbt.warnings:
+ print(psbt.warnings)
if self.warnings_ok:
log.info("Txn has warnings, but policy is to accept anyway.")
else:
@@ -951,7 +934,7 @@ async def approve_transaction(self, psbt, psbt_sha, story):
return 'y'
except BaseException as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
err = "Rejected: " + (str(exc) or problem_file_line(exc))
self.refuse(log, err)
@@ -994,7 +977,7 @@ def hsm_status_report():
rv['approval_wait'] = True
rv['users'] = Users.list()
- rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()]
+ rv['wallets'] = [msc.name for msc in MiniScriptWallet.iter_wallets()]
rv['chain'] = settings.get('chain', 'BTC')
diff --git a/shared/hsm_ux.py b/shared/hsm_ux.py
index 5cc4fb912..f34f43707 100644
--- a/shared/hsm_ux.py
+++ b/shared/hsm_ux.py
@@ -67,7 +67,7 @@ async def interact(self):
except BaseException as exc:
self.failed = "Exception"
- sys.print_exception(exc)
+ # sys.print_exception(exc)
self.refused = True
self.ux_done = True
@@ -297,7 +297,7 @@ def draw_busy(self, msg, percent):
# replacements for display.py:Display functions
- def hack_fullscreen(self, msg, percent=None):
+ def hack_fullscreen(self, msg, percent=None, **kwargs):
self.draw_busy(msg, percent)
def hack_progress_bar(self, percent):
self.draw_busy(None, percent)
@@ -354,7 +354,7 @@ async def interact(self):
await sleep_ms(100)
except BaseException as exc:
# just in case, keep going
- sys.print_exception(exc)
+ # sys.print_exception(exc)
continue
# do the interactions, but don't let user actually press anything
diff --git a/shared/imptask.py b/shared/imptask.py
index 6979854c6..297415ebd 100644
--- a/shared/imptask.py
+++ b/shared/imptask.py
@@ -58,8 +58,8 @@ def handle_exc(self, loop, context):
else:
# uncaught exception in an unnamed (and unimportant) task
print("UNNAMED: " + context["message"])
- sys.print_exception(context["exception"])
- print("... future: %r" % context.get("future", '?'))
+ sys.print_exception(context["exception"]) # VERY USEFUL on sim
+ #print("... future: %r" % context.get("future", '?'))
def start_task(self, name, awaitable):
# start a critical task and watch for it to never die
diff --git a/shared/lcd_display.py b/shared/lcd_display.py
index 0e3722a1e..de601c23a 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
@@ -154,7 +154,7 @@ def set_lcd_brightness(self, on_battery=None, tmp_override=None):
# otherwise: respect setting
if on_battery is None:
- on_battery = (get_batt_threshold() != None)
+ on_battery = (get_batt_threshold() is not None)
if on_battery:
# user-defined brightness when running on batteries.
@@ -190,7 +190,7 @@ def draw_status(self, full=False, **kws):
self.image(165, 0, 'tmp_%d' % kws['tmp'])
xfp = kws.get('xfp', None) # expects an integer
- if xfp != None:
+ if xfp is not None:
x = 217
for ch in xfp2str(xfp).lower():
self.image(x, 0, 'ch_'+ch)
@@ -268,7 +268,7 @@ def text(self, x,y, msg, font=None, invert=False, dark=False):
if x is None or x < 0:
w = self.width(msg)
- if x == None:
+ if x is None:
# center: also blanks rest of line
x = max(0, (CHARS_W - w) // 2)
end_x = x + w
@@ -612,25 +612,105 @@ def draw_story(self, lines, top, num_lines, is_sensitive, hint_icons=''):
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
+ # hint_icons not shown if is story without title
+ # maybe show that [QR,NFC] 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
+
+ @staticmethod
+ def handle_qr_msg(msg, max_lines=False):
+ 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
+ hh = len(msg) // 2
+ parts = [msg[0:hh], msg[hh:]]
+ else:
+ if not max_lines:
+ # do word wrap
+ parts = list(word_wrap(msg, CHARS_W))
+ else:
+ # 2 lines max
+ parts = [msg[:30] + "⋯", "⋯" + msg[-30:]]
+
+ return parts
+
+ def draw_qr_lines(self, lines, is_addr):
+ y = CHARS_H - len(lines)
+ prev_x = 0
+ for line in lines:
+ if not is_addr:
+ self.text(None, y, line)
+ else:
+ prev_x = self._draw_addr(y, line, prev_x=prev_x)
+ y += 1
+
+ def draw_qr_idx_hint(self, str_idx):
+ lh = len(str_idx)
+ assert lh <= 10
+ if lh > 5:
+ # needs 2 lines
+ self.text(-1, 0, str_idx[:5])
+ self.text(-1, 1, str_idx[5:])
+ else:
+ self.text(-1, 0, str_idx)
+
+ def draw_qr_error(self, idx_hint, msg=None):
+ x = 85
+ y = 30
+ w = 150
+ self.clear()
+ self.dis.fill_rect(x, y, w, w, COL_TEXT)
+ self.dis.fill_rect(x + 1, y + 1, w - 2, w - 2) # Black
+ self.text(12, 3, "QR too big")
+ if msg:
+ lines = self.handle_qr_msg(msg, max_lines=True)
+ self.draw_qr_lines(lines, False)
+
+ self.draw_qr_idx_hint(idx_hint)
+ self.show()
+
+ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,
+ is_addr=False, force_msg=False, is_change=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,18 +718,19 @@ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, par
assert not sidebar
# maybe show something other than QR contents under it
- if 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
- hh = len(msg) // 2
- parts = [msg[0:hh] + ' ', ' '+msg[hh:]]
+ 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:
- # do word wrap
- parts = list(word_wrap(msg, CHARS_W))
-
+ # p2wsh address would need 3 lines to show, so we won't
+ num_lines = 0
+ elif msg:
+ parts = self.handle_qr_msg(msg)
num_lines = len(parts)
else:
num_lines = 0
@@ -670,25 +751,21 @@ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, par
fullscreen = False
trim_lines = 0
- if w == 77:
- # v15 => 77px x 3: 77*3 = 231px
- expand = 3
- num_lines = 0
- fullscreen = True
- elif w in (109, 113, 117):
- # v23 => 109px x 2 = 218px
- # v24 => 113px x 2 = 226px
- # v25 => 117px x 2 = 234px
- expand = 2
- num_lines = 0
- fullscreen = True
- elif expand == 1 and num_lines:
- # Maybe loose the text lines?
- expand2 = max(1, ACTIVE_H // (w+2))
- if expand2 > expand:
- # v18,v19,v20,v21,v22
+ # always try to show the biggest possible QR code if not force_msg
+ if not force_msg:
+ if num_lines:
+ # better with text dropped?
+ e2 = max(1, ACTIVE_H // (w + 2))
+ if e2 > expand:
+ num_lines = 0
+ expand = e2
+
+ # fullscreen ?
+ e3 = (ACTIVE_H + 20) // (w + 2)
+ if expand < e3:
+ expand = e3
+ fullscreen = True
num_lines = 0
- expand = expand2
# vert center in available space
qw = (w+2) * expand
@@ -722,20 +799,17 @@ def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, par
if num_lines:
# centered text under that
- y = CHARS_H - num_lines
- for line in parts:
- self.text(None, y, line)
- y += 1
+ self.draw_qr_lines(parts, is_addr)
if idx_hint:
- lh = len(idx_hint)
- assert lh <= 10
- if lh > 6:
- # needs 2 lines
- self.text(-1, 0, idx_hint[:6])
- self.text(-1, 1, idx_hint[6:])
- else:
- self.text(-1, 0, idx_hint)
+ self.draw_qr_idx_hint(idx_hint)
+
+ if is_addr and is_change:
+ for i, c in enumerate("CHANGE", start=4):
+ self.text(1, i, c)
+
+ for i, c in enumerate("BACK", start=6):
+ self.text(-1, i, c)
# pass a max brightness flag here, which will be cleared after next show
self.show(max_bright=True)
@@ -770,8 +844,12 @@ def draw_bbqr_progress(self, hdr, got_parts, corrupt=False):
else:
pat = '' # clear line
- self.text(None, -3, pat)
+ if count == hdr.num_parts and count == 1:
+ # skip the BS, it's a simple one
+ self.progress_bar_show(1)
+ return
+ self.text(None, -3, pat)
self.text(None, -2, 'Keep scanning more...' if count < hdr.num_parts else 'Got all parts!')
self.text(None, -1, '%s: %d of %d parts' % (hdr.file_label(), count, hdr.num_parts),
dark=True)
diff --git a/shared/login.py b/shared/login.py
index ed6b64a82..97da5b25d 100644
--- a/shared/login.py
+++ b/shared/login.py
@@ -181,14 +181,22 @@ async def interact(self):
async def we_are_ewaste(self, num_fails):
msg = '''After %d failed PIN attempts this Coldcard is locked forever. \
By design, there is no way to reset or recover the secure element, and its contents \
-are now forever inaccessible.
+are now forever inaccessible.\n\n''' % num_fails
-Restore your seed words onto a new Coldcard.''' % num_fails
+ if has_qwerty:
+ msg += 'Calculator mode starts now.'
+ else:
+ msg += 'Restore your seed words onto a new Coldcard.'
while 1:
ch = await ux_show_story(msg, title='I Am Brick!', escape='6')
if ch == '6': break
+ if has_qwerty:
+ from calc import login_repl
+ await login_repl()
+
+
async def confirm_attempt(self, attempts_left, value):
ch = await ux_show_story('''You have %d attempts left before this Coldcard BRICKS \
@@ -270,7 +278,7 @@ async def prompt_pin(self):
return await self.interact()
- async def get_new_pin(self, title, story=None, allow_clear=False):
+ async def get_new_pin(self, title=None, story=None):
# Do UX flow to get new (or change) PIN. Always does the double-entry thing
self.is_setting = True
@@ -283,10 +291,6 @@ async def get_new_pin(self, title, story=None, allow_clear=False):
first_pin = await self.interact()
if first_pin is None: return None
- if allow_clear and first_pin == '999999-999999':
- # don't make them repeat the 'clear pin' value
- return first_pin
-
self.is_repeat = True
while 1:
diff --git a/shared/main.py b/shared/main.py
index 870bd7e15..afacb7e20 100644
--- a/shared/main.py
+++ b/shared/main.py
@@ -61,9 +61,7 @@
from psram import PSRAMWrapper
glob.PSRAM = PSRAMWrapper()
-except BaseException as exc:
- sys.print_exception(exc)
- # continue tho
+except: pass # continue tho
# Setup keypad/keyboard
if version.has_qwerty:
@@ -83,7 +81,6 @@
async def more_setup():
# Boot up code; splash screen is being shown
-
try:
from files import CardSlot
CardSlot.setup()
@@ -91,6 +88,10 @@ async def more_setup():
# This "pa" object holds some state shared w/ bootloader about the PIN
try:
from pincodes import pa
+ # check for bricked system early
+ # bricked CC not going past this point
+ await pa.enforce_brick()
+
pa.setup(b'') # just to see where we stand.
is_blank = pa.is_blank()
except RuntimeError as e:
diff --git a/shared/manifest.py b/shared/manifest.py
index df9165ac9..6699aa78c 100644
--- a/shared/manifest.py
+++ b/shared/manifest.py
@@ -1,17 +1,20 @@
# Freeze everything in this list.
# - not optimized because we need asserts to work
-# - for specific boards, see manifest_mk[34].py and manifest_q1.py
+# - for specific boards, see manifest_{mk4,q1}.py
freeze_as_mpy('', [
'actions.py',
'address_explorer.py',
'auth.py',
'backups.py',
+ 'bsms.py',
'callgate.py',
+ 'ccc.py',
'chains.py',
'choosers.py',
'compat7z.py',
'countdowns.py',
'descriptor.py',
+ 'desc_utils.py',
'dev_helper.py',
'display.py',
'drv_entro.py',
@@ -26,16 +29,24 @@
'login.py',
'main.py',
'menu.py',
+ "miniscript.py",
+ 'mk4.py',
+ 'msgsign.py',
'multisig.py',
+ 'ndef.py',
+ 'nfc.py',
'numpad.py',
'nvstore.py',
'opcodes.py',
+ 'ownership.py',
'paper.py',
'pincodes.py',
+ 'precomp_tag_hash.py',
'psbt.py',
+ 'psram.py',
'pwsave.py',
- 'queues.py',
'qrs.py',
+ 'queues.py',
'random.py',
'seed.py',
'selftest.py',
@@ -43,31 +54,33 @@
'sffile.py',
'ssd1306.py',
'stash.py',
+ 'tapsigner.py',
+ 'trick_pins.py',
'usb.py',
'utils.py',
'ux.py',
+ 'vdisk.py',
'version.py',
- 'xor_seed.py',
- 'tapsigner.py',
'wallet.py',
- 'ownership.py',
+ 'web2fa.py',
+ 'xor_seed.py'
], opt=0)
# Optimize data-like files, since no need to debug them.
freeze_as_mpy('', [
- 'sigheader.py',
- 'public_constants.py',
'charcodes.py',
+ 'public_constants.py',
+ 'sigheader.py',
], opt=3)
# Maybe include test code.
import os
if int(os.environ.get('DEBUG_BUILD', 0)):
freeze_as_mpy('', [
+ 'dev_helper.py',
'h.py',
- 'dev_helper.py',
- 'usb_test_commands.py',
'sim_display.py',
+ 'usb_test_commands.py',
], opt=0)
include("$(MPY_DIR)/extmod/uasyncio/manifest.py")
diff --git a/shared/manifest_mk4.py b/shared/manifest_mk4.py
index 9b062d466..b7ac0c1eb 100644
--- a/shared/manifest_mk4.py
+++ b/shared/manifest_mk4.py
@@ -1,17 +1,11 @@
# Mk4 only files; would not be needed on Mk3 or earlier.
freeze_as_mpy('', [
- 'ssd1306.py',
- 'mempad.py',
- 'psram.py',
- 'mk4.py',
- 'vdisk.py',
- 'nfc.py',
- 'ndef.py',
- 'trick_pins.py',
- 'ux_mk4.py',
'hsm.py',
'hsm_ux.py',
+ 'mempad.py',
+ 'ssd1306.py',
'users.py',
+ 'ux_mk4.py'
], opt=0)
# Optimize data-like files, since no need to debug them.
diff --git a/shared/manifest_q1.py b/shared/manifest_q1.py
index 6d624b801..02370ac07 100644
--- a/shared/manifest_q1.py
+++ b/shared/manifest_q1.py
@@ -1,29 +1,24 @@
-# Q1/Mk4 only files; would not be needed on Mk3 or earlier.
+# Q1 only files; would not be needed on Mk4
freeze_as_mpy('', [
- 'psram.py',
- 'mk4.py',
- 'q1.py',
- 'keyboard.py',
- 'scanner.py',
+ 'battery.py',
'bbqr.py',
- 'decoders.py',
- 'lcd_display.py',
- 'st7788.py',
+ 'calc.py',
+ 'decoders.py',
'gpu.py',
- 'vdisk.py',
- 'nfc.py',
- 'ndef.py',
- 'trick_pins.py',
- 'ux_q1.py',
- 'battery.py',
+ 'keyboard.py',
+ 'lcd_display.py',
'notes.py',
- 'calc.py',
+ 'q1.py',
+ 'scanner.py',
+ 'st7788.py',
+ 'teleport.py',
+ 'ux_q1.py'
], opt=0)
# Optimize data-like files, since no need to debug them.
freeze_as_mpy('', [
- 'graphics_q1.py',
'font_iosevka.py',
'gpu_binary.py', # remove someday?
+ 'graphics_q1.py',
], opt=3)
diff --git a/shared/menu.py b/shared/menu.py
index 4b14ee825..ccaa9e50a 100644
--- a/shared/menu.py
+++ b/shared/menu.py
@@ -119,7 +119,7 @@ def __init__(self, key, **kws):
super().__init__('SHORTCUT', shortcut=key, **kws)
class NonDefaultMenuItem(MenuItem):
- # Show a checkmark if setting is defined and not the default ... so know know it's set
+ # Show a checkmark if setting is defined and not the default
def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
super().__init__(label, **kws)
self.nvkey = nvkey
@@ -182,7 +182,7 @@ async def activate(self, menu, idx):
if self.nvkey == "chain":
default = (self.get() == "BTC")
else:
- default = (self.get(None) == None)
+ default = (self.get(None) is None)
if self.story and default:
ch = await ux_show_story(self.story)
if ch == 'x': return
@@ -306,10 +306,6 @@ def show(self):
if fcn and fcn():
checked = True
- if not has_qwerty and checked and (len(msg) > 14):
- # on mk4 every label longer than 14 will overlap with checkmark
- checked = False
-
if self.multi_selected is not None and (real_idx in self.multi_selected):
# ignore length constraint above, we need to visually show that
# smthg is selected - in any case
@@ -335,9 +331,8 @@ def should_wrap_menu(self):
if wrap: return True
# Do wrap-around (by request from NVK) if longer than the screen itself (on Q),
- # for mk4, limit is 16 which hits mostly the seed word menus.
- limit = 10 if has_qwerty else 16
- return self.count > limit
+ # Mk4: same limit
+ return self.count > 10
def down(self):
if self.cursor < self.count-1:
@@ -362,12 +357,6 @@ def top(self):
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
@@ -388,7 +377,7 @@ def page(self, n):
self.up()
# events
- def on_cancel(self):
+ async def on_cancel(self):
# override me
if the_ux.pop():
# top of stack (main top-level menu)
@@ -399,7 +388,7 @@ async def activate(self, picked):
#
if picked is None:
# "go back" or cancel or something
- self.on_cancel()
+ await self.on_cancel()
else:
await picked.activate(self, self.cursor)
@@ -412,7 +401,7 @@ async def interact(self):
gc.collect()
if self.multi_selected is not None:
# multichoice
- self.on_cancel()
+ await self.on_cancel()
return ch
await self.activate(ch)
@@ -474,7 +463,7 @@ async def wait_choice(self):
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 +478,7 @@ async def wait_choice(self):
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
new file mode 100644
index 000000000..4bbdbb4c8
--- /dev/null
+++ b/shared/miniscript.py
@@ -0,0 +1,1156 @@
+# (c) Copyright 2020 by Stepan Snigirev, see
+#
+# Changes (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+import ngu
+from binascii import unhexlify as a2b_hex
+from binascii import hexlify as b2a_hex
+from serializations import ser_compact_size
+from desc_utils import Key, read_until
+from public_constants import MAX_TR_SIGNERS
+
+
+class Number:
+ def __init__(self, num):
+ self.num = num
+
+ @classmethod
+ def read_from(cls, s, taproot=False):
+ num = 0
+ char = s.read(1)
+ while char in b"0123456789":
+ num = 10 * num + int(char.decode())
+ char = s.read(1)
+ s.seek(-1, 1)
+ return cls(num)
+
+ def compile(self):
+ if self.num == 0:
+ return b"\x00"
+ if self.num <= 16:
+ return bytes([80 + self.num])
+ b = self.num.to_bytes(32, "little").rstrip(b"\x00")
+ if b[-1] >= 128:
+ b += b"\x00"
+ return bytes([len(b)]) + b
+
+ def __len__(self):
+ return len(self.compile())
+
+ def to_string(self, *args, **kwargs):
+ return "%d" % self.num
+
+
+class KeyHash(Key):
+ @classmethod
+ def parse_key(cls, k: bytes, *args, **kwargs):
+ # convert to string
+ kd = k.decode()
+ # raw 20-byte hash
+ if len(kd) == 40:
+ return kd, None
+ return super().parse_key(k, *args, **kwargs)
+
+ def serialize(self, *args, **kwargs):
+ start = 1 if self.taproot else 0
+ return ngu.hash.hash160(self.node.pubkey()[start:33])
+
+ def __len__(self):
+ return 21 # <20:pkh>
+
+ def compile(self):
+ d = self.serialize()
+ return ser_compact_size(len(d)) + d
+
+
+class Raw:
+ def __init__(self, raw):
+ if len(raw) != self.LEN * 2:
+ raise ValueError("Invalid raw element length: %d" % len(raw))
+ self.raw = a2b_hex(raw)
+
+ @classmethod
+ def read_from(cls, s, taproot=False):
+ return cls(s.read(2 * cls.LEN).decode())
+
+ def to_string(self, *args, **kwargs):
+ return b2a_hex(self.raw).decode()
+
+ def compile(self):
+ return ser_compact_size(len(self.raw)) + self.raw
+
+ def __len__(self):
+ return len(ser_compact_size(self.LEN)) + self.LEN
+
+
+class Raw32(Raw):
+ LEN = 32
+ def __len__(self):
+ return 33
+
+
+class Raw20(Raw):
+ LEN = 20
+ def __len__(self):
+ return 21
+
+
+class Miniscript:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.taproot = kwargs.get("taproot", False)
+
+ def compile(self):
+ return self.inner_compile()
+
+ def verify(self):
+ for arg in self.args:
+ if isinstance(arg, Miniscript):
+ arg.verify()
+
+ @property
+ def keys(self):
+ res = []
+ for arg in self.args:
+ if isinstance(arg, Miniscript):
+ res += arg.keys
+ elif isinstance(arg, Key): # KeyHash is subclass of Key
+ res.append(arg)
+ return res
+
+ def is_sane(self, taproot=False):
+ err = "multi mixin"
+ forbiden = (Sortedmulti, Multi) if taproot else (Sortedmulti_a, Multi_a)
+ assert type(self) not in forbiden, err
+
+ for arg in self.args:
+ assert type(arg) not in forbiden, err
+ if isinstance(arg, Miniscript):
+ arg.is_sane(taproot=taproot)
+
+ @staticmethod
+ def key_derive(key, idx, key_map=None, change=False):
+ if key_map and key in key_map:
+ kd = key_map[key]
+ else:
+ kd = key.derive(idx, change=change)
+ return kd
+
+ def derive(self, idx, key_map=None, change=False):
+ args = []
+ for arg in self.args:
+ if isinstance(arg, Key): # KeyHash is subclass of Key
+ arg = self.key_derive(arg, idx, key_map, change=change)
+ elif hasattr(arg, "derive"):
+ arg = arg.derive(idx, key_map, change)
+
+ args.append(arg)
+ return type(self)(*args)
+
+ @property
+ def properties(self):
+ return self.PROPS
+
+ @property
+ def type(self):
+ return self.TYPE
+
+ @classmethod
+ def read_from(cls, s, taproot=False):
+ op, char = read_until(s, b"(")
+ op = op.decode()
+ wrappers = ""
+ if ":" in op:
+ wrappers, op = op.split(":")
+ if char != b"(":
+ raise ValueError("Missing operator")
+ if op not in OPERATOR_NAMES:
+ raise ValueError("Unknown operator '%s'" % op)
+ # number of arguments, classes of arguments, compile function, type, validity checker
+ MiniscriptCls = OPERATORS[OPERATOR_NAMES.index(op)]
+ args = MiniscriptCls.read_arguments(s, taproot=taproot)
+ miniscript = MiniscriptCls(*args, taproot=taproot)
+ for w in reversed(wrappers):
+ if w not in WRAPPER_NAMES:
+ raise ValueError("Unknown wrapper %s" % w)
+ WrapperCls = WRAPPERS[WRAPPER_NAMES.index(w)]
+ miniscript = WrapperCls(miniscript, taproot=taproot)
+ return miniscript
+
+ @classmethod
+ def read_arguments(cls, s, taproot=False):
+ args = []
+ if cls.NARGS is None:
+ if type(cls.ARGCLS) == tuple:
+ firstcls, nextcls = cls.ARGCLS
+ else:
+ firstcls, nextcls = cls.ARGCLS, cls.ARGCLS
+
+ args.append(firstcls.read_from(s, taproot=taproot))
+ while True:
+ char = s.read(1)
+ if char == b",":
+ args.append(nextcls.read_from(s, taproot=taproot))
+ elif char == b")":
+ break
+ else:
+ raise ValueError(
+ "Expected , or ), got: %s" % (char + s.read())
+ )
+ else:
+ for i in range(cls.NARGS):
+ args.append(cls.ARGCLS.read_from(s, taproot=taproot))
+ if i < cls.NARGS - 1:
+ char = s.read(1)
+ if char != b",":
+ raise ValueError("Missing arguments, %s" % char)
+ char = s.read(1)
+ if char != b")":
+ raise ValueError("Expected ) got %s" % (char + s.read()))
+ return args
+
+ def to_string(self, external=True, internal=True):
+ # meh
+ res = type(self).NAME + "("
+ res += ",".join([
+ arg.to_string(external, internal)
+ for arg in self.args
+ ])
+ res += ")"
+ return res
+
+ def __len__(self):
+ """Length of the compiled script, override this if you know the length"""
+ return len(self.compile())
+
+ def len_args(self):
+ return sum([len(arg) for arg in self.args])
+
+########### Known fragments (miniscript operators) ##############
+
+
+class OneArg(Miniscript):
+ NARGS = 1
+ # small handy functions
+ @property
+ def arg(self):
+ return self.args[0]
+
+ @property
+ def carg(self):
+ return self.arg.compile()
+
+
+class PkK(OneArg):
+ #
+ NAME = "pk_k"
+ ARGCLS = Key
+ TYPE = "K"
+ PROPS = "ondu"
+
+ def inner_compile(self):
+ return self.carg
+
+ def __len__(self):
+ return self.len_args()
+
+
+class PkH(OneArg):
+ # DUP HASH160 EQUALVERIFY
+ NAME = "pk_h"
+ ARGCLS = KeyHash
+ TYPE = "K"
+ PROPS = "ndu"
+
+ def inner_compile(self):
+ return b"\x76\xa9" + self.carg + b"\x88"
+
+ def __len__(self):
+ return self.len_args() + 3
+
+class After(OneArg):
+ # CHECKLOCKTIMEVERIFY
+ NAME = "after"
+ ARGCLS = Number
+ TYPE = "B"
+ PROPS = "z"
+
+ def inner_compile(self):
+ return self.carg + b"\xb1"
+
+ def verify(self):
+ super().verify()
+ assert 0 < self.arg.num < 0x80000000, "%s out of range [1, 2147483647]" % self.NAME
+
+ def __len__(self):
+ return self.len_args() + 1
+
+class Older(OneArg):
+ # CHECKSEQUENCEVERIFY
+ NAME = "older"
+ ARGCLS = Number
+ TYPE = "B"
+ PROPS = "z"
+
+ def inner_compile(self):
+ return self.carg + b"\xb2"
+
+ def verify(self):
+ super().verify()
+ # not consensus valid
+ # https://github.com/bitcoin/bitcoin/pull/33135 older(65536) is equivalent to older(1)
+ if self.arg.num & (1 << 22):
+ # time-based
+ assert 0x400000 < self.arg.num < 0x410000, "Time-based %s out of range [4194305, 4259839]" % self.NAME
+ else:
+ # block-based
+ assert 0 < self.arg.num < 0x10000, "Block-based %s out of range [1, 65535]" % self.NAME
+
+ def __len__(self):
+ return self.len_args() + 1
+
+class Sha256(OneArg):
+ # SIZE <32> EQUALVERIFY SHA256 EQUAL
+ NAME = "sha256"
+ ARGCLS = Raw32
+ TYPE = "B"
+ PROPS = "ondu"
+
+ def inner_compile(self):
+ # Number(32).compile() --> b"\x01\x20"
+ return b"\x82\x01\x20\x88\xa8" + self.carg + b"\x87"
+
+ def __len__(self):
+ return self.len_args() + 6
+
+class Hash256(Sha256):
+ # SIZE <32> EQUALVERIFY HASH256 EQUAL
+ NAME = "hash256"
+
+ def inner_compile(self):
+ # Number(32).compile() --> b"\x01\x20"
+ return b"\x82\x01\x20\x88\xaa" + self.carg + b"\x87"
+
+
+class Ripemd160(Sha256):
+ # SIZE <32> EQUALVERIFY RIPEMD160 EQUAL
+ NAME = "ripemd160"
+ ARGCLS = Raw20
+
+ def inner_compile(self):
+ # Number(32).compile() --> b"\x01\x20"
+ return b"\x82\x01\x20\x88\xa6" + self.carg + b"\x87"
+
+
+class Hash160(Ripemd160):
+ # SIZE <32> EQUALVERIFY HASH160 EQUAL
+ NAME = "hash160"
+
+ def inner_compile(self):
+ # Number(32).compile() --> b"\x01\x20"
+ return b"\x82\x01\x20\x88\xa9" + self.carg + b"\x87"
+
+
+class AndOr(Miniscript):
+ # [X] NOTIF [Z] ELSE [Y] ENDIF
+ NAME = "andor"
+ NARGS = 3
+ ARGCLS = Miniscript
+
+ @property
+ def type(self):
+ # type same as Y/Z
+ return self.args[1].type
+
+ def verify(self):
+ # requires: X is Bdu; Y and Z are both B, K, or V
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("andor: X should be 'B'")
+ px = self.args[0].properties
+ if "d" not in px and "u" not in px:
+ raise ValueError("andor: X should be 'du'")
+ if self.args[1].type != self.args[2].type:
+ raise ValueError("andor: Y and Z should have the same types")
+ if self.args[1].type not in "BKV":
+ raise ValueError("andor: Y and Z should be B K or V")
+
+ @property
+ def properties(self):
+ # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ
+ props = ""
+ px, py, pz = [arg.properties for arg in self.args]
+ if "z" in px and "z" in py and "z" in pz:
+ props += "z"
+ if ("z" in px and "o" in py and "o" in pz) or (
+ "o" in px and "z" in py and "z" in pz
+ ):
+ props += "o"
+ if "u" in py and "u" in pz:
+ props += "u"
+ if "d" in pz:
+ props += "d"
+ return props
+
+ def inner_compile(self):
+ return (
+ self.args[0].compile()
+ + b"\x64"
+ + self.args[2].compile()
+ + b"\x67"
+ + self.args[1].compile()
+ + b"\x68"
+ )
+
+ def __len__(self):
+ return self.len_args() + 3
+
+class AndV(Miniscript):
+ # [X] [Y]
+ NAME = "and_v"
+ NARGS = 2
+ ARGCLS = Miniscript
+
+ def inner_compile(self):
+ return self.args[0].compile() + self.args[1].compile()
+
+ def __len__(self):
+ return self.len_args()
+
+ def verify(self):
+ # X is V; Y is B, K, or V
+ super().verify()
+ if self.args[0].type != "V":
+ raise ValueError("and_v: X should be 'V'")
+ if self.args[1].type not in "BKV":
+ raise ValueError("and_v: Y should be B K or V")
+
+ @property
+ def type(self):
+ # same as Y
+ return self.args[1].type
+
+ @property
+ def properties(self):
+ # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY
+ px, py = [arg.properties for arg in self.args]
+ props = ""
+ if "z" in px and "z" in py:
+ props += "z"
+ if ("z" in px and "o" in py) or ("z" in py and "o" in px):
+ props += "o"
+ if "n" in px or ("z" in px and "n" in py):
+ props += "n"
+ if "u" in py:
+ props += "u"
+ return props
+
+
+class AndB(Miniscript):
+ # [X] [Y] BOOLAND
+ NAME = "and_b"
+ NARGS = 2
+ ARGCLS = Miniscript
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.args[0].compile() + self.args[1].compile() + b"\x9a"
+
+ def __len__(self):
+ return self.len_args() + 1
+
+ def verify(self):
+ # X is B; Y is W
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("and_b: X should be B")
+ if self.args[1].type != "W":
+ raise ValueError("and_b: Y should be W")
+
+ @property
+ def properties(self):
+ # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; d=dXdY; u
+ px, py = [arg.properties for arg in self.args]
+ props = ""
+ if "z" in px and "z" in py:
+ props += "z"
+ if ("z" in px and "o" in py) or ("z" in py and "o" in px):
+ props += "o"
+ if "n" in px or ("z" in px and "n" in py):
+ props += "n"
+ if "d" in px and "d" in py:
+ props += "d"
+ props += "u"
+ return props
+
+
+class AndN(Miniscript):
+ # [X] NOTIF 0 ELSE [Y] ENDIF
+ # andor(X,Y,0)
+ NAME = "and_n"
+ NARGS = 2
+ ARGCLS = Miniscript
+
+ def inner_compile(self):
+ return (
+ self.args[0].compile()
+ + b"\x64\x00\x67"
+ + self.args[1].compile()
+ + b"\x68"
+ )
+
+ def __len__(self):
+ return self.len_args() + 4
+
+ @property
+ def type(self):
+ # type same as Y/Z
+ return self.args[1].type
+
+ def verify(self):
+ # requires: X is Bdu; Y and Z are both B, K, or V
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("and_n: X should be 'B'")
+ px = self.args[0].properties
+ if "d" not in px and "u" not in px:
+ raise ValueError("and_n: X should be 'du'")
+ if self.args[1].type != "B":
+ raise ValueError("and_n: Y should be B")
+
+ @property
+ def properties(self):
+ # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ
+ props = ""
+ px, py = [arg.properties for arg in self.args]
+ pz = "zud"
+ if "z" in px and "z" in py and "z" in pz:
+ props += "z"
+ if ("z" in px and "o" in py and "o" in pz) or (
+ "o" in px and "z" in py and "z" in pz
+ ):
+ props += "o"
+ if "u" in py and "u" in pz:
+ props += "u"
+ if "d" in pz:
+ props += "d"
+ return props
+
+
+class OrB(Miniscript):
+ # [X] [Z] BOOLOR
+ NAME = "or_b"
+ NARGS = 2
+ ARGCLS = Miniscript
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.args[0].compile() + self.args[1].compile() + b"\x9b"
+
+ def __len__(self):
+ return self.len_args() + 1
+
+ def verify(self):
+ # X is Bd; Z is Wd
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("or_b: X should be B")
+ if "d" not in self.args[0].properties:
+ raise ValueError("or_b: X should be d")
+ if self.args[1].type != "W":
+ raise ValueError("or_b: Z should be W")
+ if "d" not in self.args[1].properties:
+ raise ValueError("or_b: Z should be d")
+
+ @property
+ def properties(self):
+ # z=zXzZ; o=zXoZ or zZoX; d; u
+ props = ""
+ px, pz = [arg.properties for arg in self.args]
+ if "z" in px and "z" in pz:
+ props += "z"
+ if ("z" in px and "o" in pz) or ("z" in pz and "o" in px):
+ props += "o"
+ props += "du"
+ return props
+
+
+class OrC(Miniscript):
+ # [X] NOTIF [Z] ENDIF
+ NAME = "or_c"
+ NARGS = 2
+ ARGCLS = Miniscript
+ TYPE = "V"
+
+ def inner_compile(self):
+ return self.args[0].compile() + b"\x64" + self.args[1].compile() + b"\x68"
+
+ def __len__(self):
+ return self.len_args() + 2
+
+ def verify(self):
+ # X is Bdu; Z is V
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("or_c: X should be B")
+ if self.args[1].type != "V":
+ raise ValueError("or_c: Z should be V")
+ px = self.args[0].properties
+ if "d" not in px or "u" not in px:
+ raise ValueError("or_c: X should be du")
+
+ @property
+ def properties(self):
+ # z=zXzZ; o=oXzZ
+ props = ""
+ px, pz = [arg.properties for arg in self.args]
+ if "z" in px and "z" in pz:
+ props += "z"
+ if "o" in px and "z" in pz:
+ props += "o"
+ return props
+
+
+class OrD(Miniscript):
+ # [X] IFDUP NOTIF [Z] ENDIF
+ NAME = "or_d"
+ NARGS = 2
+ ARGCLS = Miniscript
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.args[0].compile() + b"\x73\x64" + self.args[1].compile() + b"\x68"
+
+ def __len__(self):
+ return self.len_args() + 3
+
+ def verify(self):
+ # X is Bdu; Z is B
+ super().verify()
+ if self.args[0].type != "B":
+ raise ValueError("or_d: X should be B")
+ if self.args[1].type != "B":
+ raise ValueError("or_d: Z should be B")
+ px = self.args[0].properties
+ if "d" not in px or "u" not in px:
+ raise ValueError("or_d: X should be du")
+
+ @property
+ def properties(self):
+ # z=zXzZ; o=oXzZ; d=dZ; u=uZ
+ props = ""
+ px, pz = [arg.properties for arg in self.args]
+ if "z" in px and "z" in pz:
+ props += "z"
+ if "o" in px and "z" in pz:
+ props += "o"
+ if "d" in pz:
+ props += "d"
+ if "u" in pz:
+ props += "u"
+ return props
+
+
+class OrI(Miniscript):
+ # IF [X] ELSE [Z] ENDIF
+ NAME = "or_i"
+ NARGS = 2
+ ARGCLS = Miniscript
+
+ def inner_compile(self):
+ return (
+ b"\x63"
+ + self.args[0].compile()
+ + b"\x67"
+ + self.args[1].compile()
+ + b"\x68"
+ )
+
+ def __len__(self):
+ return self.len_args() + 3
+
+ def verify(self):
+ # both are B, K, or V
+ super().verify()
+ if self.args[0].type != self.args[1].type:
+ raise ValueError("or_i: X and Z should be the same type")
+ if self.args[0].type not in "BKV":
+ raise ValueError("or_i: X and Z should be B K or V")
+
+ @property
+ def type(self):
+ return self.args[0].type
+
+ @property
+ def properties(self):
+ # o=zXzZ; u=uXuZ; d=dX or dZ
+ props = ""
+ px, pz = [arg.properties for arg in self.args]
+ if "z" in px and "z" in pz:
+ props += "o"
+ if "u" in px and "u" in pz:
+ props += "u"
+ if "d" in px or "d" in pz:
+ props += "d"
+ return props
+
+
+class Thresh(Miniscript):
+ # [X1] [X2] ADD ... [Xn] ADD ... EQUAL
+ NAME = "thresh"
+ NARGS = None
+ ARGCLS = (Number, Miniscript)
+ TYPE = "B"
+
+ def inner_compile(self):
+ return (
+ self.args[1].compile()
+ + b"".join([arg.compile()+b"\x93" for arg in self.args[2:]])
+ + self.args[0].compile()
+ + b"\x87"
+ )
+
+ def __len__(self):
+ return self.len_args() + len(self.args) - 1
+
+ def verify(self):
+ # 1 <= k <= n; X1 is Bdu; others are Wdu
+ super().verify()
+ if self.args[0].num < 1 or self.args[0].num >= len(self.args):
+ raise ValueError(
+ "thresh: Invalid k! Should be 1 <= k <= %d, got %d"
+ % (len(self.args) - 1, self.args[0].num)
+ )
+ if self.args[1].type != "B":
+ raise ValueError("thresh: X1 should be B")
+ px = self.args[1].properties
+ if "d" not in px or "u" not in px:
+ raise ValueError("thresh: X1 should be du")
+ for i, arg in enumerate(self.args[2:]):
+ if arg.type != "W":
+ raise ValueError("thresh: X%d should be W" % (i + 1))
+ p = arg.properties
+ if "d" not in p or "u" not in p:
+ raise ValueError("thresh: X%d should be du" % (i + 1))
+
+ @property
+ def properties(self):
+ # z=all are z; o=all are z except one is o; d; u
+ props = ""
+ parr = [arg.properties for arg in self.args[1:]]
+ zarr = ["z" for p in parr if "z" in p]
+ if len(zarr) == len(parr):
+ props += "z"
+ noz = [p for p in parr if "z" not in p]
+ if len(noz) == 1 and "o" in noz[0]:
+ props += "o"
+ props += "du"
+ return props
+
+
+class Multi(Miniscript):
+ # ... CHECKMULTISIG
+ NAME = "multi"
+ NARGS = None
+ ARGCLS = (Number, Key)
+ TYPE = "B"
+ PROPS = "ndu"
+ N_MAX = 20
+
+ def inner_compile(self):
+ # scr = [arg.compile() for arg in self.args[1:]]
+ # optimization - it is all keys with known length (xonly keys not allowed here)
+ scr = [b'\x21' + arg.key_bytes() for arg in self.args[1:]]
+ if self.NAME == "sortedmulti":
+ scr.sort()
+ return (
+ self.args[0].compile()
+ + b"".join(scr)
+ + Number(len(self.args) - 1).compile()
+ + b"\xae"
+ )
+
+ def __len__(self):
+ return self.len_args() + 2
+
+ def m_n(self):
+ return self.args[0].num, len(self.args[1:])
+
+ def verify(self):
+ super().verify()
+ N = (len(self.args) - 1)
+ assert N <= self.N_MAX, 'M/N range'
+ M = self.args[0].num
+ if M < 1 or M > N:
+ raise ValueError(
+ "M must be <= N: 1 <= M <= %d, got %d" % ((len(self.args) - 1), self.args[0].num)
+ )
+
+
+class Sortedmulti(Multi):
+ # ... CHECKMULTISIG
+ NAME = "sortedmulti"
+
+
+class Multi_a(Multi):
+ # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY
+ NAME = "multi_a"
+ PROPS = "du"
+ N_MAX = MAX_TR_SIGNERS
+
+ def inner_compile(self):
+ from opcodes import OP_CHECKSIGADD, OP_NUMEQUAL, OP_CHECKSIG
+ script = b""
+ # scr = [arg.compile() for arg in self.args[1:]]
+ # optimization - it is all keys with known length (only xonly keys allowed here)
+ scr = [b"\x20" + arg.key_bytes() for arg in self.args[1:]]
+ if self.NAME == "sortedmulti_a":
+ scr.sort()
+
+ for i, key in enumerate(scr):
+ script += key
+ if i == 0:
+ script += bytes([OP_CHECKSIG])
+ else:
+ script += bytes([OP_CHECKSIGADD])
+
+ script += self.args[0].compile() # M (threshold)
+ script += bytes([OP_NUMEQUAL])
+ return script
+
+ def __len__(self):
+ # len(M) + len(k0) ... + len(kN) + len(keys) + 1
+ return self.len_args() + len(self.args)
+
+
+class Sortedmulti_a(Multi_a):
+ # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY
+ NAME = "sortedmulti_a"
+
+
+class Pk(OneArg):
+ # CHECKSIG
+ NAME = "pk"
+ ARGCLS = Key
+ TYPE = "B"
+ PROPS = "ondu"
+
+ def inner_compile(self):
+ return self.carg + b"\xac"
+
+ def __len__(self):
+ return self.len_args() + 1
+
+
+class Pkh(OneArg):
+ # DUP HASH160 EQUALVERIFY CHECKSIG
+ NAME = "pkh"
+ ARGCLS = KeyHash
+ TYPE = "B"
+ PROPS = "ndu"
+
+ def inner_compile(self):
+ return b"\x76\xa9" + self.carg + b"\x88\xac"
+
+ def __len__(self):
+ return self.len_args() + 4
+
+
+OPERATORS = [
+ PkK,
+ PkH,
+ Older,
+ After,
+ Sha256,
+ Hash256,
+ Ripemd160,
+ Hash160,
+ AndOr,
+ AndV,
+ AndB,
+ AndN,
+ OrB,
+ OrC,
+ OrD,
+ OrI,
+ Thresh,
+ Multi,
+ Sortedmulti,
+ Multi_a,
+ Sortedmulti_a,
+ Pk,
+ Pkh,
+]
+OPERATOR_NAMES = [cls.NAME for cls in OPERATORS]
+
+
+class Wrapper(OneArg):
+ ARGCLS = Miniscript
+
+ @property
+ def op(self):
+ return type(self).__name__.lower()
+
+ def to_string(self, *args, **kwargs):
+ # more wrappers follow
+ if isinstance(self.arg, Wrapper):
+ return self.op + self.arg.to_string(*args, **kwargs)
+ # we are the last wrapper
+ return self.op + ":" + self.arg.to_string(*args, **kwargs)
+
+
+class A(Wrapper):
+ # TOALTSTACK [X] FROMALTSTACK
+ TYPE = "W"
+
+ def inner_compile(self):
+ return b"\x6b" + self.carg + b"\x6c"
+
+ def __len__(self):
+ return len(self.arg) + 2
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("a: X should be B")
+
+ @property
+ def properties(self):
+ props = ""
+ px = self.arg.properties
+ if "d" in px:
+ props += "d"
+ if "u" in px:
+ props += "u"
+ return props
+
+
+class S(Wrapper):
+ # SWAP [X]
+ TYPE = "W"
+
+ def inner_compile(self):
+ return b"\x7c" + self.carg
+
+ def __len__(self):
+ return len(self.arg) + 1
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("s: X should be B")
+ if "o" not in self.arg.properties:
+ raise ValueError("s: X should be o")
+
+ @property
+ def properties(self):
+ props = ""
+ px = self.arg.properties
+ if "d" in px:
+ props += "d"
+ if "u" in px:
+ props += "u"
+ return props
+
+
+class C(Wrapper):
+ # [X] CHECKSIG
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.carg + b"\xac"
+
+ def __len__(self):
+ return len(self.arg) + 1
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "K":
+ raise ValueError("c: X should be K")
+
+ @property
+ def properties(self):
+ props = ""
+ px = self.arg.properties
+ for p in ["o", "n", "d"]:
+ if p in px:
+ props += p
+ props += "u"
+ return props
+
+
+class T(Wrapper):
+ # [X] 1
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.carg + b"\x51" # Number(1).compile() --> b"\x51"
+
+ def __len__(self):
+ return len(self.arg) + 1
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "V":
+ raise ValueError("t: X must be of type V")
+
+ @property
+ def properties(self):
+ # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY
+ px = self.arg.properties
+ py = "zu"
+ props = ""
+ if "z" in px and "z" in py:
+ props += "z"
+ if ("z" in px and "o" in py) or ("z" in py and "o" in px):
+ props += "o"
+ if "n" in px or ("z" in px and "n" in py):
+ props += "n"
+ if "u" in py:
+ props += "u"
+ return props
+
+
+class D(Wrapper):
+ # DUP IF [X] ENDIF
+ TYPE = "B"
+
+ def inner_compile(self):
+ return b"\x76\x63" + self.carg + b"\x68"
+
+ def __len__(self):
+ return len(self.arg) + 3
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "V":
+ raise ValueError("d: X should be V")
+ if "z" not in self.arg.properties:
+ raise ValueError("d: X should be z")
+
+ @property
+ def properties(self):
+ # https://github.com/bitcoin/bitcoin/pull/24906
+ if self.taproot:
+ props = "ndu"
+ else:
+ props = "nd"
+ px = self.arg.properties
+ if "z" in px:
+ props += "o"
+ return props
+
+
+class V(Wrapper):
+ # [X] VERIFY (or VERIFY version of last opcode in [X])
+ TYPE = "V"
+
+ def inner_compile(self):
+ """Checks last check code and makes it verify"""
+ if self.carg[-1] in [0xAC, 0xAE, 0x9C, 0x87]:
+ return self.carg[:-1] + bytes([self.carg[-1] + 1])
+ return self.carg + b"\x69"
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("v: X should be B")
+
+ @property
+ def properties(self):
+ props = ""
+ px = self.arg.properties
+ for p in ["z", "o", "n"]:
+ if p in px:
+ props += p
+ return props
+
+
+class J(Wrapper):
+ # SIZE 0NOTEQUAL IF [X] ENDIF
+ TYPE = "B"
+
+ def inner_compile(self):
+ return b"\x82\x92\x63" + self.carg + b"\x68"
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("j: X should be B")
+ if "n" not in self.arg.properties:
+ raise ValueError("j: X should be n")
+
+ @property
+ def properties(self):
+ props = "nd"
+ px = self.arg.properties
+ for p in ["o", "u"]:
+ if p in px:
+ props += p
+ return props
+
+
+class N(Wrapper):
+ # [X] 0NOTEQUAL
+ TYPE = "B"
+
+ def inner_compile(self):
+ return self.carg + b"\x92"
+
+ def __len__(self):
+ return len(self.arg) + 1
+
+ def verify(self):
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("n: X should be B")
+
+ @property
+ def properties(self):
+ props = "u"
+ px = self.arg.properties
+ for p in ["z", "o", "n", "d"]:
+ if p in px:
+ props += p
+ return props
+
+
+class L(Wrapper):
+ # IF 0 ELSE [X] ENDIF
+ TYPE = "B"
+
+ def inner_compile(self):
+ return b"\x63\x00\x67" + self.carg + b"\x68"
+
+ def __len__(self):
+ return len(self.arg) + 4
+
+ def verify(self):
+ # both are B, K, or V
+ super().verify()
+ if self.arg.type != "B":
+ raise ValueError("or_i: X and Z should be the same type")
+
+ @property
+ def properties(self):
+ # o=zXzZ; u=uXuZ; d=dX or dZ
+ props = "d"
+ pz = self.arg.properties
+ if "z" in pz:
+ props += "o"
+ if "u" in pz:
+ props += "u"
+ return props
+
+
+class U(L):
+ # IF [X] ELSE 0 ENDIF
+ def inner_compile(self):
+ return b"\x63" + self.carg + b"\x67\x00\x68"
+
+ def __len__(self):
+ return len(self.arg) + 4
+
+
+WRAPPERS = [A, S, C, T, D, V, J, N, L, U]
+WRAPPER_NAMES = [w.__name__.lower() for w in WRAPPERS]
\ No newline at end of file
diff --git a/shared/mk4.py b/shared/mk4.py
index 6bffbd18e..69b024ced 100644
--- a/shared/mk4.py
+++ b/shared/mk4.py
@@ -11,8 +11,8 @@ def make_flash_fs():
os.VfsLfs2.mkfs(fl)
os.mount(fl, '/flash')
-
- os.mkdir('/flash/settings')
+ os.chdir('/flash')
+ os.mkdir('settings')
def make_psram_fs():
# Filesystem is wiped and rebuilt on each boot before this point, but
@@ -58,8 +58,7 @@ def init0():
try:
make_psram_fs()
- except BaseException as exc:
- sys.print_exception(exc)
+ except: pass
if version.is_devmode:
try:
@@ -71,10 +70,13 @@ def init0():
rng_seeding()
async def dev_enable_repl(*a):
- # Mk4: Enable serial port connection. You'll have to break case open.
+ # Enable serial port connection. You'll have to break case open.
+
from ux import ux_show_story
+ from utils import wipe_if_deltamode
wipe_if_deltamode()
+ if not version.is_devmode: return
# allow REPL access
ckcc.vcp_enabled(True)
@@ -83,15 +85,4 @@ async def dev_enable_repl(*a):
await ux_show_story("""\
The serial port has now been enabled.\n\n3.3v TTL on Tx/Rx/Gnd pads @ 115,200 bps.""")
-def wipe_if_deltamode():
- # If in deltamode, give up and wipe self rather do
- # a thing that might reveal true master secret...
-
- from pincodes import pa
-
- if not pa.is_deltamode():
- return
-
- callgate.fast_wipe()
-
# EOF
diff --git a/shared/msgsign.py b/shared/msgsign.py
new file mode 100644
index 000000000..144dc6516
--- /dev/null
+++ b/shared/msgsign.py
@@ -0,0 +1,512 @@
+# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# Signatures over text ... not transactions.
+#
+import stash, chains, sys, gc, ngu, ujson, version
+from ubinascii import b2a_base64, a2b_base64
+from ubinascii import hexlify as b2a_hex
+from ubinascii import unhexlify as a2b_hex
+from uhashlib import sha256
+from public_constants import MSG_SIGNING_MAX_LENGTH
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
+from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
+from ux import (ux_show_story, OK, ux_enter_bip32_index, ux_input_text, the_ux,
+ import_export_prompt, ux_aborted)
+from utils import problem_file_line, to_ascii_printable, show_single_address
+from files import CardSlot, CardMissingError, needs_microsd
+
+def rfc_signature_template(msg, addr, sig):
+ # RFC2440 style signatures, popular
+ # since the genesis block, but not really part of any BIP as far as I know.
+ #
+ return [
+ "-----BEGIN BITCOIN SIGNED MESSAGE-----\n",
+ "%s\n" % msg,
+ "-----BEGIN BITCOIN SIGNATURE-----\n",
+ "%s\n" % addr,
+ "%s\n" % sig,
+ "-----END BITCOIN SIGNATURE-----\n"
+ ]
+
+def parse_armored_signature_file(contents):
+ # XXX limited parser: will fail w/ messages containing dashes
+ sep = "-----"
+ assert contents.count(sep) == 6, "Armor text MUST be surrounded by exactly five (5) dashes."
+
+ temp = contents.split(sep)
+ msg = temp[2].strip()
+ addr_sig = temp[4].strip()
+ addr, sig_str = addr_sig.split()
+
+ return msg, addr, sig_str
+
+def verify_signature(msg, addr, sig_str):
+ # Look at a base64 signature, and given address. Do full verification.
+ # - raise on errors
+ # - return warnings as string: can only be mismatch between addr format encoded in recid
+ warnings = ""
+ script = None
+ hash160 = None
+ invalid_addr_fmt_msg = "Invalid address format - must be one of p2pkh, p2sh-p2wpkh, or p2wpkh."
+ invalid_addr = "Invalid signature for message."
+
+ if addr[0] in "1mn":
+ addr_fmt = AF_CLASSIC
+ decoded_addr = ngu.codecs.b58_decode(addr)
+ hash160 = decoded_addr[1:] # remove prefix
+ elif addr.startswith("bc1q") or addr.startswith("tb1q") or addr.startswith("bcrt1q"):
+ if len(addr) > 44: # testnet/mainnet max singlesig len 42, regtest 44
+ # p2wsh
+ raise ValueError(invalid_addr_fmt_msg)
+ addr_fmt = AF_P2WPKH
+ _, _, hash160 = ngu.codecs.segwit_decode(addr)
+ elif addr[0] in "32":
+ addr_fmt = AF_P2WPKH_P2SH
+ decoded_addr = ngu.codecs.b58_decode(addr)
+ script = decoded_addr[1:] # remove prefix
+ else:
+ raise ValueError(invalid_addr_fmt_msg)
+
+ try:
+ sig_bytes = a2b_base64(sig_str)
+ if not sig_bytes or len(sig_bytes) != 65:
+ # can return b'' in case of wrong, can also raise
+ raise ValueError("invalid encoding")
+
+ header_byte = sig_bytes[0]
+ header_base = chains.current_chain().sig_hdr_base(addr_fmt)
+ if (header_byte - header_base) not in (0, 1, 2, 3):
+ # wrong header value only - this can still verify OK
+ warnings += "Specified address format does not match signature header byte format."
+
+ # least two significant bits
+ rec_id = (header_byte - 27) & 0x03
+ # need to normalize it to 31 base for ngu
+ new_header_byte = 31 + rec_id
+ sig = ngu.secp256k1.signature(bytes([new_header_byte]) + sig_bytes[1:])
+ except ValueError as e:
+ raise ValueError("Parsing signature failed - %s." % str(e))
+
+ digest = chains.current_chain().hash_message(msg.encode('ascii'))
+ try:
+ rec_pubkey = sig.verify_recover(digest)
+ except ValueError as e:
+ raise ValueError("Invalid signature for msg - %s." % str(e))
+
+ rec_pubkey_bytes = rec_pubkey.to_bytes()
+ rec_hash160 = ngu.hash.hash160(rec_pubkey_bytes)
+
+ if script:
+ target = bytes([0, 20]) + rec_hash160
+ target = ngu.hash.hash160(target)
+ if target != script:
+ raise ValueError(invalid_addr)
+ else:
+ if rec_hash160 != hash160:
+ raise ValueError(invalid_addr)
+
+ return warnings
+
+async def verify_armored_signed_msg(contents, digest_check=True):
+ # Verify on-disk checksums of files listed inside a signed file.
+ # - digest_check=False for NFC cases, where we do not have filesystem
+ from glob import dis
+
+ dis.fullscreen("Verifying...")
+
+ try:
+ msg, addr, sig_str = parse_armored_signature_file(contents)
+ except Exception as e:
+ e_line = problem_file_line(e)
+ await ux_show_story("Malformed signature file. %s %s" % (str(e), e_line), title="FAILURE")
+ return
+
+ try:
+ sig_warn = verify_signature(msg, addr, sig_str)
+ except Exception as e:
+ await ux_show_story(str(e), title="ERROR")
+ return
+
+ title = "CORRECT"
+ warn_msg = ""
+ err_msg = ""
+ story = "Good signature by address:\n%s" % show_single_address(addr)
+
+ if digest_check:
+ digest_prob = verify_signed_file_digest(msg)
+ if digest_prob:
+ err, digest_warn = digest_prob
+ if digest_warn:
+ title = "WARNING"
+ wmsg_base = "not present. Contents verification not possible."
+ if len(digest_warn) == 1:
+ fname = digest_warn[0][0]
+ warn_msg += "'%s' is %s" % (fname, wmsg_base)
+ else:
+ warn_msg += "Files:\n" + "\n".join("> %s" % fname for fname, _ in digest_warn)
+ warn_msg += "\nare %s" % wmsg_base
+
+ if err:
+ title = "ERROR"
+ for fname, calc, got in err:
+ err_msg += ("Referenced file '%s' has wrong contents.\n"
+ "Got:\n%s\n\nExpected:\n%s" % (fname, got, calc))
+
+ if sig_warn:
+ # we know not ours only because wrong recid header used & not BIP-137 compliant
+ story = "Correctly signed, but not by this Coldcard. %s" % sig_warn
+
+ 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):
+ # copy message into memory
+ try:
+ with CardSlot() as card:
+ with card.open(filename, 'rt') as fd:
+ text = fd.read()
+ except CardMissingError:
+ await needs_microsd()
+ return
+ except Exception as e:
+ await ux_show_story('Error: ' + str(e))
+ return
+
+ await verify_armored_signed_msg(text)
+
+async def msg_sign_ux_get_subpath(addr_fmt):
+ # Ask for account number, and maybe change component of path for signature.
+ # - return full derivation path to be used.
+ 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)
+
+
+def sign_export_contents(content_list, deriv, addr_fmt, pk=None):
+ # Return signed message over hashes of files.
+ msg2sign = make_signature_file_msg(content_list)
+ bitcoin_digest = chains.current_chain().hash_message(msg2sign)
+ sig_bytes, addr = sign_message_digest(bitcoin_digest, deriv, "Signing...", addr_fmt, pk=pk)
+ sig = b2a_base64(sig_bytes).decode().strip()
+
+ return rfc_signature_template(addr=addr, msg=msg2sign.decode(), sig=sig)
+
+def verify_signed_file_digest(msg):
+ # Look inside a list of hashs and file names, and
+ # verify at their actual hashes and return list of issues if any.
+ parsed_msg = parse_signature_file_msg(msg)
+ if not parsed_msg:
+ # not our format
+ return
+
+ try:
+ err, warn = [], []
+ with CardSlot() as card:
+ for digest, fname in parsed_msg:
+ path = card.abs_path(fname)
+ if not card.exists(path):
+ warn.append((fname, None))
+ continue
+ path = card.abs_path(fname)
+
+ md = sha256()
+ with open(path, "rb") as f:
+ while True:
+ chunk = f.read(1024)
+ if not chunk:
+ break
+ md.update(chunk)
+
+ h = b2a_hex(md.digest()).decode().strip()
+ if h != digest:
+ err.append((fname, h, digest))
+ except:
+ # fail silently if issues with reading files or SD issues
+ # no digest checking
+ return
+
+ return err, warn
+
+def write_sig_file(content_list, derive=None, addr_fmt=AF_CLASSIC, pk=None, sig_name=None):
+ if derive is None:
+ ct = chains.current_chain().b44_cointype
+ derive = "m/44'/%d'/0'/0/0" % ct
+
+ fpath = content_list[0][1]
+ if len(content_list) > 1:
+ # we're signing contents of more files - need generic name for sig file
+ assert sig_name
+ sig_nice = sig_name + ".sig"
+ sig_fpath = fpath.rsplit("/", 1)[0] + "/" + sig_nice
+ else:
+ sig_fpath = fpath.rsplit(".", 1)[0] + ".sig"
+ sig_nice = sig_fpath.split("/")[-1]
+
+ sig_gen = sign_export_contents([(h, f.split("/")[-1]) for h, f in content_list],
+ derive, addr_fmt, pk=pk)
+
+ with open(sig_fpath, 'wt') as fd:
+ for i, part in enumerate(sig_gen):
+ fd.write(part)
+
+ return sig_nice
+
+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
+
+ result = to_ascii_printable(text, only_printable=only_printable)
+
+ length = len(result)
+ assert length >= 2, "msg too short (min. 2)"
+ assert length <= MSG_SIGNING_MAX_LENGTH, "msg too long (max. %d)" % MSG_SIGNING_MAX_LENGTH
+ assert " " not in result, 'too many spaces together in msg(max. 3)'
+ # other confusion w/ whitepace
+ assert result[0] != ' ', 'leading space(s) in msg'
+ assert result[-1] != ' ', 'trailing space(s) in msg'
+
+ # 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 lines, "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
+
+
+def make_signature_file_msg(content_list):
+ # list of tuples consisting of (hash, file_name)
+ return b"\n".join([
+ b2a_hex(h) + b" " + fname.encode()
+ for h, fname in content_list
+ ])
+
+def parse_signature_file_msg(msg):
+ # only succeed for our format digest + 2 spaces + fname
+ try:
+ res = []
+ lines = msg.split('\n')
+ for ln in lines:
+ d, fn = ln.split(' ')
+ # should not need to strip if our file format, so dont
+ # is hex? is 32 bytes long?
+ assert len(a2b_hex(d)) == 32
+ res.append((d, fn))
+
+ return res
+ except:
+ return
+
+def sign_message_digest(digest, subpath, prompt, addr_fmt=AF_CLASSIC, pk=None):
+ # do the signature itself!
+ from glob import dis
+
+ ch = chains.current_chain()
+
+ if prompt:
+ dis.fullscreen(prompt, percent=.25)
+
+ if pk is None:
+ with stash.SensitiveValues() as sv:
+ node = sv.derive_path(subpath)
+ dis.progress_sofar(50, 100)
+ pk = node.privkey()
+ addr = ch.address(node, addr_fmt)
+ else:
+ # if private key is provided, derivation subpath is ignored
+ # and given private key is used for signing.
+ node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
+ dis.progress_sofar(50, 100)
+ addr = ch.address(node, addr_fmt)
+
+ dis.progress_sofar(75, 100)
+
+ rv = ngu.secp256k1.sign(pk, digest, 0).to_bytes()
+
+ # AF_CLASSIC header byte base 31 is returned by default from ngu - NOOP
+ if addr_fmt != AF_CLASSIC:
+ # ngu only produces header base for compressed p2pkh, anyways get only rec_id
+ rv = bytearray(rv)
+ rec_id = (rv[0] - 27) & 0x03
+ rv[0] = rec_id + ch.sig_hdr_base(addr_fmt=addr_fmt)
+
+ dis.progress_bar_show(1)
+
+ return rv, addr
+
+async def ux_sign_msg(txt, approved_cb=None, kill_menu=True):
+ from menu import MenuSystem, MenuItem
+
+ async def done(_1, _2, item):
+ from auth import approve_msg_sign
+
+ 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 chains.SINGLESIG_AF
+ ]
+ the_ux.push(MenuSystem(rv))
+
+async def msg_signing_done(signature, address, text):
+ ch = await import_export_prompt("Signed Msg")
+ 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
+
+ to_sign = await ux_input_text("", scan_ok=True, prompt="Enter MSG") # max len is 100 only here
+ if not to_sign: return
+
+ from auth import approve_msg_sign
+ 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(addr=address, msg=text, sig=sig)
+ for i, part in enumerate(gen):
+ fd.write(part)
+
+ # 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')
+
+
+
+# EOF
diff --git a/shared/multisig.py b/shared/multisig.py
index f0bb1620d..2aa63f2ec 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -1,1628 +1,62 @@
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
-# multisig.py - support code for multisig signing and p2sh in general.
+# multisig.py - ms coordinator code mostly + some utils
#
import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version
-from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable
-from utils import str_to_keypath, problem_file_line, parse_extended_key, get_filesize
-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 public_constants import AF_P2WSH, AF_P2WSH_P2SH
+from ubinascii import hexlify as b2a_hex
+from utils import xfp2str, problem_file_line, get_filesize
from files import CardSlot, CardMissingError, needs_microsd
-from descriptor import MultisigDescriptor, multisig_descriptor_template
-from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS
-from menu import MenuSystem, MenuItem, NonDefaultMenuItem
-from opcodes import OP_CHECKMULTISIG
-from exceptions import FatalPSBTIssue
+from ux import ux_show_story, ux_dramatic_pause, ux_enter_number, ux_enter_bip32_index
+from public_constants import MAX_SIGNERS
from glob import settings
-from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
-from wallet import WalletABC, MAX_BIP32_IDX
+from charcodes import KEY_QR
+from desc_utils import Key, KeyOriginInfo
-# PSBT Xpub trust policies
-TRUST_VERIFY = const(0)
-TRUST_OFFER = const(1)
-TRUST_PSBT = const(2)
-
-class MultisigOutOfSpace(RuntimeError):
- pass
-
-def disassemble_multisig_mn(redeem_script):
- # pull out just M and N from script. Simple, faster, no memory.
-
- assert MAX_SIGNERS == 15
- assert redeem_script[-1] == OP_CHECKMULTISIG, 'need CHECKMULTISIG'
-
- M = redeem_script[0] - 80
- N = redeem_script[-2] - 80
-
- return M, N
-
-def disassemble_multisig(redeem_script):
- # Take apart a standard multisig's redeem/witness script, and return M/N and public keys
- # - only for multisig scripts, not general purpose
- # - expect OP_1 (pk1) (pk2) (pk3) OP_3 OP_CHECKMULTISIG for 1 of 3 case
- # - returns M, N, (list of pubkeys)
- # - for very unlikely/impossible asserts, dont document reason; otherwise do.
- from serializations import disassemble
-
- M, N = disassemble_multisig_mn(redeem_script)
- assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range'
- assert len(redeem_script) == 1 + (N * 34) + 1 + 1, 'bad len'
-
- # generator function
- dis = disassemble(redeem_script)
-
- # expect M value first
- ex_M, opcode = next(dis)
- assert ex_M == M and opcode == None, 'bad M'
-
- # need N pubkeys
- pubkeys = []
- for idx in range(N):
- data, opcode = next(dis)
- assert opcode == None and len(data) == 33, 'data'
- assert data[0] == 0x02 or data[0] == 0x03, 'Y val'
- pubkeys.append(data)
-
- assert len(pubkeys) == N
-
- # next is N value
- ex_N, opcode = next(dis)
- assert ex_N == N and opcode == None
-
- # finally, the opcode: CHECKMULTISIG
- data, opcode = next(dis)
- assert opcode == OP_CHECKMULTISIG
-
- # must have reached end of script at this point
- try:
- next(dis)
- raise AssertionError("too long")
- except StopIteration:
- # expected, since we're reading past end
- pass
-
- return M, N, pubkeys
-
-def make_redeem_script(M, nodes, subkey_idx, bip67=True):
- # take a list of BIP-32 nodes, and derive Nth subkey (subkey_idx) and make
- # a standard M-of-N redeem script for that. Applies BIP-67 sorting by default.
- N = len(nodes)
- assert 1 <= M <= N <= MAX_SIGNERS
-
- pubkeys = []
- for n in nodes:
- copy = n.copy()
- copy.derive(subkey_idx, False)
- # 0x21 = 33 = len(pubkey) = OP_PUSHDATA(33)
- pubkeys.append(b'\x21' + copy.pubkey())
- del copy
-
- if bip67:
- pubkeys.sort()
-
- # serialize redeem script
- pubkeys.insert(0, bytes([80 + M]))
- pubkeys.append(bytes([80 + N, OP_CHECKMULTISIG]))
-
- return b''.join(pubkeys)
-
-class MultisigWallet(WalletABC):
- # Capture the info we need to store long-term in order to participate in a
- # multisig wallet as a co-signer.
- # - can be saved to nvram
- # - can be imported from a simple text file
- # - can be displayed to user in a menu (and deleted)
- # - required during signing to verify change outputs
- # - can reconstruct any redeem script from this
- # Challenges:
- # - can be big, taking big % of 4k storage in nvram
- # - complex object, want to have flexibility going forward
- FORMAT_NAMES = [
- (AF_P2SH, 'p2sh'),
- (AF_P2WSH, 'p2wsh'),
- (AF_P2WSH_P2SH, 'p2sh-p2wsh'), # preferred
- (AF_P2WSH_P2SH, 'p2wsh-p2sh'), # obsolete (now an alias)
- ]
-
- # optional: user can short-circuit many checks (system wide, one power-cycle only)
- disable_checks = False
-
- def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC', bip67=True):
- self.storage_idx = -1
-
- self.name = name
- assert len(m_of_n) == 2
- self.M, self.N = m_of_n
- self.chain_type = chain_type or 'BTC'
- assert len(xpubs[0]) == 3
- self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str))
- self.addr_fmt = addr_fmt # address format for wallet
- self.bip67 = bip67
-
- # calc useful cache value: numeric xfp+subpath, with lookup
- self.xfp_paths = {}
- for xfp, deriv, xpub in self.xpubs:
- self.xfp_paths[xfp] = str_to_keypath(xfp, deriv)
-
- assert len(self.xfp_paths) == self.N, 'dup XFP' # not supported
-
- @classmethod
- def render_addr_fmt(cls, addr_fmt):
- for k, v in cls.FORMAT_NAMES:
- if k == addr_fmt:
- return v.upper()
- return '?'
-
- def render_path(self, change_idx, idx):
- # assuming shared derivations for all cosigners. Wrongish.
- derivs, _ = self.get_deriv_paths()
- if len(derivs) > 1:
- deriv = '(various)'
- else:
- deriv = derivs[0]
- return deriv + '/%d/%d' % (change_idx, idx)
-
- @property
- def chain(self):
- return chains.get_chain(self.chain_type)
-
- @classmethod
- def get_trust_policy(cls):
-
- which = settings.get('pms', None)
-
- if which is None:
- which = TRUST_VERIFY if cls.exists() else TRUST_OFFER
-
- return which
-
- def serialize(self):
- # return a JSON-able object
-
- opts = dict()
- if self.addr_fmt != AF_P2SH:
- opts['ft'] = self.addr_fmt
- if self.chain_type != 'BTC':
- opts['ch'] = self.chain_type
-
- # Data compression: most legs will all use same derivation.
- # put a int(0) in place and set option 'pp' to be derivation
- # (used to be common_prefix assumption)
- pp = list(sorted(set(d for _,d,_ in self.xpubs)))
- if len(pp) == 1:
- # generate old-format data, to preserve firmware downgrade path
- xp = [(a, c) for a,deriv,c in self.xpubs]
- opts['pp'] = pp[0]
- else:
- # allow for distinct deriv paths on each leg
- opts['d'] = pp
- xp = [(a, pp.index(deriv),c) for a,deriv,c in self.xpubs]
-
- # make list already, will become one after json ser/deser
- res = [self.name, (self.M, self.N), xp, opts]
- if not self.bip67:
- # wallets that do not follow BIP-67 are backwards incompatible
- res.append(0)
-
- return res
-
- @classmethod
- def deserialize(cls, vals, idx=-1):
- # take json object, make instance.
- bip67 = 1 # default enabled, requires 5-element serialization to disable
- if len(vals) == 5:
- bip67 = vals[-1]
- vals = vals[:-1]
-
- name, m_of_n, xpubs, opts = vals
-
- if len(xpubs[0]) == 2:
- # promote from old format to new: assume common prefix is the derivation
- # for all of them
- # PROBLEM: we don't have enough info if no common prefix can be assumed
- common_prefix = opts.get('pp', None)
- if not common_prefix:
- # TODO: this should raise a warning, not supported anymore
- common_prefix = 'm'
- common_prefix = common_prefix.replace("'", "h")
- xpubs = [(a, common_prefix, b) for a,b in xpubs]
- else:
- # new format decompression
- if 'd' in opts:
- derivs = [p.replace("'", "h") for p in opts.get('d')]
- xpubs = [(a, derivs[b], c) for a,b,c in xpubs]
-
- rv = cls(name, m_of_n, xpubs, addr_fmt=opts.get('ft', AF_P2SH),
- chain_type=opts.get('ch', 'BTC'), bip67=bool(bip67))
- rv.storage_idx = idx
- return rv
-
- @classmethod
- def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmt=None):
- # yield MS wallets we know about, that match at least right M,N if known.
- # - this is only place we should be searching this list, please!!
- lst = settings.get('multisig', [])
-
- for idx, rec in enumerate(lst):
- if idx == not_idx:
- # ignore one by index
- continue
-
- if M or N:
- # peek at M/N
- has_m, has_n = tuple(rec[1])
- if M is not None and has_m != M: continue
- if N is not None and has_n != N: continue
-
- if addr_fmt is not None:
- opts = rec[3]
- af = opts.get('ft', AF_P2SH)
- if af != addr_fmt: continue
-
- yield cls.deserialize(rec, idx)
-
- def get_xfp_paths(self):
- # return list of lists [xfp, *deriv]
- return list(self.xfp_paths.values())
-
- @classmethod
- def find_match(cls, M, N, xfp_paths, addr_fmt=None):
- # Find index of matching wallet
- # - xfp_paths is list of lists: [xfp, *path] like in psbt files
- # - M and N must be known
- # - returns instance, or None if not found
- for rv in cls.iter_wallets(M, N, addr_fmt=addr_fmt):
- if rv.matching_subpaths(xfp_paths):
- return rv
-
- return None
-
- @classmethod
- def find_candidates(cls, xfp_paths, addr_fmt=None, M=None):
- # Return a list of matching wallets for various M values.
- # - xpfs_paths should already be sorted
- # - returns set of matches, of any M value
-
- # we know N, but not M at this point.
- N = len(xfp_paths)
-
- matches = []
- for rv in cls.iter_wallets(M=M, addr_fmt=addr_fmt):
- if rv.matching_subpaths(xfp_paths):
- matches.append(rv)
-
- return matches
-
- def matching_subpaths(self, xfp_paths):
- # Does this wallet use same set of xfp values, and
- # the same prefix path per-each xfp, as indicated
- # xfp_paths (unordered)?
- # - could also check non-prefix part is all non-hardened
- if len(xfp_paths) != len(self.xfp_paths):
- # cannot be the same if len(w0.N) != len(w1.N)
- # maybe check duplicates first?
- return False
- for x in xfp_paths:
- if x[0] not in self.xfp_paths:
- return False
- prefix = self.xfp_paths[x[0]]
-
- if len(x) < len(prefix):
- # PSBT specs a path shorter than wallet's xpub
- #print('path len: %d vs %d' % (len(prefix), len(x)))
- return False
-
- comm = len(prefix)
- if tuple(prefix[:comm]) != tuple(x[:comm]):
- # xfp => maps to wrong path
- #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm))
- return False
-
- return True
-
- def assert_matching(self, M, N, xfp_paths):
- # compare in-memory wallet with details recovered from PSBT
- # - xfp_paths must be sorted already
- assert (self.M, self.N) == (M, N), "M/N mismatch"
- assert len(xfp_paths) == N, "XFP count"
- if self.disable_checks: return
- assert self.matching_subpaths(xfp_paths), "wrong XFP/derivs"
-
- @classmethod
- def quick_check(cls, M, N, xfp_xor):
- # quicker? USB method.
- rv = []
- for ms in cls.iter_wallets(M, N):
- x = 0
- for xfp in ms.xfp_paths.keys():
- x ^= xfp
- if x != xfp_xor: continue
-
- return True
-
- return False
-
- @classmethod
- def get_all(cls):
- # return them all, as a generator
- return cls.iter_wallets()
-
- @classmethod
- def exists(cls):
- # are there any wallets defined?
- return bool(settings.get('multisig', False))
-
- @classmethod
- def get_by_idx(cls, nth):
- # instance from index number (used in menu)
- lst = settings.get('multisig', [])
- try:
- obj = lst[nth]
- except IndexError:
- return None
-
- return cls.deserialize(obj, nth)
-
- def commit(self):
- # data to save
- # - important that this fails immediately when nvram overflows
- obj = self.serialize()
-
- v = settings.get('multisig', [])
- orig = v.copy()
- if not v or self.storage_idx == -1:
- # create
- self.storage_idx = len(v)
- v.append(obj)
- else:
- # update in place
- v[self.storage_idx] = obj
-
- settings.set('multisig', v)
-
- # save now, rather than in background, so we can recover
- # from out-of-space situation
- try:
- settings.save()
- except:
- # back out change; no longer sure of NVRAM state
- try:
- settings.set('multisig', orig)
- settings.save()
- except: pass # give up on recovery
-
- raise MultisigOutOfSpace
-
- def has_similar(self):
- # check if we already have a saved duplicate to this proposed wallet
- # - return (name_change, diff_items, count_similar) where:
- # - name_change is existing wallet that has exact match, different name
- # - diff_items: text list of similarity/differences
- # - count_similar: same N, same xfp+paths
-
- lst = self.get_xfp_paths()
- c = self.find_match(self.M, self.N, lst, addr_fmt=self.addr_fmt)
- if c:
- # All details are same: M/N, paths, addr fmt
- if sorted(self.xpubs) != sorted(c.xpubs):
- # this also applies to non-BIP-67 type multisig wallets
- # multi(2,A,B) is treated as duplicate of multi(2,B,A)
- # consensus-wise they are different script/wallet but CC
- # don't allow to import one if other already imported
- return None, ['xpubs'], 0
- elif self.bip67 != c.bip67:
- # treat same keys inside different desc multi/sortedmulti as duplicates
- # sortedmulti(2,A,B) is considered same as multi(2,A,B) or multi(2,B,A)
- # do not allow to import multi if sortedmulti with the same set of keys
- # already imported and vice-versa
- return None, ["BIP-67 clash"], 1
- elif self.name == c.name:
- return None, [], 1
- else:
- return c, ['name'], 0
-
- similar = MultisigWallet.find_candidates(lst)
- if not similar:
- # no matches, good.
- return None, [], 0
-
- # See if the xpubs are changing, which is risky... other differences like
- # name are okay.
- diffs = set()
- for c in similar:
- if c.M != self.M:
- diffs.add('M differs')
- if c.addr_fmt != self.addr_fmt:
- diffs.add('address type')
- if c.name != self.name:
- diffs.add('name')
- if c.xpubs != self.xpubs:
- diffs.add('xpubs')
-
- return None, diffs, len(similar)
-
- def delete(self):
- # remove saved entry
- # - important: not expecting more than one instance of this class in memory
- assert self.storage_idx >= 0
-
- # safety check
- for existing in self.iter_wallets(M=self.M, N=self.N, addr_fmt=self.addr_fmt):
- if existing.storage_idx != self.storage_idx: continue
- break
- else:
- raise IndexError # consistency bug
-
- lst = settings.get('multisig', [])
- del lst[self.storage_idx]
- if lst:
- settings.set('multisig', lst)
- else:
- settings.remove_key('multisig')
- settings.save()
-
- self.storage_idx = -1
-
- def xpubs_with_xfp(self, xfp):
- # return set of indexes of xpubs with indicated xfp
- return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs)
- if wxfp == xfp)
-
- def yield_addresses(self, start_idx, count, change_idx=0):
- # Assuming a suffix of /0/0 on the defined prefix's, yield
- # possible deposit addresses for this wallet.
- ch = self.chain
-
- assert self.addr_fmt, 'no addr fmt known'
-
- # setup
- nodes = []
- paths = []
- for xfp, deriv, xpub in self.xpubs:
- # load bip32 node for each cosigner
- node = ch.deserialize_node(xpub, AF_P2SH)
- node.derive(change_idx, False)
- # indicate path used (for UX)
- path = "[%s/%s/%d/{idx}]" % (xfp2str(xfp), deriv[2:], change_idx)
- nodes.append(node)
- paths.append(path)
-
- idx = start_idx
- while count:
- if idx > MAX_BIP32_IDX:
- break
- # make the redeem script, convert into address
- script = make_redeem_script(self.M, nodes, idx, self.bip67)
- addr = ch.p2sh_address(self.addr_fmt, script)
-
- yield idx, addr, [p.format(idx=idx) for p in paths], script
-
- idx += 1
- count -= 1
-
- def validate_script(self, redeem_script, subpaths=None, xfp_paths=None):
- # Check we can generate all pubkeys in the redeem script, raise on errors.
- # - working from pubkeys in the script, because duplicate XFP can happen
- # - if disable_checks is set better to handle in caller, but we're also neutered
- #
- # redeem_script: what we expect and we were given
- # subpaths: pubkey => (xfp, *path)
- # xfp_paths: (xfp, *path) in same order as pubkeys in redeem script
-
- subpath_help = []
- used = set()
- ch = self.chain
-
- M, N, pubkeys = disassemble_multisig(redeem_script)
- assert M==self.M and N == self.N, 'wrong M/N in script'
-
- if self.disable_checks: return ['UNVERIFIED']
-
- for pk_order, pubkey in enumerate(pubkeys):
- check_these = []
-
- # TODO: this could be simpler now that XFP is unique per co-signer
- if subpaths:
- # in PSBT, we are given a map from pubkey to xfp/path, use it
- # while remembering it's potentially one-2-many
- assert pubkey in subpaths, "unexpected pubkey"
- xfp, *path = subpaths[pubkey]
-
- for xp_idx, (wxfp, _, xpub) in enumerate(self.xpubs):
- if wxfp != xfp: continue
- if xp_idx in used: continue # only allow once
- check_these.append((xp_idx, path))
- else:
- # Without PSBT, USB caller must provide xfp+path
- # in same order as they occur inside redeem script.
- # Working solely from the redeem script's pubkeys, we
- # wouldn't know which xpub to use, nor correct path for it.
- xfp, *path = xfp_paths[pk_order]
-
- for xp_idx in self.xpubs_with_xfp(xfp):
- if xp_idx in used: continue # only allow once
- check_these.append((xp_idx, path))
-
- here = None
- too_shallow = False
- for xp_idx, path in check_these:
- if not self.bip67:
- assert xp_idx == pk_order, "script key order"
-
- # matched fingerprint, try to make pubkey that needs to match
- xpub = self.xpubs[xp_idx][-1]
-
- node = ch.deserialize_node(xpub, AF_P2SH); assert node
- dp = node.depth()
-
- #print("%s => deriv=%s dp=%d len(path)=%d path=%s" %
- # (xfp2str(xfp), self.xpubs[xp_idx][1], dp, len(path), path))
-
- if not (0 <= dp <= len(path)):
- # obscure case: xpub isn't deep enough to represent
- # indicated path... not wrong really.
- too_shallow = True
- continue
-
- for sp in path[dp:]:
- assert not (sp & 0x80000000), 'hard deriv'
- node.derive(sp, False) # works in-place
-
- found_pk = node.pubkey()
-
- # Document path(s) used. Not sure this is useful info to user tho.
- # - Do not show what we can't verify: we don't really know the hardeneded
- # part of the path from fingerprint to here.
- here = '[%s]' % xfp2str(xfp)
- if dp != len(path):
- here = here[:-1] + ('/_'*dp) + keypath_to_str(path[dp:], '/', 0) + "]"
-
- if found_pk != pubkey:
- # Not a match but not an error by itself, since might be
- # another dup xfp to look at still.
-
- #print('pk mismatch: %s => %s != %s' % (
- # here, b2a_hex(found_pk), b2a_hex(pubkey)))
- continue
-
- subpath_help.append(here)
-
- used.add(xp_idx)
- break
- else:
- msg = 'pk#%d wrong' % (pk_order+1)
- if not check_these:
- msg += ', unknown XFP'
- elif here:
- msg += ', tried: ' + here
- if too_shallow:
- msg += ', too shallow'
- raise AssertionError(msg)
-
- if self.bip67 and pk_order:
- # verify sorted order
- assert bytes(pubkey) > bytes(pubkeys[pk_order-1]), 'BIP-67 violation'
-
- assert len(used) == self.N, 'not all keys used: %d of %d' % (len(used), self.N)
-
- return subpath_help
-
- @classmethod
- def from_simple_text(cls, lines):
- # standard multisig file format - more than one line
- has_mine = 0
- M, N = -1, -1
- deriv = None
- name = None
- xpubs = []
- addr_fmt = AF_P2SH
- my_xfp = settings.get('xfp')
- for ln in lines:
- # remove comments
- comm = ln.find('#')
- if comm == 0:
- continue
- if comm != -1:
- if not ln[comm + 1:comm + 2].isdigit():
- ln = ln[0:comm]
-
- ln = ln.strip()
-
- if ':' not in ln:
- if 'pub' in ln:
- # pointless optimization: allow bare xpub if we can calc xfp
- label = '00000000'
- value = ln
- else:
- # complain?
- # if ln: print("no colon: " + ln)
- continue
- else:
- label, value = ln.split(':', 1)
- label = label.lower()
-
- value = value.strip()
-
- if label == 'name':
- name = value
- elif label == 'policy':
- try:
- # accepts: 2 of 3 2/3 2,3 2 3 etc
- mat = ure.search(r'(\d+)\D*(\d+)', value)
- assert mat
- M = int(mat.group(1))
- N = int(mat.group(2))
- assert 1 <= M <= N <= MAX_SIGNERS
- except:
- raise AssertionError('bad policy line')
-
- elif label == 'derivation':
- # reveal the path derivation for following key(s)
- try:
- assert value, 'blank'
- deriv = cleanup_deriv_path(value)
- except BaseException as exc:
- raise AssertionError('bad derivation line: ' + str(exc))
-
- elif label == 'format':
- # pick segwit vs. classic vs. wrapped version
- value = value.lower()
- for fmt_code, fmt_label in cls.FORMAT_NAMES:
- if value == fmt_label:
- addr_fmt = fmt_code
- break
- else:
- raise AssertionError('bad format line')
- elif len(label) == 8:
- try:
- xfp = str2xfp(label)
- except:
- # complain?
- # print("Bad xfp: " + ln)
- continue
-
- # deserialize, update list and lots of checks
- is_mine = cls.check_xpub(xfp, value, deriv, chains.current_chain().ctype, my_xfp, xpubs)
- if is_mine:
- has_mine += 1
-
- return name, addr_fmt, xpubs, has_mine, M, N
-
- @classmethod
- def from_descriptor(cls, descriptor: str):
- # excpect descriptor here if only one line, normal multisig file requires more lines
- has_mine = 0
- my_xfp = settings.get('xfp')
- xpubs = []
-
- desc = MultisigDescriptor.parse(descriptor)
- for xfp, deriv, xpub in desc.keys:
- deriv = cleanup_deriv_path(deriv)
- is_mine = cls.check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, my_xfp, xpubs)
- if is_mine:
- has_mine += 1
- return None, desc.addr_fmt, xpubs, has_mine, desc.M, desc.N, desc.is_sorted
-
- def to_descriptor(self):
- return MultisigDescriptor(
- M=self.M, N=self.N,
- keys=self.xpubs,
- addr_fmt=self.addr_fmt,
- is_sorted=self.bip67,
- )
-
- @classmethod
- def from_file(cls, config, name=None):
- # Given a simple text file, parse contents and create instance (unsaved).
- # format is: label: value
- # where label is:
- # name: nameforwallet
- # policy: M of N
- # format: p2sh (+etc)
- # derivation: m/45h/0 (common prefix)
- # (8digithex): xpub of cosigner
- #
- # Descriptor support
- # * text file containing multisig descriptor
- #
- # quick checks:
- # - name: 1-20 ascii chars
- # - M of N line (assume N of N if not spec'd)
- # - xpub: any bip32 serialization we understand, but be consistent
- #
- expect_chain = chains.current_chain().ctype
- if MultisigDescriptor.is_descriptor(config):
- _, addr_fmt, xpubs, has_mine, M, N, bip67 = cls.from_descriptor(config)
- if not bip67 and not settings.get("unsort_ms", 0):
- # BIP-67 disabled, but unsort_ms not allowed - raise
- raise AssertionError('Unsorted multisig "multi(...)" not allowed')
- else:
- # oldschool
- bip67 = True
- lines = [line for line in config.split('\n') if line] # remove empty lines
- parsed_name, addr_fmt, xpubs, has_mine, M, N = cls.from_simple_text(lines)
- if parsed_name:
- # if name provided in file, use that instead of name inferred from filename
- name = parsed_name
-
- assert len(xpubs), 'need xpubs'
-
- if M == N == -1:
- # default policy: all keys
- N = M = len(xpubs)
-
- if not name:
- # provide a default name
- name = '%d-of-%d' % (M, N)
-
- try:
- name = to_ascii_printable(name)
- assert 1 <= len(name) <= 20
- except:
- raise AssertionError('name must be ascii, 1..20 long')
-
- assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range'
- assert N == len(xpubs), 'wrong # of xpubs, expect %d' % N
- assert addr_fmt & AFC_SCRIPT, 'script style addr fmt'
-
- # check we're included... do not insert ourselves, even tho we
- # have enough info, simply because other signers need to know my xpubkey anyway
- assert has_mine != 0, 'my key not included'
- assert has_mine == 1, 'my key included more than once'
-
- # done. have all the parts
- return cls(name, (M, N), xpubs, addr_fmt=addr_fmt,
- chain_type=expect_chain, bip67=bip67)
-
- @classmethod
- def check_xpub(cls, xfp, xpub, deriv, expect_chain, my_xfp, xpubs):
- # Shared code: consider an xpub for inclusion into a wallet, if ok, append
- # to list: xpubs with a tuple: (xfp, deriv, xpub)
- # return T if it's our own key
- # - deriv can be None, and in very limited cases can recover derivation path
- # - could enforce all same depth, and/or all depth >= 1, but
- # seems like more restrictive than needed, so "m" is allowed
-
- try:
- # Note: addr fmt detected here via SLIP-132 isn't useful
- node, chain, _ = parse_extended_key(xpub)
- except:
- raise AssertionError('unable to parse xpub')
-
- try:
- assert node.privkey() == None # 'no privkeys plz'
- except ValueError:
- pass
-
- if expect_chain == "XRT":
- # HACK but there is no difference extended_keys - just bech32 hrp
- assert chain.ctype == "XTN"
- else:
- assert chain.ctype == expect_chain, 'wrong chain'
-
- depth = node.depth()
-
- if depth == 1:
- if not xfp:
- # allow a shortcut: zero/omit xfp => use observed parent value
- xfp = swab32(node.parent_fp())
- else:
- # generally cannot check fingerprint values, but if we can, do so.
- if not cls.disable_checks:
- assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong'
-
- assert xfp, 'need fingerprint' # happens if bare xpub given
-
- # In most cases, we cannot verify the derivation path because it's hardened
- # and we know none of the private keys involved.
- if depth == 1:
- # but derivation is implied at depth==1
- kn, is_hard = node.child_number()
- if is_hard: kn |= 0x80000000
- guess = keypath_to_str([kn], skip=0)
-
- if deriv:
- if not cls.disable_checks:
- assert guess == deriv, '%s != %s' % (guess, deriv)
- else:
- deriv = guess # reachable? doubt it
-
- assert deriv, 'empty deriv' # or force to be 'm'?
- assert deriv[0] == 'm'
-
- # path length of derivation given needs to match xpub's depth
- if not cls.disable_checks:
- p_len = deriv.count('/')
- assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % (
- p_len, depth, xfp2str(xfp))
-
- if xfp == my_xfp:
- # its 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:
- chk_node = sv.derive_path(deriv)
- assert node.pubkey() == chk_node.pubkey(), \
- "[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:])
-
- # serialize xpub w/ BIP-32 standard now.
- # - this has effect of stripping SLIP-132 confusion away
- xpubs.append((xfp, deriv, chain.serialize_public(node, AF_P2SH)))
-
- return (xfp == my_xfp)
-
- def make_fname(self, prefix, suffix='txt'):
- rv = '%s-%s.%s' % (prefix, self.name, suffix)
- return rv.replace(' ', '_')
-
- async def export_electrum(self):
- # Generate and save an Electrum JSON file.
- from export import make_json_wallet
-
- def doit():
- rv = dict(seed_version=17, use_encryption=False,
- wallet_type='%dof%d' % (self.M, self.N))
-
- ch = self.chain
-
- # the important stuff.
- for idx, (xfp, deriv, xpub) in enumerate(self.xpubs):
-
- node = None
- if self.addr_fmt != AF_P2SH:
- # CHALLENGE: we must do slip-132 format [yz]pubs here when not p2sh mode.
- node = ch.deserialize_node(xpub, AF_P2SH); assert node
- xp = ch.serialize_public(node, self.addr_fmt)
- else:
- xp = xpub
-
- rv['x%d/' % (idx+1)] = dict(
- hw_type='coldcard', type='hardware',
- ckcc_xfp=xfp,
- label='Coldcard %s' % xfp2str(xfp),
- derivation=deriv, xpub=xp)
-
- # sign export with first p2pkh key
- return ujson.dumps(rv), False, False
-
- await make_json_wallet('Electrum multisig wallet', doit,
- fname_pattern=self.make_fname('el', 'json'))
-
- async def export_wallet_file(self, mode="exported from", extra_msg=None, descriptor=False,
- core=False, desc_pretty=True):
- # create a text file with the details; ready for import to next Coldcard
- from glob import NFC, dis
-
- my_xfp = xfp2str(settings.get('xfp'))
- if core:
- name = "Bitcoin Core"
- fname_pattern = self.make_fname('bitcoin-core')
- elif descriptor:
- name = "Descriptor"
- fname_pattern = self.make_fname('desc')
- else:
- name = "Coldcard"
- fname_pattern = self.make_fname('export')
-
- hdr = '%s %s' % (mode, my_xfp)
- label = "%s multisig setup" % name
-
- choice = await import_export_prompt("%s file" % label, is_import=False,
- no_qr=not version.has_qwerty)
- if choice == KEY_CANCEL:
- return
-
- dis.fullscreen("Wait...")
- if choice in (KEY_NFC, KEY_QR):
- with uio.StringIO() as fp:
- self.render_export(fp, hdr_comment=hdr, descriptor=descriptor,
- core=core, desc_pretty=desc_pretty)
- if choice == KEY_NFC:
- await NFC.share_text(fp.getvalue())
- else:
- try:
- await show_qr_code(fp.getvalue())
- except (ValueError, RuntimeError):
- if version.has_qwerty:
- # do BBQr on Q
- from ux_q1 import show_bbqr_codes
- await show_bbqr_codes('U', fp.getvalue(), label)
- return
-
- try:
- with CardSlot(**choice) as card:
- fname, nice = card.pick_filename(fname_pattern)
-
- # do actual write
- with open(fname, 'w+') as fp:
- self.render_export(fp, hdr_comment=hdr, descriptor=descriptor,
- core=core, desc_pretty=desc_pretty)
- # fp.seek(0)
- # contents = fp.read()
- # TODO re-enable once we know how to proceed with regards to with which key to sign
- # from auth import write_sig_file
- # h = ngu.hash.sha256s(contents.encode())
- # sig_nice = write_sig_file([(h, fname)])
-
- msg = '%s file written:\n\n%s' % (label, nice)
- # msg += '\n\nColdcard multisig signature file written:\n\n%s' % sig_nice
- if extra_msg:
- msg += extra_msg
-
- await ux_show_story(msg)
-
- except CardMissingError:
- await needs_microsd()
- return
- except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
- return
-
- def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True):
- if descriptor:
- # serialize descriptor
- desc_obj = self.to_descriptor()
- if core:
- core_obj = desc_obj.bitcoin_core_serialize()
- core_str = ujson.dumps(core_obj)
- print("importdescriptors '%s'\n" % core_str, file=fp)
- else:
- if desc_pretty:
- desc = desc_obj.pretty_serialize()
- else:
- desc = desc_obj.serialize()
- print("%s\n" % desc, file=fp)
- else:
- if hdr_comment:
- print("# Coldcard Multisig setup file (%s)\n#" % hdr_comment, file=fp)
-
- print("Name: %s\nPolicy: %d of %d" % (self.name, self.M, self.N), file=fp)
-
- if self.addr_fmt != AF_P2SH:
- print("Format: " + self.render_addr_fmt(self.addr_fmt), file=fp)
-
- last_deriv = None
- for xfp, deriv, val in self.xpubs:
- if last_deriv != deriv:
- print("\nDerivation: %s\n" % deriv, file=fp)
- last_deriv = deriv
-
- print('%s: %s' % (xfp2str(xfp), val), file=fp)
-
- @classmethod
- def guess_addr_fmt(cls, npath):
- # Assuming the bips are being respected, what address format will be used,
- # based on indicated numeric subkey path observed.
- # - return None if unsure, no errors
- #
- #( "m/45h", 'p2sh', AF_P2SH),
- #( "m/48h/{coin}h/0h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH),
- #( "m/48h/{coin}h/0h/2h", 'p2wsh', AF_P2WSH)
-
- top = npath[0] & 0x7fffffff
- if top == npath[0]:
- # non-hardened top? rare/bad
- return
-
- if top == 45:
- return AF_P2SH
-
- if top == 48:
- if len(npath) < 4: return
-
- last = npath[3] & 0x7fffffff
- if last == 1:
- return AF_P2WSH_P2SH
- if last == 2:
- return AF_P2WSH
-
- @classmethod
- def import_from_psbt(cls, M, N, xpubs_list):
- # given the raw data from PSBT global header, offer the user
- # the details, and/or bypass that all and just trust the data.
- # - xpubs_list is a list of (xfp+path, binary BIP-32 xpub)
- # - already know not in our records.
- trust_mode = cls.get_trust_policy()
-
- if trust_mode == TRUST_VERIFY:
- # already checked for existing import and wasn't found, so fail
- raise FatalPSBTIssue("XPUBs in PSBT do not match any existing wallet")
-
- # build up an in-memory version of the wallet.
- # - capture address format based on path used for my leg (if standards compliant)
-
- assert N == len(xpubs_list)
- assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range'
- my_xfp = settings.get('xfp')
-
- expect_chain = chains.current_chain().ctype
- xpubs = []
- has_mine = 0
-
- for k, v in xpubs_list:
- xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0)
- xpub = ngu.codecs.b58_encode(v)
- is_mine = cls.check_xpub(xfp, xpub, keypath_to_str(path, skip=0),
- expect_chain, my_xfp, xpubs)
- if is_mine:
- has_mine += 1
- addr_fmt = cls.guess_addr_fmt(path)
-
- assert has_mine == 1 # 'my key not included'
-
- name = 'PSBT-%d-of-%d' % (M, N)
- # this will always create sortedmulti multisig (BIP-67)
- # because BIP-174 came years after wide spread acceptance of BIP-67 policy
- ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH)
-
- # may just keep in-memory version, no approval required, if we are
- # trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet
- return ms, (trust_mode != TRUST_PSBT)
-
- def validate_psbt_xpubs(self, xpubs_list):
- # The xpubs provided in PSBT must be exactly right, compared to our record.
- # But we're going to use our own values from setup time anyway.
- # Check:
- # - chain codes match what we have stored already
- # - pubkey vs. path will be checked later
- # - xfp+path already checked when selecting this wallet
- # - some cases we cannot check, so count those for a warning
- # Any issue here is a fraud attempt in some way, not innocent.
- # But it would not have tricked us and so the attack targets some other signer.
- assert len(xpubs_list) == self.N
-
- for k, v in xpubs_list:
- xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0)
- xpub = ngu.codecs.b58_encode(v)
-
- # cleanup and normalize xpub
- tmp = []
- self.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), self.chain_type, 0, tmp)
- (_, deriv, xpub_reserialized) = tmp[0]
- assert deriv # because given as arg
-
- if self.disable_checks:
- # allow wrong derivation paths in PSBT; but also allows usage when
- # old pre-3.2.1 MS wallet lacks derivation details for all legs
- continue
-
- # find in our records.
- for (x_xfp, x_deriv, x_xpub) in self.xpubs:
- if x_xfp != xfp: continue
- # found matching XFP
- assert deriv == x_deriv
-
- assert xpub_reserialized == x_xpub, 'xpub wrong (xfp=%s)' % xfp2str(xfp)
- break
- else:
- assert False # not reachable, since we picked wallet based on xfps
-
- def get_deriv_paths(self):
- # List of unique derivation paths being used. Often length one.
- # - also a rendered single-value summary
- derivs = sorted(set(d for _,d,_ in self.xpubs))
-
- if len(derivs) == 1:
- dsum = derivs[0]
- else:
- dsum = 'Varies (%d)' % len(derivs)
-
- return derivs, dsum
-
- async def confirm_import(self):
- # prompt them about a new wallet, let them see details and then commit change.
- M, N = self.M, self.N
-
- if M == N == 1:
- exp = 'The one signer must approve spends.'
- elif M == N:
- exp = 'All %d co-signers must approve spends.' % N
- elif M == 1:
- exp = 'Any signature from %d co-signers will approve spends.' % N
- else:
- exp = '{M} signatures, from {N} possible co-signers, will be required to approve spends.'.format(M=M, N=N)
-
- # Look for duplicate stuff
- name_change, diff_items, num_dups = self.has_similar()
-
- is_dup = False
- if name_change:
- story = 'Update NAME only of existing multisig wallet?'
- elif num_dups and isinstance(diff_items, list):
- # failures only
- story = "Duplicate wallet."
- if diff_items:
- story += diff_items[0]
- else:
- story += ' All details are the same as existing!'
- is_dup = True
- elif diff_items:
- # Concern here is overwrite when similar, but we don't overwrite anymore, so
- # more of a warning about funny business.
- story = '''\
-WARNING: This new wallet is similar to an existing wallet, but will NOT replace it. Consider deleting previous wallet first. Differences: \
-''' + ', '.join(diff_items)
- else:
- story = 'Create new multisig wallet?'
-
- derivs, dsum = self.get_deriv_paths()
-
- if not self.bip67 and not is_dup:
- # do not need to warn if duplicate, won;t be allowed to import anyways
- story += "\nWARNING: BIP-67 disabled! Unsorted multisig - order of keys in descriptor/backup is crucial"
-
- story += '''\n
-Wallet Name:
- {name}
-
-Policy: {M} of {N}
-
-{exp}
-
-Addresses:
- {at}
-
-Derivation:
- {dsum}
-
-Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=exp, dsum=dsum,
- at=self.render_addr_fmt(self.addr_fmt))
- if not is_dup:
- story += ('%s to approve, %s to cancel.' % (OK, X))
- else:
- story += '%s to cancel' % X
-
- ux_clear_keys(True)
- while 1:
- ch = await ux_show_story(story, escape='1')
-
- if ch == '1':
- await self.show_detail(verbose=False)
- continue
-
- if ch == 'y' and not is_dup:
- # save to nvram, may raise MultisigOutOfSpace
- if name_change:
- name_change.delete()
-
- assert self.storage_idx == -1
- self.commit()
- await ux_dramatic_pause("Saved.", 2)
- break
-
- return ch
-
- async def show_detail(self, verbose=True):
- # Show the xpubs; might be 2k or more rendered.
- msg = uio.StringIO()
-
- if verbose:
- if not self.bip67:
- msg.write("WARNING: BIP-67 disabled! Unsorted multisig - order of keys in descriptor/backup is crucial.\n\n")
-
- vmsg = ('Policy: {M} of {N}\n'
- 'Blockchain: {ctype}\n'
- 'Addresses: {at}\n\n')
- vmsg = vmsg.format(M=self.M, N=self.N, ctype=self.chain_type,
- at=self.render_addr_fmt(self.addr_fmt))
- msg.write(vmsg)
-
- # order of keys in self.xpubs is same as order of keys in CC import format or descriptor
- for idx, (xfp, deriv, xpub) in enumerate(self.xpubs):
- if idx:
- msg.write('\n---===---\n\n')
-
- msg.write('%s:\n %s\n\n%s\n' % (xfp2str(xfp), deriv, xpub))
-
- if self.addr_fmt != AF_P2SH:
- # SLIP-132 format [yz]pubs here when not p2sh mode.
- # - has same info as proper bitcoin serialization, but looks much different
- node = self.chain.deserialize_node(xpub, AF_P2SH)
- xp = self.chain.serialize_public(node, self.addr_fmt)
-
- msg.write('\nSLIP-132 equiv:\n%s\n' % xp)
-
- return await ux_show_story(msg, title=self.name)
-
-async def no_ms_yet(*a):
- # action for 'no wallets yet' menu item
- await ux_show_story("You don't have any multisig wallets yet.")
-
-def disable_checks_chooser():
- ch = ['Normal', 'Skip Checks']
-
- def xset(idx, text):
- MultisigWallet.disable_checks = bool(idx)
-
- 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('''\
-With many different wallet vendors and implementors involved, it can \
-be hard to create a PSBT consistent with the many keys involved. \
-With this setting, you can \
-disable the more stringent verification checks your Coldcard normally provides.
-
-USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \
-not be accepted by network.
-
-This settings lasts only until power down.
-
-Press (4) to confirm entering this DANGEROUS mode.
-''', escape='4')
-
- if ch != '4': return
-
- start_chooser(disable_checks_chooser)
-
-
-def psbt_xpubs_policy_chooser():
- # Chooser for trust policy
- ch = ['Verify Only', 'Offer Import', 'Trust PSBT']
-
- def xset(idx, text):
- settings.set('pms', idx)
-
- return MultisigWallet.get_trust_policy(), ch, xset
-
-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 \
-with the co-signer public keys (XPUB) that may \
-be provided inside a PSBT file. Three choices:
-
-- Verify Only. Do not import the xpubs found, but do \
-verify the correct wallet already exists on the Coldcard.
-
-- Offer Import. If it's a new multisig wallet, offer to import \
-the details and store them as a new wallet in the Coldcard.
-
-- Trust PSBT. Use the wallet data in the PSBT as a temporary,
-multisig wallet, and do not import it. This permits some \
-deniability and additional privacy.
-
-When the XPUB data is not provided in the PSBT, regardless of the above, \
-we require the appropriate multisig wallet to already exist \
-on the Coldcard. Default is to 'Offer' unless a multisig wallet already \
-exists, otherwise 'Verify'.''')
-
- if ch == 'x': return
- start_chooser(psbt_xpubs_policy_chooser)
-
-def unsorted_ms_chooser():
- ch = ['Do Not Allow', 'Allow']
-
- def xset(idx, text):
- settings.set('unsort_ms', idx)
- from actions import goto_top_menu
- goto_top_menu()
-
- return settings.get('unsort_ms', 0), ch, 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.'
- ' 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')
-
- if ch != '4': return
-
- else:
- # unsort_ms enabled - assume he is going to disable
- # check any multi(...) imported
- ms = settings.get("multisig", [])
- multi_names = [m[0] for m in ms if len(m) == 5]
- if multi_names:
- # do not allow to disable if any multi(...) imported
- # list by name what needs to be removed
- await ux_show_story(
- "Remove already saved multi(...) wallets first.\n\n%s"
- % multi_names
- )
- return
-
- start_chooser(unsorted_ms_chooser)
-
-class MultisigMenu(MenuSystem):
-
- @classmethod
- def construct(cls):
- # Dynamic menu with user-defined names of wallets shown
-
- if not MultisigWallet.exists():
- rv = [MenuItem('(none setup yet)', f=no_ms_yet)]
- else:
- rv = []
- for ms in MultisigWallet.get_all():
- rv.append(MenuItem('%d/%d: %s' % (ms.M, ms.N, ms.name),
- menu=make_ms_wallet_menu, arg=ms.storage_idx))
- from glob import NFC
- rv.append(MenuItem('Import from File', f=import_multisig))
- rv.append(MenuItem('Import from QR', f=import_multisig_qr,
- predicate=version.has_qwerty, shortcut=KEY_QR))
- rv.append(MenuItem('Import via NFC', f=import_multisig_nfc,
- predicate=bool(NFC), shortcut=KEY_NFC))
- rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs))
- 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))
-
- return rv
-
- def update_contents(self):
- # Reconstruct the list of wallets on this dynamic menu, because
- # we added or changed them and are showing that same menu again.
- tmp = self.construct()
- self.replace_items(tmp)
-
-
-async def make_multisig_menu(*a):
- # list of all multisig wallets, and high-level settings/actions
- from pincodes import pa
-
- if not pa.has_secrets():
- await ux_show_story("You must have wallet seed before creating multisig wallets.")
- return
-
- rv = MultisigMenu.construct()
- return MultisigMenu(rv)
-
-async def make_ms_wallet_menu(menu, label, item):
- # details, actions on single multisig wallet
- ms = MultisigWallet.get_by_idx(item.arg)
- if not ms: return
-
- rv = [
- MenuItem('"%s"' % ms.name, f=ms_wallet_detail, arg=ms),
- MenuItem('View Details', f=ms_wallet_detail, arg=ms),
- MenuItem('Delete', f=ms_wallet_delete, arg=ms),
- ]
- if ms.bip67:
- rv += [
- MenuItem('Coldcard Export', f=ms_wallet_ckcc_export, arg=(ms, {})),
- MenuItem('Electrum Wallet', f=ms_wallet_electrum_export, arg=ms),
- ]
- # only way to export non-BIP-67 ms wallet is descriptors (+core export)
- rv.append(MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms))
- return rv
-
-async def make_ms_wallet_descriptor_menu(menu, label, item):
- # descriptor menu
- ms = item.arg
- if not ms:
- return
-
- rv = [
- MenuItem('View Descriptor', f=ms_wallet_show_descriptor, arg=ms),
- MenuItem('Export', f=ms_wallet_ckcc_export,
- arg=(ms, {"descriptor": True, "desc_pretty": False})),
- MenuItem('Bitcoin Core', f=ms_wallet_ckcc_export,
- arg=(ms, {"descriptor": True, "core": True})),
- ]
- return rv
-
-async def ms_wallet_delete(menu, label, item):
- ms = item.arg
-
- # delete
- if not await ux_confirm("Delete this multisig wallet (%s)?\n\nFunds may be impacted."
- % ms.name):
- await ux_dramatic_pause('Aborted.', 3)
- return
-
- ms.delete()
- await ux_dramatic_pause('Deleted.', 3)
-
- # update/hide from menu
- #menu.update_contents()
-
- from ux import the_ux
- # pop stack
- the_ux.pop()
-
- m = the_ux.top_of_stack()
- m.update_contents()
-
-async def ms_wallet_ckcc_export(menu, label, item):
- # create a text file with the details; ready for import to next Coldcard
- ms = item.arg[0]
- kwargs = item.arg[1]
- await ms.export_wallet_file(**kwargs)
-
-async def ms_wallet_show_descriptor(menu, label, item):
- from glob import dis
- dis.fullscreen("Wait...")
- ms = item.arg
- desc = ms.to_descriptor()
- desc_str = desc.serialize()
- ch = await ux_show_story("Press (1) to export in pretty human readable format.\n\n" + desc_str, escape="1")
- if ch == "1":
- await ms.export_wallet_file(descriptor=True, desc_pretty=True)
-
-async def ms_wallet_electrum_export(menu, label, item):
- # create a JSON file that Electrum can use. Challenges:
- # - file contains derivation paths for each co-signer to use
- # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like:
- # m/48h/1h/0h/2h
- # - above is now called BIP-48
- # - other signers might not be coldcards (we don't know)
- # solution:
- # - when building air-gap, pick address type at that point, and matching path to suit
- # - could check path prefix and addr_fmt make sense together, but meh.
- ms = item.arg
- from actions import electrum_export_story
-
- derivs, dsum = ms.get_deriv_paths()
-
- msg = 'The new wallet will have derivation path:\n %s\n and use %s addresses.\n' % (
- dsum, MultisigWallet.render_addr_fmt(ms.addr_fmt) )
-
- if await ux_show_story(electrum_export_story(msg)) != 'y':
- return
-
- await ms.export_electrum()
-
-
-async def ms_wallet_detail(menu, label, item):
- # show details of single multisig wallet
- from glob import dis
- ms = item.arg
- dis.fullscreen("Wait...")
- return await ms.show_detail()
-
-
-async def export_multisig_xpubs(*a):
- # WAS: Create a single text file with lots of docs, and all possible useful xpub values.
- # THEN: Just create the one-liner xpub export value they need/want to support BIP-45
- # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path
- #
- # Consumer for this file is supposed to be ourselves, when we build on-device multisig.
- # - however some 3rd parties are making use of it as well.
- #
- from glob import NFC, dis
- from ux import import_export_prompt
-
- xfp = xfp2str(settings.get('xfp', 0))
- chain = chains.current_chain()
-
- fname_pattern = 'ccxp-%s.json' % xfp
- label = "Multisig XPUB"
-
- msg = '''\
-This feature creates a small file containing \
-the extended public keys (XPUB) you would need to join \
-a multisig wallet.
-
-Public keys for BIP-48 conformant paths are used:
-
-P2SH-P2WSH:
- m/48h/{coin}h/{{acct}}h/1h
-P2WSH:
- m/48h/{coin}h/{{acct}}h/2h
-
-{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X)
-
- ch = await ux_show_story(msg)
- if ch != "y":
- return
-
- acct_num = await ux_enter_bip32_index('Account Number:') or 0
-
- choice = await import_export_prompt("%s file" % label, is_import=False,
- no_qr=not version.has_qwerty)
-
- if choice == KEY_CANCEL:
- return
-
- dis.fullscreen('Generating...')
-
- todo = [
- ( "m/45h", 'p2sh', AF_P2SH), # iff acct_num == 0
- ( "m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH ),
- ( "m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH ),
- ]
-
- def render(fp):
- fp.write('{\n')
- with stash.SensitiveValues() as sv:
- for deriv, name, fmt in todo:
- if fmt == AF_P2SH and acct_num:
- continue
- dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num)
- node = sv.derive_path(dd)
- xp = chain.serialize_public(node, fmt)
- fp.write(' "%s_deriv": "%s",\n' % (name, dd))
- fp.write(' "%s": "%s",\n' % (name, xp))
- xpub = chain.serialize_public(node)
- descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt)
- if descriptor_template is None:
- continue
- fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template))
-
- fp.write(' "account": "%d",\n' % acct_num)
- fp.write(' "xfp": "%s"\n}\n' % xfp)
-
- if choice in (KEY_NFC, KEY_QR):
- with uio.StringIO() as fp:
- render(fp)
- if choice == KEY_NFC:
- await NFC.share_json(fp.getvalue())
- elif version.has_qwerty:
- from ux_q1 import show_bbqr_codes
- await show_bbqr_codes('J', fp.getvalue(), label)
- return
-
- try:
- with CardSlot(**choice) as card:
- fname, nice = card.pick_filename(fname_pattern)
- # do actual write: manual JSON here so more human-readable.
- with open(fname, 'w+') as fp:
- render(fp)
- # fp.seek(0)
- # contents = fp.read()
- # TODO re-enable once we know how to proceed with regards to with which key to sign
- # from auth import write_sig_file
- # h = ngu.hash.sha256s(contents.encode())
- # sig_nice = write_sig_file([(h, fname)])
-
- except CardMissingError:
- await needs_microsd()
- return
- except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
- return
-
- msg = '%s file written:\n\n%s' % (label, nice)
- # msg += '\n\nMultisig XPUB signature file written:\n\n%s' % sig_nice
- await ux_show_story(msg)
-
-async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs):
- # Read xpub and validate from JSON received via SD card or BBQr
- # - obj => JSON object (mapping)
- # - af_str => address format we expect/need
-
- # value in file is BE32, but we want LE32 internally
- # - KeyError here handled by caller
- xfp = str2xfp(obj['xfp'])
- deriv = cleanup_deriv_path(obj[af_str + '_deriv'])
- ln = obj.get(af_str)
-
- return MultisigWallet.check_xpub(xfp, ln, deriv, chain.ctype, my_xfp, xpubs)
-
-async def ms_coordinator_qr(af_str, my_xfp, chain):
+async def ms_coordinator_qr(af_str, my_xfp):
# Scan a number of JSON files from BBQr w/ derive, xfp and xpub details.
#
- from ux_q1 import QRScannerInteraction
+ from ux_q1 import QRScannerInteraction, decode_qr_result, QRDecodeExplained
+
+ def convertor(got):
+ file_type, _, data = decode_qr_result(got, expect_bbqr=True)
+ if isinstance(data, bytes):
+ # we expect BBQr, but simple QR also possible here
+ data = data.decode()
+
+ if file_type == 'U':
+ data = data.strip()
+ if data[0] == '{' and data[-1] == '}':
+ file_type = 'J'
+ if file_type == 'J':
+ try:
+ return ujson.loads(data)
+ except:
+ raise QRDecodeExplained('Unable to decode JSON data')
+ else:
+ for line in data.split("\n"):
+ if len(line) > 112 and ("pub" in line):
+ return line.strip()
num_mine = 0
num_files = 0
- xpubs = []
+ keys = []
msg = 'Scan Exported XPUB from Coldcard'
while True:
- vals = await QRScannerInteraction().scan_json(msg)
- if vals is None:
+ key = await QRScannerInteraction().scan_general(msg, convertor, enter_quits=True)
+ if key is None:
break
-
try:
- is_mine = await validate_xpub_for_ms(vals, af_str, chain, my_xfp, xpubs)
+ if isinstance(key, dict):
+ k = Key.from_cc_json(key, af_str)
+ else:
+ k = Key.from_string(key)
+
+ num_mine += k.validate(my_xfp)
+ keys.append(k)
+
except KeyError as e:
# random JSON will end up here
msg = "Missing value: %s" % str(e)
@@ -1632,19 +66,17 @@ async def ms_coordinator_qr(af_str, my_xfp, chain):
msg = "Failure: %s" % str(e)
continue
- if is_mine:
- num_mine += 1
num_files += 1
msg = "Number of keys scanned: %d" % num_files
- return xpubs, num_mine, num_files
+ return keys, num_mine, num_files
-async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
+async def ms_coordinator_file(af_str, my_xfp, slot_b=None):
num_mine = 0
num_files = 0
- xpubs = []
+ keys = []
try:
with CardSlot(slot_b=slot_b) as card:
for path in card.get_paths():
@@ -1653,7 +85,9 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
# ignore subdirs
continue
- if not fn.startswith('ccxp-') or not fn.endswith('.json'):
+ if fn.endswith('.bsms'):
+ pass # allows files with [xfp/p/a/t/h]xpub
+ elif not fn.startswith('ccxp-') or not fn.endswith('.json'):
# wrong prefix/suffix: ignore
continue
@@ -1663,19 +97,34 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
# sigh, OS/filesystem variations
file_size = var[1] if len(var) == 2 else get_filesize(full_fname)
- if not (0 <= file_size <= 1100):
+ if not (0 <= file_size <= 1500):
# out of range size
continue
try:
with open(full_fname, 'rt') as fp:
- vals = ujson.load(fp)
-
- is_mine = await validate_xpub_for_ms(vals, af_str, chain,
- my_xfp, xpubs)
- if is_mine:
- num_mine += 1
-
+ try:
+ # CC multisig XPUBs JSON expected
+ vals = ujson.load(fp)
+ except:
+ # try looking for BIP-380 key expression
+ fp.seek(0)
+ for line in fp.readlines():
+ if len(line) > 112 and ("pub" in line):
+ vals = line.strip()
+ break
+
+ try:
+ if isinstance(vals, dict):
+ k = Key.from_cc_json(vals, af_str)
+ else:
+ k = Key.from_string(vals)
+ except Exception as e:
+ # sys.print_exception(e)
+ raise
+
+ num_mine += k.validate(my_xfp)
+ keys.append(k)
num_files += 1
except CardMissingError:
@@ -1683,16 +132,31 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
except Exception as exc:
# show something for coders, but no user feedback
- sys.print_exception(exc)
+ # sys.print_exception(exc)
continue
except CardMissingError:
await needs_microsd()
return
- return xpubs, num_mine, num_files
+ return keys, num_mine, num_files
+
-async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False):
+def add_own_xpub(chain, acct_num, addr_fmt, secret=None):
+ # Build out what's required for using master secret (or another
+ # encoded secret) as a co-signer
+ deriv = "48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num,
+ 2 if addr_fmt == AF_P2WSH else 1)
+
+ with stash.SensitiveValues(secret=secret) as sv:
+ the_xfp = xfp2str(sv.get_xfp())
+ koi = KeyOriginInfo.from_string(the_xfp + "/" + deriv)
+ node = sv.derive_path(deriv, register=False)
+ key = Key(node, koi, chain_type=chain.ctype)
+ return key
+
+
+async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, for_ccc=None):
# collect all xpub- exports (must be >= 1) to make "air gapped" wallet
# - function f specifies a way how to collect co-signer info - currently SD and QR (Q only)
# - ask for M value
@@ -1705,21 +169,21 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
my_xfp = settings.get('xfp')
if is_qr:
- xpubs, num_mine, num_files = await ms_coordinator_qr(mode, my_xfp, chain)
+ keys, num_mine, num_files = await ms_coordinator_qr(mode, my_xfp)
else:
- xpubs, num_mine, num_files = await ms_coordinator_file(mode, my_xfp, chain)
+ keys, num_mine, num_files = await ms_coordinator_file(mode, my_xfp)
if CardSlot.both_inserted():
# handle dual slot usage: assumes slot A used by first call above
- bxpubs, bnum_mine, bnum_files = await ms_coordinator_file(mode, my_xfp,
- chain, True)
- xpubs.extend(bxpubs)
+ bkeys, bnum_mine, bnum_files = await ms_coordinator_file(mode, my_xfp,
+ slot_b=True)
+ keys.extend(bkeys)
num_mine += bnum_mine
num_files += bnum_files
# remove dups; easy to happen if you double-tap the export
- xpubs = list(set(xpubs))
+ keys = list(set(keys))
- if not xpubs or (len(xpubs) == 1 and num_mine):
+ if not keys or (len(keys) == 1 and num_mine):
if is_qr:
msg = "No XPUBs scanned. Exit."
else:
@@ -1727,45 +191,82 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
" Must have filename: ccxp-....json")
await ux_show_story(msg)
return
-
- # add myself if not included already ?
- if not num_mine:
+
+ if for_ccc:
+ secret, ccc_ms_count = for_ccc
+ # Always include 2 keys from CCC: own master (key A) and key C
+ # - force them to same derivation.
+ acct = await ux_enter_bip32_index('CCC Account Number:') or 0
+
+ dis.fullscreen("Wait...")
+ a = add_own_xpub(chain, acct, addr_fmt) # master: key A
+ c = add_own_xpub(chain, acct, addr_fmt, secret=secret)
+
+ # problem: above file searching may find xpub export from key C
+ # (or our master seed, exported) .. we can't add them again,
+ # since xfp are not unique and that's probably not what they wanted
+ got_xfps = [a.origin.fingerprint, c.origin.fingerprint]
+ keys = [k for k in keys if k.origin.fingerprint not in got_xfps]
+
+ if not keys:
+ await ux_show_story("Need at least one other co-signer (key B).")
+ return
+
+ # master seed is always key0, key C is key1, k2..kn backup keys
+ keys = [a, c] + keys
+ num_mine += 2
+
+ elif not num_mine:
+ # add myself if not included already? As an option.
ch = await ux_show_story("Add current Coldcard with above XFP ?",
title="[%s]" % xfp2str(my_xfp))
if ch == "y":
acct = await ux_enter_bip32_index('Account Number:') or 0
dis.fullscreen("Wait...")
- deriv = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct,
- 2 if addr_fmt == AF_P2WSH else 1)
- with stash.SensitiveValues() as sv:
- node = sv.derive_path(deriv)
- xpubs.append((my_xfp, deriv, chain.serialize_public(node, AF_P2SH)))
+ keys.append(add_own_xpub(chain, acct, addr_fmt))
num_mine += 1
- N = len(xpubs)
+ N = len(keys)
if (N > MAX_SIGNERS) or (N < 2):
await ux_show_story("Invalid number of signers,min is 2 max is %d." % MAX_SIGNERS)
return
- # pick useful M value to start
- M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
- if not M:
- await ux_dramatic_pause('Aborted.', 2)
- return # user cancel
+ if for_ccc:
+ M = 2
+ else:
+ # pick useful M value to start
+ M = await ux_enter_number("How many need to sign?(M)", N, can_cancel=True)
+ if not M:
+ await ux_dramatic_pause('Aborted.', 2)
+ return # user cancel
dis.fullscreen("Wait...")
# create appropriate object
assert 1 <= M <= N <= MAX_SIGNERS
- name = 'CC-%d-of-%d' % (M, N)
- ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt)
+ if for_ccc:
+ name = "Coldcard Co-sign" if version.has_qwerty else "CCC"
+ if ccc_ms_count:
+ # make name unique for each CCC wallet, but they can edit
+ name += " #%d" % (ccc_ms_count + 1)
+ else:
+ name = 'CC-%d-of-%d' % (M, N)
+
+ from miniscript import Sortedmulti, Number
+ from wallet import MiniScriptWallet
+ from descriptor import Descriptor
+
+ desc_obj = Descriptor(miniscript=Sortedmulti(Number(M), *keys),
+ addr_fmt=addr_fmt)
+ # no need to validate here - as all the keys are already validated
+ msc = MiniScriptWallet.from_descriptor_obj(name, desc_obj)
if num_mine:
- from auth import NewEnrollRequest, UserAuthorizedAction
+ from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction
- UserAuthorizedAction.active_request = NewEnrollRequest(ms)
+ UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc)
# menu item case: add to stack
from ux import the_ux
@@ -1773,24 +274,26 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
else:
# we cannot enroll multisig in which we do not participate
# thou we can put descriptor on screen or on SD
- await ms.export_wallet_file(descriptor=True, desc_pretty=False)
+ # cannot sign export if my key not included
+ await msc.export_wallet_file(sign=False)
-async def create_ms_step1(*a):
+async def create_ms_step1(*a, for_ccc=None):
# Show story, have them pick address format.
ch = None
is_qr = False
if version.has_qr:
# They have a scanner, could do QR codes...
- ch = await ux_show_story("Press "+ KEY_QR + " to scan multisg XPUBs from "\
- "QR codes (BBQr) or ENTER to use SD card(s).", title="QR or SD Card?")
+ ch = await ux_show_story("Press " + KEY_QR + " to scan multisig XPUBs from "
+ "QR codes (BBQr) or ENTER to use SD card(s).",
+ title="QR or SD Card?")
if ch == KEY_QR:
is_qr = True
- ch = await ux_show_story("Press ENTER for default address format (P2WSH, segwit), "\
+ ch = await ux_show_story("Press ENTER for default address format (P2WSH, segwit), "
"otherwise, press (1) for P2SH-P2WSH.", title="Address Format",
- escape="1")
+ escape="1")
else:
ch = await ux_show_story('''\
@@ -1808,81 +311,10 @@ async def create_ms_step1(*a):
return
try:
- return await ondevice_multisig_create(n, f, is_qr)
+ return await ondevice_multisig_create(n, f, is_qr, for_ccc=for_ccc)
except Exception as e:
+ # sys.print_exception(e)
await ux_show_story('Failed to create multisig.\n\n%s\n%s' % (e, problem_file_line(e)),
title="ERROR")
-
-async def import_multisig_nfc(*a):
- from glob import NFC
- # this menu option should not be available if NFC is disabled
- try:
- return await NFC.import_multisig_nfc()
- except Exception as e:
- await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e))
-
-async def import_multisig_qr(*a):
- from auth import maybe_enroll_xpub
- from ux_q1 import QRScannerInteraction
- data = await QRScannerInteraction().scan_text('Scan Multisig from a QR code')
- if not data:
- # pressed CANCEL
- return
-
- try:
- maybe_enroll_xpub(config=data)
- except Exception as e:
- await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
-
-async def import_multisig(*a):
- # pick text file from SD card, import as multisig setup file
- from actions import file_picker
- from glob import VD
-
- force_vdisk = False
- if VD:
- prompt = "Press (1) to import multisig wallet file from SD Card"
- escape = "1"
- if VD is not None:
- prompt += ", press (2) to import from Virtual Disk"
- escape += "2"
- prompt += "."
- ch = await ux_show_story(prompt, escape=escape)
- if ch == "1":
- force_vdisk=False
- elif ch == "2":
- force_vdisk = True
- else:
- return
-
- def possible(filename):
- with open(filename, 'rt') as fd:
- for ln in fd:
- if "sh(" in ln or "wsh(" in ln:
- # descriptor import
- return True
- if 'pub' in ln:
- return True
-
- fn = await file_picker(suffix=['.txt', '.json'], min_size=100, max_size=20*200,
- taster=possible, force_vdisk=force_vdisk)
- if not fn: return
-
- try:
- with CardSlot(force_vdisk=force_vdisk) as card:
- with open(fn, 'rt') as fp:
- data = fp.read()
- except CardMissingError:
- await needs_microsd()
- return
-
- from auth import maybe_enroll_xpub
- try:
- possible_name = (fn.split('/')[-1].split('.'))[0]
- maybe_enroll_xpub(config=data, name=possible_name)
- except Exception as e:
- #import sys; sys.print_exception(e)
- await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
-
# EOF
diff --git a/shared/nfc.py b/shared/nfc.py
index 2a7c57e15..be66c5001 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 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
@@ -107,13 +107,14 @@ async def wipe(self, full_wipe):
from glob import dis
here = bytes(256)
end = 8196
- for pos in range(0, end, 256) :
+ for pos in range(0, end, 256):
self.i2c.writeto_mem(I2C_ADDR_USER, pos, here, addrsize=16)
- if pos == 256 and not full_wipe: break
+ if (pos == 256) and not full_wipe: break
# 6ms per 16 byte row, worst case, so ~100ms here per iter! 3.2seconds total
if full_wipe:
dis.progress_bar_show(pos / end)
+
await self.wait_ready()
# system config area (flash cells, but affect operation): table 12
@@ -224,6 +225,14 @@ def setup(self):
self.set_rf_disable(1)
+ async def share_loop(self, n, **kws):
+ while 1:
+ done = await self.share_start(n, **kws)
+ if done:
+ # do not wipe if we are not done
+ await self.wipe(kws.get("is_secret", False))
+ break
+
async def share_signed_txn(self, txid, file_offset, txn_len, txn_sha):
# we just signed something, share it over NFC
if txn_len >= MAX_NFC_SIZE:
@@ -231,13 +240,20 @@ async def share_signed_txn(self, txid, file_offset, txn_len, txn_sha):
return
n = ndef.ndefMaker()
+ line2 = None
if txid is not None:
n.add_text('Signed Transaction: ' + txid)
n.add_custom('bitcoin.org:txid', a2b_hex(txid)) # want binary
+ line2 = self.txid_line2(txid)
+
n.add_custom('bitcoin.org:sha256', txn_sha)
n.add_large_object('bitcoin.org:txn', file_offset, txn_len)
- return await self.share_start(n)
+ return await self.share_loop(n, line2=line2)
+
+ @staticmethod
+ def txid_line2(txid):
+ return "Signed TXID: %s⋯%s" % (txid[0:8], txid[-8:])
async def share_push_tx(self, url, txid, txn, txn_sha, line2=None):
# Given a signed TXN, we convert to URL which a web backend can broadcast directly
@@ -267,13 +283,9 @@ async def share_push_tx(self, url, txid, txn, txn_sha, line2=None):
n.add_url(url, https=is_https)
if line2 is None:
- line2 = "Signed TXID: %s⋯%s" % (txid[0:8], txid[-8:])
-
- while 1:
- done = await self.share_start(n, prompt="Tap to broadcast, CANCEL when done",
- line2=line2)
+ line2 = self.txid_line2(txid)
- if done: break
+ await self.share_loop(n, prompt="Tap to broadcast, CANCEL when done", line2=line2)
async def push_tx_from_file(self):
# Pick (signed txn) file from SD card and broadcast via PushTx
@@ -343,24 +355,19 @@ async def share_psbt(self, file_offset, psbt_len, psbt_sha, label=None):
return
n = ndef.ndefMaker()
- n.add_text(label or 'Partly signed PSBT')
+ label = label or 'Partly signed PSBT'
+ n.add_text(label)
n.add_custom('bitcoin.org:sha256', psbt_sha)
n.add_large_object('bitcoin.org:psbt', file_offset, psbt_len)
- return await self.share_start(n)
-
- async def share_deposit_address(self, addr, **kws):
- n = ndef.ndefMaker()
- n.add_text('Deposit Address')
- n.add_custom('bitcoin.org:addr', addr.encode())
- return await self.share_start(n, **kws)
+ return await self.share_loop(n, line2=label)
async def share_json(self, json_data, **kws):
# a text file of JSON for programs to read
n = ndef.ndefMaker()
n.add_mime_data('application/json', json_data)
- return await self.share_start(n, **kws)
+ return await self.share_loop(n, **kws)
async def share_text(self, data, **kws):
# share text from a list of values
@@ -368,7 +375,7 @@ async def share_text(self, data, **kws):
n = ndef.ndefMaker()
n.add_text(data)
- return await self.share_start(n, **kws)
+ return await self.share_loop(n, **kws)
async def wait_ready(self):
# block until chip ready to continue (ACK happens)
@@ -394,11 +401,12 @@ async def setup_gpio(self):
self.write_dyn(GPO_CTRL_Dyn, 0x01) # GPO_EN
self.read_dyn(IT_STS_Dyn) # clear interrupt
- async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=None):
+ async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=None,
+ is_secret=False):
# Run the pretty animation, and detect both when we are written, and/or key to exit/abort.
# - similar when "read" and then removed from field
# - return T if aborted by user
- from glob import dis, numpad
+ from glob import dis
await self.wait_ready()
self.set_rf_disable(0)
@@ -467,8 +475,6 @@ async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=No
break
self.set_rf_disable(1)
- if not write_mode:
- await self.wipe(False)
return aborted
@@ -476,9 +482,7 @@ async def share_start(self, ndef_obj, **kws):
# do the UX while we are sharing a value over NFC
# - assumpting is people know what they are scanning
# - x key to abort early, but also self-clears
-
await self.big_write(ndef_obj.bytes())
-
return await self.ux_animation(False, **kws)
async def start_nfc_rx(self, **kws):
@@ -514,8 +518,7 @@ async def start_nfc_rx(self, **kws):
await self.wipe(False)
return rv
-
- async def start_psbt_rx(self):
+ async def start_psbt_rx(self, miniscript_wallet=None):
from auth import psbt_encoding_taster, TXN_INPUT_OFFSET
from auth import UserAuthorizedAction, ApproveTransaction
from ux import the_ux
@@ -540,10 +543,7 @@ async def start_psbt_rx(self):
if urn == 'urn:nfc:ext:bitcoin.org:sha256' and len(msg) == 32:
# probably produced by another Coldcard: SHA256 over expected contents
psbt_sha = bytes(msg)
- except Exception as e:
- # dont crash when given garbage
- import sys; sys.print_exception(e)
- pass
+ except Exception: pass # dont crash when given garbage
if psbt_in is None:
await ux_show_story("Could not find PSBT in what was written.", title="Sorry!")
@@ -564,44 +564,13 @@ async def start_psbt_rx(self):
# start signing UX
UserAuthorizedAction.cleanup()
- UserAuthorizedAction.active_request = ApproveTransaction(psbt_len, 0x0, psbt_sha=psbt_sha,
- approved_cb=self.signing_done)
+ UserAuthorizedAction.active_request = ApproveTransaction(
+ psbt_len, psbt_sha=psbt_sha, input_method="nfc",
+ output_encoder=output_encoder, miniscript_wallet=miniscript_wallet,
+ )
# kill any menu stack, and put our thing at the top
the_ux.push(UserAuthorizedAction.active_request)
- async def signing_done(self, psbt):
- # User approved the PSBT, and signing worked... share result over NFC (only)
- from auth import TXN_OUTPUT_OFFSET, try_push_tx
- from version import MAX_TXN_LEN
- from sffile import SFFile
-
- txid = None
-
- # asssume they want final transaction when possible, else PSBT output
- is_comp = psbt.is_complete()
-
- # re-serialize the PSBT back out (into PSRAM)
- with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as fd:
- if is_comp:
- txid = psbt.finalize(fd)
- else:
- psbt.serialize(fd)
-
- self.result = (fd.tell(), fd.checksum.digest())
-
- out_len, out_sha = self.result
-
- if is_comp:
- if txid and await try_push_tx(out_len, txid, out_sha):
- return # success, exit
-
- await self.share_signed_txn(txid, TXN_OUTPUT_OFFSET, out_len, out_sha)
- else:
- await self.share_psbt(TXN_OUTPUT_OFFSET, out_len, out_sha)
-
- # ? show txid on screen ?
- # thank them?
-
@classmethod
async def selftest(cls):
# Check for chip present, field present .. and that it works
@@ -610,10 +579,12 @@ async def selftest(cls):
n.setup()
assert n.uid
- aborted = await n.share_text("NFC is working: %s" % n.get_uid(), allow_enter=False)
+ nn = ndef.ndefMaker()
+ nn.add_text("NFC is working: %s" % n.get_uid())
+
+ aborted = await n.share_start(nn, allow_enter=False)
assert not aborted, "Aborted"
-
async def share_file(self):
# Pick file from SD card and share over NFC...
from actions import file_picker
@@ -659,82 +630,33 @@ def is_suitable(fname):
else:
raise ValueError(ext)
- async def import_multisig_nfc(self, *a):
- # user is pushing a file downloaded from another CC over NFC
- # - would need an NFC app in between for the sneakernet step
- # get some data
- data = await self.start_nfc_rx()
- if not data: return
-
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- if len(msg) < 70: continue
- msg = bytes(msg).decode() # from memory view
- # multi( catches both multi( and sortedmulti(
- if 'pub' in msg or "multi(" in msg:
- winner = msg
- break
-
- if not winner:
- await ux_show_story('Unable to find multisig descriptor.')
- return
-
- from auth import maybe_enroll_xpub
- try:
- maybe_enroll_xpub(config=winner)
- except Exception as e:
- #import sys; sys.print_exception(e)
- await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
-
async def import_ephemeral_seed_words_nfc(self, *a):
- data = await self.start_nfc_rx()
- if not data: return
+ def f(m):
+ sm = m.decode().strip().split(" ")
+ if len(sm) in stash.SEED_LEN_OPTS:
+ return sm
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode().strip() # from memory view
- split_msg = msg.split(" ")
- if len(split_msg) in stash.SEED_LEN_OPTS:
- winner = split_msg
- break
-
- if not winner:
- await ux_show_story('Unable to find seed words')
- return
-
- try:
- from seed import set_ephemeral_seed_words
- await set_ephemeral_seed_words(winner, meta='NFC Import')
- except Exception as e:
- #import sys; sys.print_exception(e)
- await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
+ winner = await self._nfc_reader(f, 'Unable to find seed words')
- async def confirm_share_loop(self, string):
- while True:
- # added loop here as NFC send can fail, or not send the data
- # and in that case one would have to start from beginning (send us cmd, approve, etc.)
- # => get chance to check if you received the data and if something went wrong - retry just send
- await self.share_text(string)
- ch = await ux_show_story(title="Shared", msg="Press %s to share again, otherwise %s to stop." % (OK, X))
- if ch != "y":
- break
+ if winner:
+ try:
+ from seed import set_ephemeral_seed_words
+ await set_ephemeral_seed_words(winner, origin='NFC Import')
+ except Exception as e:
+ #import sys; sys.print_exception(e)
+ await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
async def address_show_and_share(self):
- from auth import show_address, ApproveMessageSign
+ from auth import show_address
- data = await self.start_nfc_rx()
- if not data: return
+ def f(m):
+ sm = m.decode().split("\n")
+ if 1 <= len(sm) <= 2:
+ return sm
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
- split_msg = msg.split("\n")
- if 1 <= len(split_msg) <= 2:
- winner = split_msg
- break
+ winner = await self._nfc_reader(f, 'Expected address and derivation path.')
if not winner:
- await ux_show_story('Expected address and derivation path.')
return
if len(winner) == 1:
@@ -743,7 +665,7 @@ async def address_show_and_share(self):
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
@@ -754,133 +676,124 @@ async def address_show_and_share(self):
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
- data = await self.start_nfc_rx()
- if not data: return
-
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
- split_msg = msg.split("\n")
+ def f(m):
+ m = m.decode()
+ split_msg = m.split("\n")
if 1 <= len(split_msg) <= 3:
- winner = split_msg
- break
+ return m
+ winner = await self._nfc_reader(f, 'Unable to find correctly formated message to sign.')
if not winner:
- await ux_show_story('Unable to find correctly formated message to sign.')
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
-
- 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
+ await approve_msg_sign(None, None, None, approved_cb=self.msg_sign_done,
+ msg_sign_request=winner)
async def msg_sign_done(self, signature, address, text):
- from auth import rfc_signature_template_gen
+ from msgsign import rfc_signature_template
sig = b2a_base64(signature).decode('ascii').strip()
- armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text, sig=sig))
- await self.confirm_share_loop(armored_str)
+ armored_str = "".join(rfc_signature_template(addr=address, msg=text, sig=sig))
+ await self.share_text(armored_str)
async def verify_sig_nfc(self):
- from auth import verify_armored_signed_msg
+ from msgsign import verify_armored_signed_msg
- data = await self.start_nfc_rx()
- if not data: return
+ f = lambda x: x.decode().strip() if b"SIGNED MESSAGE" in x else None
+ winner = await self._nfc_reader(f, 'Unable to find signed message.')
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
- if "SIGNED MESSAGE" in msg:
- winner = msg.strip()
- break
+ if winner:
+ await verify_armored_signed_msg(winner, digest_check=False)
- if not winner:
- await ux_show_story('Unable to find signed message.')
- return
+ async def read_address(self):
+ # Read an address or BIP-21 url and parse out addr (just one)
+ from utils import decode_bip21_text
- await verify_armored_signed_msg(winner, digest_check=False)
+ def f(m):
+ m = m.decode()
+ what, vals = decode_bip21_text(m)
+ if what == 'addr':
+ return vals
+
+ winner = await self._nfc_reader(f, 'Unable to find address from NFC data.')
+
+ return winner
async def verify_address_nfc(self):
# Get an address or complete bip-21 url even and search it... slow.
- from utils import decode_bip21_text
+ _, addr, args = await self.read_address()
+ if addr:
+ from ownership import OWNERSHIP
+ await OWNERSHIP.search_ux(addr, args)
+
+ async def read_extended_private_key(self):
+ f = lambda x: x.decode().strip() if b"prv" in x else None
+ return await self._nfc_reader(f, 'Unable to find extended private key.')
+
+ async def read_tapsigner_b64_backup(self):
+ f = lambda x: a2b_base64(x.decode()) if 150 <= len(x) <= 280 else None
+ return await self._nfc_reader(f, 'Unable to find base64 encoded TAPSIGNER backup.')
+ async def _nfc_reader(self, func, fail_msg):
data = await self.start_nfc_rx()
if not data: return
winner = None
for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
+ msg = bytes(msg)
try:
- what, vals = decode_bip21_text(msg)
- if what == 'addr':
- winner = vals[1]
+ r = func(msg)
+ if r is not None:
+ winner = r
break
- except ValueError:
+ except:
pass
if not winner:
- await ux_show_story('Unable to find address from NFC data.')
- return
-
- from ownership import OWNERSHIP
- await OWNERSHIP.search_ux(winner)
-
- async def read_extended_private_key(self):
- data = await self.start_nfc_rx()
- if not data: return
-
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
- if "prv" in msg:
- winner = msg.strip()
- break
-
- if not winner:
- await ux_show_story('Unable to find extended private key.')
+ await ux_show_story(fail_msg)
return
return winner
- async def read_tapsigner_b64_backup(self):
- data = await self.start_nfc_rx()
- if not data: return
-
- winner = None
- for urn, msg, meta in ndef.record_parser(data):
- msg = bytes(msg).decode() # from memory view
+ async def read_bsms_token(self):
+ def f(m):
+ m = m.decode().strip()
try:
- if 150 <= len(msg) <= 280:
- winner = a2b_base64(msg)
- break
- except:
- pass
+ int(m, 16)
+ return m
+ except: pass
+
+ return await self._nfc_reader(f, 'Unable to find BSMS token in NDEF data')
+ async def read_bsms_data(self):
+ def f(m):
+ m = m.decode().strip() # from memory view
+ try:
+ if "BSMS" in m or int(m[:6], 16):
+ # unencrypted/encrypted case
+ return m
+ except: pass
+
+ return await self._nfc_reader(f, 'Unable to find BSMS data in NDEF data')
+
+ async def import_miniscript_nfc(self):
+ def f(m):
+ if len(m) < 70: return
+ m = m.decode()
+ # TODO this should be Descriptor.is_descriptor() ?
+ if 'pub' in m:
+ return m
+
+ winner = await self._nfc_reader(f, 'Unable to find miniscript descriptor expected in NDEF')
if not winner:
- await ux_show_story('Unable to find base64 encoded TAPSIGNER backup.')
return
- return winner
+ from auth import maybe_enroll_xpub
+ try:
+ maybe_enroll_xpub(config=winner)
+ except Exception as e:
+ await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
# EOF
diff --git a/shared/notes.py b/shared/notes.py
index 108bfd975..d1fd1f76e 100644
--- a/shared/notes.py
+++ b/shared/notes.py
@@ -13,7 +13,7 @@
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
-from utils import problem_file_line, url_decode
+from utils import problem_file_line, url_unquote, wipe_if_deltamode
# title, username and such are limited that they fit on the one line both in
# text entry (W-2) and also in menu display (W-3)
@@ -21,7 +21,18 @@
ONE_LINE = CHARS_W-2
async def make_notes_menu(*a):
- if settings.get('notes', False) == False:
+ from pincodes import pa
+
+ if pa.hobbled_mode:
+ # Read only version of menu system
+ # - used when spending policy in effect
+ # - must have some notes already, or unreachable
+ assert NoteContent.count()
+ rv = NotesMenu(NotesMenu.construct_readonly())
+ rv.readonly = True
+ return rv
+
+ if not settings.get('secnap', False):
# Explain feature, and then enable if interested. Drop them into menu.
ch = await ux_show_story('''\
Enable this feature to store short text notes and passwords inside the Coldcard.
@@ -34,15 +45,17 @@ async def make_notes_menu(*a):
if ch != 'y':
return
- # mark as enabled (altho empty)
- settings.set('notes', [])
+ # mark as enabled
+ settings.set('secnap', True)
+ if settings.get('notes', None) is None:
+ settings.set('notes', [])
# need to correct top menu now, so this choice is there.
goto_top_menu()
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.
@@ -96,12 +109,14 @@ async def _toggle_case(was):
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):
+ readonly = False
+
@classmethod
def construct(cls):
# Dynamic menu with user-defined names of notes shown
@@ -110,9 +125,12 @@ def construct(cls):
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:
+ wipe_if_deltamode()
+
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), menu=note.make_menu))
@@ -121,14 +139,37 @@ def construct(cls):
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
+ @classmethod
+ def construct_readonly(cls):
+ # When only allowed to view, no export/add new/delete.
+ wipe_if_deltamode()
+
+ rv = []
+ for note in NoteContent.get_all():
+ rv.append(MenuItem('%d: %s' % (note.idx+1, note.title),
+ menu=note.make_menu, arg=True)) # readonly=True
+
+ return rv
+
@classmethod
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.
@@ -145,7 +186,7 @@ async def quick_create(cls, menu, _, item):
if got.startswith('otpauth://totp/'):
# see
- tmp.title = url_decode(got[15:]).split('?', 1)[0]
+ tmp.title = url_unquote(got[15:]).split('?', 1)[0]
elif got.startswith('otpauth-migration://offline'):
# see
tmp.title = 'Google Auth'
@@ -159,7 +200,6 @@ async def quick_create(cls, menu, _, item):
await tmp._save_ux(menu)
await cls.drill_to(menu, tmp)
-
def update_contents(self):
# Reconstruct the list of notes on this dynamic menu, because
# we added or changed them and are showing that same menu again.
@@ -170,6 +210,7 @@ def update_contents(self):
async def disable_notes(cls, *a):
# they don't want feature anymore; already checked no notes in effect
# - no need for confirm, they aren't loosing anything
+ settings.remove_key('secnap')
settings.remove_key('notes')
settings.save()
@@ -188,8 +229,8 @@ async def new_note(cls, menu, _, item):
async def drill_to(cls, menu, item):
# make it so looks like we drilled down into the new note
menu.goto_idx(item.idx)
- m = MenuSystem(await item.make_menu())
- the_ux.push(m)
+ m = await item._make_menu()
+ the_ux.push(MenuSystem(m))
class NoteContentBase:
@@ -223,6 +264,17 @@ def count(cls):
# 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.")
@@ -246,7 +298,7 @@ async def delete(self, *a):
await ux_dramatic_pause('Deleted.', 3)
- async def share_nfc(self, menu, _, item):
+ async def share_nfc(self, a, b, item):
# share something via NFC -- if small enough and enabled
from glob import NFC
@@ -256,12 +308,26 @@ async def share_nfc(self, menu, _, item):
if len(v) < 8000: # see MAX_NFC_SIZE
await NFC.share_text(v)
+ async def view_qr(self, k):
+ # full screen QR
+ try:
+ await show_qr_code(getattr(self, k), msg=self.title, is_secret=True)
+ except Exception as exc:
+ # - not all data can be a QR (non-text, binary, zeros)
+ # - might be too big for single QR
+ # - may be a RuntimeError(n) where n is line number inside uqr
+ await ux_show_story("Unable to display as QR.\n\nError: " + str(exc))
+
+ async def view_qr_menu(self, a, b, item):
+ await self.view_qr(item.arg)
+
async def _save_ux(self, menu):
is_new = self.save()
if not is_new:
# change our own menu contents
- menu.replace_items(await self.make_menu())
+ mi = await self._make_menu()
+ menu.replace_items(mi)
# update parent
parent = the_ux.parent_of(menu)
@@ -289,29 +355,50 @@ async def export(self, *a):
# single export
await start_export([self])
+ async def sign_txt_msg(self, a, b, item):
+ from msgsign 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' ]
type_label = 'password'
- async def make_menu(self, *a):
+ async def _make_menu(self, readonly=False):
rv = [MenuItem('"%s"' % self.title, f=self.view)]
if self.user:
rv.append(MenuItem('↳ %s' % self.user, f=self.view))
if self.site:
rv.append(MenuItem('↳ %s' % self.site, f=self.view))
- #if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
- return rv + [
+ # if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
+ rv += [
MenuItem('View Password', f=self.view_pw),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
- MenuItem('Export', f=self.export),
- MenuItem('Edit Metadata', f=self.edit),
- MenuItem('Delete', f=self.delete),
- MenuItem('Change Password', f=self.change_pw),
- ShortcutItem(KEY_QR, f=self.view_qr),
- ShortcutItem(KEY_NFC, f=self.share_nfc, arg='password'),
+ ]
+ if not readonly:
+ rv += [
+ MenuItem('Export', f=self.export),
+ MenuItem('Edit Metadata', f=self.edit),
+ MenuItem('Delete', f=self.delete),
+ MenuItem('Change Password', f=self.change_pw),
+ ]
+ rv += [
+ self.sign_misc_menu_item(),
+ ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label),
+ ShortcutItem(KEY_NFC, f=self.share_nfc, arg=self.type_label),
]
+ return rv
+
+ async def make_menu(self, a, b, item):
+ items = await self._make_menu(readonly=item.arg)
+ return MenuSystem(items)
+
async def view(self, *a):
pl = len(self.password)
m = ''
@@ -350,7 +437,7 @@ async def view_pw(self, *a):
ch = await ux_show_story(msg, title=self.title, escape=KEY_QR,
hint_icons=KEY_QR)
if ch == KEY_QR:
- await self.view_qr()
+ await self.view_qr(self.type_label)
async def send_pw(self, *a):
# use USB to send it -- weak at present
@@ -362,10 +449,6 @@ async def send_pw(self, *a):
"we cannot type at this time.")
await single_send_keystrokes(self.password)
- async def view_qr(self, *a):
- # full screen QR
- await show_qr_code(self.password, msg=self.title)
-
async def edit(self, menu, _, item):
# Edit, also used for add new
@@ -429,33 +512,34 @@ class NoteContent(NoteContentBase):
flds = ['title', 'misc']
type_label = 'note'
- async def make_menu(self, *a):
+ async def _make_menu(self, readonly=False):
# Details and actions for this Note
- return [
+ rv = [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Note', f=self.view),
- MenuItem('Edit', f=self.edit),
- MenuItem('Delete', f=self.delete),
- MenuItem('Export', f=self.export),
- ShortcutItem(KEY_QR, f=self.view_qr),
+ ]
+ if not readonly:
+ rv += [
+ MenuItem('Edit', f=self.edit),
+ MenuItem('Delete', f=self.delete),
+ MenuItem('Export', f=self.export),
+ ]
+ rv += [
+ self.sign_misc_menu_item(),
+ ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
]
+ return rv
+
+ async def make_menu(self, a, b, item):
+ items = await self._make_menu(readonly=item.arg)
+ return MenuSystem(items)
async def view(self, *a):
ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR,
hint_icons=KEY_QR)
if ch == KEY_QR:
- await self.view_qr()
-
- async def view_qr(self, *a):
- # full screen QR
- try:
- await show_qr_code(self.misc, msg=self.title)
- except Exception as exc:
- # - not all data can be a QR (non-text, binary, zeros)
- # - might be too big for single QR
- # - may be a RuntimeError(n) where n is line number inside uqr
- await ux_show_story("Unable to display as QR.\n\nError: "+str(exc))
+ await self.view_qr("misc")
async def edit(self, menu, _, item):
# Edit, also used for add new
@@ -498,16 +582,16 @@ async def edit(self, menu, _, item):
async def start_export(notes):
# Save out notes/passwords
from glob import NFC
- from auth import write_sig_file
+ from msgsign import write_sig_file
import ujson as json
from ux_q1 import show_bbqr_codes
singular = (len(notes) == 1)
item = notes[0].type_label if singular else 'all notes & passwords'
- choice = await import_export_prompt(item, is_import=False, title="Data Export", no_nfc=True,
- footnotes="\n\nWARNING: No encryption happens here. "
- "Your secrets will be cleartext.")
+ choice = await import_export_prompt(item, title="Data Export", no_nfc=True,
+ footnotes="WARNING: No encryption happens here."
+ " Your secrets will be cleartext.")
if choice == KEY_CANCEL:
return
@@ -536,7 +620,7 @@ async def start_export(notes):
await needs_microsd()
return
except Exception as e:
- await ux_show_story('Failed to write!\n\n\n'+str(e))
+ await ux_show_story('Failed to write!\n\n'+str(e))
return
msg = 'Export file written:\n\n%s\n\nSignature file written:\n\n%s' % (
@@ -565,14 +649,11 @@ async def import_from_other(menu, *a):
else:
def contains_json(fname):
if not fname.endswith('.json'): return False
- print(fname)
try:
obj = json.load(open(fname, 'rt'))
assert 'coldcard_notes' in obj
return True
- except Exception as exc:
- import sys; sys.print_exception(exc)
- pass
+ except: pass
fn = await file_picker(min_size=8, max_size=100000, taster=contains_json, **choice)
if not fn: return
@@ -581,7 +662,13 @@ def contains_json(fname):
records = json.load(open(fn, 'rt'))
# We have some JSON, parsed now.
- # - should dedup, but we aren't
+ await import_from_json(records)
+
+ await ux_dramatic_pause('Saved.', 3)
+ menu.update_contents()
+
+async def import_from_json(records):
+ # should dedup, but we aren't
try:
assert 'coldcard_notes' in records, 'Incorrect format'
@@ -591,14 +678,11 @@ def contains_json(fname):
was = list(settings.get('notes', []))
was.extend(new)
- settings.put('notes', was)
+ settings.set('notes', was)
+ settings.set('secnap', True)
settings.save()
except Exception as e:
await ux_show_story(title="Failure", msg=str(e) + '\n\n' + problem_file_line(e))
-
- await ux_dramatic_pause('Saved.', 3)
- menu.update_contents()
-
# EOF
diff --git a/shared/nvstore.py b/shared/nvstore.py
index 6151c2c04..f162dc6de 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
@@ -32,7 +32,8 @@
# batt_to = (when on battery only) idle timeout period
# _age = internal verison number for data (see below)
# tested = selftest has been completed successfully
-# multisig = list of defined multisig wallets (complex)
+# multisig = list of defined multisig wallets (complex) [before removal of MultisigWallet]
+# miniscript = list of defined miniscript wallets, including multisig (complex)
# pms = trust/import/distrust xpubs found in PSBT files
# fee_limit = (int) percentage of tx value allowed as max fee
# axi = index of last selected address in explorer
@@ -56,13 +57,18 @@
# seedvault = (bool) opt-in enable seed vault feature
# seeds = list of stored secrets for seedvault feature
# bright = (int:0-255) LCD brightness when on battery
+# secnap = (bool) opt-in enable Secure Notes & Passwords feature
# notes = (complex) Secure notes held for user, see notes.py
# accts = (list of tuples: (addr_fmt, account#)) Single-sig wallets we've seen them use
# aei = (bool) allow changing start index in Address Explorer
# b85max = (bool) allow max BIP-32 int value in BIP-85 derivations
# ptxurl = (str) URL for PushTx feature, clear to disable feature
# hmx = (bool) Force display of current XFP in home menu, even w/o tmp seed active
-# unsort_ms = (bool) Allow unsorted multisig with BIP-67 disabled
+# ccc = (complex) If present, CCC feature is enabled and key details stored here.
+# ktrx = (privkey) Key teleport Rx has been started, this will be our keypair
+# aemscsv = (bool) opt-in enable more verbose CSV output for miniscript wallets with Derivations and Scripts
+# sssp = (complex) If present, a (single signer) spending-policy is defined (maybe disabled)
+# lfr = (string) If present, the reason why Spending Policy blocked last transaction
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)
@@ -76,16 +82,19 @@
# terms_ok = customer has signed-off on the terms of sale
# settings linked to seed
-# LINKED_SETTINGS = ["multisig", "tp", "ovc", "xfp", "xpub", "words"]
+# LINKED_SETTINGS = ["miniscript", "tp", "ovc", "xfp", "xpub", "words"]
# settings that does not make sense to copy to temporary secret
# LINKED_SETTINGS += ["sd2fa", "usr", "axi", "hsmcmd"]
# 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']
+# key value pairs saved directly to master seed settings
+# held in RAM for tmp seed sessions
+MASTER_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw", "sssp"]
NUM_SLOTS = const(100)
SLOTS = range(NUM_SLOTS)
@@ -175,6 +184,13 @@ def get_capacity(self):
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):
@@ -191,13 +207,13 @@ def _wipe_slot(self, pos):
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)
@@ -242,9 +258,12 @@ def _write_slot(self, pos, aes):
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
@@ -265,10 +284,11 @@ def _nonempty_slots(self, dis=None):
def leaving_master_seed(self):
# going from master seed to a tmp seed, so capture a few values we need.
+ self.save_if_dirty()
SettingsObject.master_nvram_key = self.nvram_key
- for fn in SEEDVAULT_FIELDS:
+ for fn in MASTER_FIELDS:
curr = self.current.get(fn, None)
if curr is not None:
SettingsObject.master_sv_data[fn] = curr
@@ -284,7 +304,7 @@ def return_to_master_seed(self):
SettingsObject.master_sv_data.clear()
SettingsObject.master_nvram_key = None
- def master_set(self, key, value):
+ def master_set(self, key, value, master_only=False):
# Set a value, and it must be saved under the master seed's
# Concern is we may be changing a setting from a tmp seed mode
# - always does a save
@@ -295,6 +315,7 @@ def master_set(self, key, value):
self.set(key, value)
self.save()
else:
+ assert not master_only
# harder, slower: have to load, change and write
master = SettingsObject(nvram_key=SettingsObject.master_nvram_key)
master.load()
@@ -303,7 +324,7 @@ def master_set(self, key, value):
del master
# track our copies
- if key in SEEDVAULT_FIELDS:
+ if key in MASTER_FIELDS:
SettingsObject.master_sv_data[key] = value
def master_get(self, kn, default=None):
@@ -315,7 +336,7 @@ def master_get(self, kn, default=None):
return self.get(kn, default)
# LIMITATION: only supporting a few values we know we will need
- assert kn in SEEDVAULT_FIELDS
+ assert kn in MASTER_FIELDS
res = SettingsObject.master_sv_data.get(kn, default)
if res is None:
return default
@@ -391,8 +412,9 @@ def put(self, kn, v):
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
@@ -400,7 +422,7 @@ def merge_previous_active(self, previous):
if previous:
for k in KEEP_IF_BLANK_SETTINGS:
- if k in previous and k not in self.current:
+ if (k in previous) and (k not in self.current):
self.current[k] = previous[k]
# nfc, usb, vidsk handling
@@ -450,11 +472,8 @@ async def write_out(self):
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/opcodes.py b/shared/opcodes.py
index d015d1737..7224795f0 100644
--- a/shared/opcodes.py
+++ b/shared/opcodes.py
@@ -82,7 +82,7 @@
#OP_RSHIFT = const(153)
#OP_BOOLAND = const(154)
#OP_BOOLOR = const(155)
-#OP_NUMEQUAL = const(156)
+OP_NUMEQUAL = const(156)
#OP_NUMEQUALVERIFY = const(157)
#OP_NUMNOTEQUAL = const(158)
#OP_LESSTHAN = const(159)
@@ -114,6 +114,7 @@
#OP_NOP8 = const(183)
#OP_NOP9 = const(184)
#OP_NOP10 = const(185)
+OP_CHECKSIGADD = const(186)
#OP_NULLDATA = const(252)
#OP_PUBKEYHASH = const(253)
#OP_PUBKEY = const(254)
diff --git a/shared/ownership.py b/shared/ownership.py
index 2eb6c9773..968eb22bb 100644
--- a/shared/ownership.py
+++ b/shared/ownership.py
@@ -2,16 +2,17 @@
#
# ownership.py - store a cache of hashes related to addresses we might control.
#
-import os, sys, chains, ngu, struct, version
+import os, chains, ngu, struct, version
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
# Track many addresses, but in compressed form
# - map from random Bech32/Base58 payment address to (wallet) + keypath
-# - only normal (external, not change) addresses, and won't consider
-# any keypath that does not end in 0/*
+# - won't consider any keypath that does not end in <0;1>/*
# - store only hints, since we can re-construct any address and want to fully verify
# - try to keep private between different duress wallets, and seed vaults
# - storing bulk data into LFS, not settings
@@ -38,7 +39,7 @@
# target 3 flash blocks, max file size => 764 addresses
MAX_ADDRS_STORED = const(764) # =((3*512) - OWNERSHIP_FILE_HDR_LEN) // HASH_ENC_LEN
-BONUS_GAP_LIMIT = const(20)
+BONUS_AFTER_MATCH = const(20) # number of addresses to still generate after match found
def encode_addr(addr, salt):
# Convert text address to something we can store while preserving privacy.
@@ -49,12 +50,13 @@ class AddressCacheFile:
def __init__(self, wallet, change_idx):
self.wallet = wallet
self.change_idx = change_idx
- desc = wallet.to_descriptor().serialize()
+ desc = wallet.to_descriptor().to_string(internal=False)
h = b2a_hex(ngu.hash.sha256d(wallet.chain.ctype + desc))
self.fname = h[0:32] + '-%d.own' % change_idx
self.salt = h[32:]
self.count = 0
self.hdr = None
+ self.fd = None
self.peek()
@@ -64,9 +66,6 @@ def nice_name(self):
rv += ' (change)'
return rv
- def exists(self):
- return bool(self.count)
-
def peek(self):
# see what we have on-disk; just reads header.
try:
@@ -80,7 +79,7 @@ def peek(self):
except OSError:
return
except Exception as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
self.count = 0
self.hdr = None
return
@@ -104,15 +103,14 @@ def setup(self, change_idx, start_idx):
self.fd.write(hdr)
def append(self, addr):
- if addr is None:
- # close file, done
- self.fd.close()
- del self.fd
- return
-
- assert '_' not in addr
self.fd.write(encode_addr(addr, self.salt))
+ def close(self):
+ # close file, done
+ if self.fd is not None:
+ self.fd.close()
+ self.fd = None
+
def fast_search(self, addr):
# Do the easy part of the searching, using the existing file's contents.
# - generates candidate path subcomponents; might be false positive
@@ -120,6 +118,7 @@ def fast_search(self, addr):
from glob import dis
if not self.hdr or not self.count:
+ # cache empty
return
with open(self.fname, 'rb') as fd:
@@ -131,7 +130,7 @@ def fast_search(self, addr):
chk = encode_addr(addr, self.salt)
for idx in range(self.count):
if buf[idx*HASH_ENC_LEN : (idx*HASH_ENC_LEN)+HASH_ENC_LEN] == chk:
- yield (self.change_idx, idx)
+ yield self.change_idx, idx
dis.progress_sofar(idx, self.count)
@@ -147,93 +146,114 @@ def build_and_search(self, addr):
# - return subpath for a hit or None
from glob import dis
- bonus = 0
match = None
start_idx = self.count
count = MAX_ADDRS_STORED - start_idx
if count <= 0:
- return None
+ return match
self.setup(self.change_idx, start_idx)
- for idx,here,*_ in self.wallet.yield_addresses(start_idx, count,
- change_idx=self.change_idx):
-
- if here == addr:
- # Found it! But keep going a little for next time.
- match = (self.change_idx, idx)
-
+ bonus = None
+ for idx,here,*_ in self.wallet.yield_addresses(start_idx, count, self.change_idx):
self.append(here)
self.count += 1
- if match:
+
+ if bonus:
+ if bonus >= BONUS_AFTER_MATCH:
+ # do (at most) 20 more - limited by 'start_idx' & 'count'
+ break
bonus += 1
- if match and bonus >= BONUS_GAP_LIMIT:
- self.append(None)
- return match
- dis.progress_sofar(idx-start_idx, count)
+ if here == addr:
+ # match but keep going
+ match = (self.change_idx, idx)
+ bonus = 1
- self.append(None)
+ dis.progress_sofar(idx - start_idx, count)
- return None
+ self.close()
+ return match
class OwnershipCache:
@classmethod
- def saver(cls, wallet, change_idx, start_idx):
- # when we are generating many addresses for export, capture them
+ def saver(cls, wallet, change_idx, start_idx, count):
+ # when we are generating many addresses for export, capture them (if suitable)
# as we go with this function
- # - not change -- only main addrs
+ if not count:
+ return
+ if change_idx not in (0, 1):
+ return
+ if start_idx >= MAX_ADDRS_STORED:
+ return
+
file = AddressCacheFile(wallet, change_idx)
+ current_pos = file.count
+
+ if start_idx > current_pos:
+ # nothing to do here, we are missing some addresses in the middle
+ return
+ if (start_idx + count) <= current_pos:
+ # we already have all these addresses
+ return
- if file.exists():
- # don't save to existing file, has some already
- return None
+ file.setup(change_idx, current_pos)
- try:
- file.setup(change_idx, start_idx)
- except:
- # in some cases we don't want to save anything, not an error
- return None
+ def doit(addr, idx):
+ if addr is None:
+ file.close()
+ elif (idx < MAX_ADDRS_STORED) and idx >= current_pos:
+ file.append(addr)
- return file.append
+ return doit
@classmethod
- def search(cls, addr):
- # Find it!
- # - returns wallet object, and tuple2 of final 2 subpath components
+ def filter(cls, addr, args):
+ # Filter possible candidates!
# - if you start w/ testnet, we'll follow that
- from multisig import MultisigWallet
- from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH
+ from wallet import MiniScriptWallet
from glob import dis
ch = chains.current_chain()
+ args = args or {}
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 = []
+ # user has specified specific (named) wallet
+ named_wal = args.get("wallet", None)
+ if named_wal:
+ # quick search without deserialization
+ res = list(MiniScriptWallet.iter_wallets(name=named_wal))
+ if not res:
+ raise UnknownAddressExplained("Wallet '%s' not defined." % named_wal)
- if addr_fmt & AFC_SCRIPT:
- # multisig or script at least.. must exist already
- possibles.extend(MultisigWallet.iter_wallets(addr_fmt=addr_fmt))
+ # only return desired named wallet, no other wallets are searched
+ return res
+ possibles = []
+ if addr_fmt == AF_P2TR:
+ possibles.extend([w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2TR])
+ if addr_fmt & AFC_SCRIPT:
+ # multisig or script at least... must exist already
+ afs = [addr_fmt]
if addr_fmt == AF_P2SH:
# might look like P2SH but actually be AF_P2WSH_P2SH
- possibles.extend(MultisigWallet.iter_wallets(addr_fmt=AF_P2WSH_P2SH))
+ # wrapped segwit is more used than legacy
+ afs = [AF_P2WSH_P2SH, AF_P2SH]
# Might be single-sig p2wpkh wrapped in p2sh ... but that was a transition
# thing that hopefully is going away, so if they have any multisig wallets,
# defined, assume that that's the only p2sh address source.
addr_fmt = AF_P2WPKH_P2SH
- # TODO: add tapscript and such fancy stuff here
+ possibles.extend(MiniScriptWallet.iter_wallets(addr_fmts=afs))
try:
# Construct possible single-signer wallets, always at least account=0 case
@@ -247,89 +267,138 @@ def search(cls, addr):
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
raise UnknownAddressExplained(
- "No suitable multisig wallets are currently defined.")
-
- # "quick" check first, before doing any generations
+ "No suitable multisig/miniscript wallets are currently defined.")
- count = 0
- phase2 = []
- for change_idx in (0, 1):
- files = [AddressCacheFile(w, change_idx) for w in possibles]
- for f in files:
- if dis.has_lcd:
- dis.fullscreen('Searching wallet(s)...', line2=f.nice_name())
- else:
- dis.fullscreen('Searching...')
+ # ordering here
+ return possibles
- for maybe in f.fast_search(addr):
- ok = f.check_match(addr, maybe)
- if not ok: continue # false positive - will happen
-
- # found winner.
- return f.wallet, maybe
-
- if f.count < MAX_ADDRS_STORED:
- phase2.append(f)
+ @classmethod
+ def search_wallet_cache(cls, addr, cf):
+ # - returns wallet object, and tuple2 of final 2 subpath components
+ # "quick" check first, before doing any generations
+ # external chain first, then internal (change)
+ for maybe in cf.fast_search(addr):
+ ok = cf.check_match(addr, maybe)
+ if ok:
+ return cf.wallet, maybe
+ return None, None
- count += f.count
+ @classmethod
+ def search_build_wallet(cls, addr, cf):
# maybe we haven't calculated all the addresses yet, so do that
# - very slow, but only needed once; any negative (failed) search causes this
# - could stop when match found, but we go a bit beyond that for next time
# - we could search all in parallel, rather than serially because
# more likely to find a match with low index... but seen as too much memory
+ result = cf.build_and_search(addr)
+ if result:
+ # found it, so report it and stop
+ return cf.wallet, result
- for f in phase2:
- b4 = f.count
- if dis.has_lcd:
- dis.fullscreen("Generating addresses...", line2=f.nice_name())
- else:
- dis.fullscreen("Generating...")
-
- result = f.build_and_search(addr)
- if result:
- # found it, so report it and stop
- return f.wallet, result
+ # possible phase 3: other seedvault... slow, rare and not implemented
+ return None, None
- count += f.count - b4
+ @classmethod
+ def search(cls, addr, args=None):
+ from glob import dis
- # possible phase 3: other seedvault... slow, rare and not implemented
+ dis.fullscreen("Wait...")
+
+ matches = OWNERSHIP.filter(addr, args)
+
+ # build cache files for both external & internal chain
+ cachefs = []
+ for w in matches:
+ cachefs.append(AddressCacheFile(w, 0))
+ cachefs.append(AddressCacheFile(w, 1))
+
+ for cf in cachefs:
+ msg = "Searching wallet(s)..." if dis.has_lcd else "Searching..."
+ dis.fullscreen(msg, line2=cf.nice_name())
+ wallet, subpath = OWNERSHIP.search_wallet_cache(addr, cf)
+ if wallet:
+ # first arg from_cache=True
+ return True, wallet, subpath
+
+ # nothing found in existing cache files
+ c = 0
+ for cf in cachefs:
+ msg = "Generating addresses..." if dis.has_lcd else "Generating..."
+ dis.fullscreen(msg, line2=cf.nice_name())
+ wallet, subpath = OWNERSHIP.search_build_wallet(addr, cf)
+ c += cf.count
+ if wallet:
+ # first arg from_cache=False
+ return False, wallet, subpath
- raise UnknownAddressExplained('Searched %d candidates without finding a match.' % count)
+ else:
+ raise UnknownAddressExplained('Searched %d candidate addresses in %d wallet(s)'
+ ' without finding a match.' % (c, len(matches)))
@classmethod
- async def search_ux(cls, addr):
+ async def search_ux(cls, addr, args):
# 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 wallet import MiniScriptWallet
from public_constants import AFC_BECH32, AFC_BECH32M
try:
- wallet, subpath = OWNERSHIP.search(addr)
+ _, wallet, subpath = cls.search(addr, args)
+ is_complex = isinstance(wallet, MiniScriptWallet)
+
+ msg = show_single_address(addr)
+ msg += '\n\nFound in wallet:\n' + wallet.name
+
+ msg += '\n\nDerivation path:\n'
+ if hasattr(wallet, "render_path"):
+ sp = wallet.render_path(*subpath)
+ msg += sp
+ else:
+ sp = None
+ msg += ".../%d/%d" % subpath
+
+ if is_complex:
+ esc = ""
+ else:
+ esc = "0"
+ msg += "\n\nPress (0) to sign message with this key."
- msg = addr
- msg += '\n\nFound in wallet:\n ' + wallet.name
- msg += '\nDerivation path:\n ' + wallet.render_path(*subpath)
+ title = "Verified"
if version.has_qwerty:
- esc = KEY_QR
+ esc += KEY_QR
+ title += " Address"
else:
- msg += '\n\nPress (1) for QR'
- esc = '1'
+ 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 msgsign 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)))
+
@classmethod
def note_subpath_used(cls, subpath):
@@ -363,8 +432,6 @@ def note_wallet_used(cls, addr_fmt, subaccount):
# - 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/paper.py b/shared/paper.py
index 952b667f5..745f36d73 100644
--- a/shared/paper.py
+++ b/shared/paper.py
@@ -3,10 +3,10 @@
#
# paper.py - generate paper wallets, based on random values (not linked to wallet)
#
-import ujson
+import ujson, ngu, chains
from ubinascii import hexlify as b2a_hex
-from utils import imported
-from public_constants import AF_CLASSIC, AF_P2WPKH
+from utils import imported, problem_file_line
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR
from ux import ux_show_story, ux_dramatic_pause
from files import CardSlot, CardMissingError, needs_microsd
from actions import file_picker
@@ -29,10 +29,6 @@
SECP256K1_ORDER = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xba\xae\xdc\xe6\xaf\x48\xa0\x3b\xbf\xd2\x5e\x8c\xd0\x36\x41\x41"
-# Aprox. time of this feature release (Nov 20/2019) so no need to scan
-# blockchain earlier than this during "importmulti"
-FEATURE_RELEASE_TIME = const(1574277000)
-
# These very-specific text values are matched on the Coldcard; cannot be changed.
class placeholders:
addr = b'ADDRESS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # 37 long
@@ -51,6 +47,12 @@ def __init__(self, my_menu):
self.my_menu = my_menu
self.template_fn = None
self.is_segwit = False
+ self.is_taproot = False
+
+ def atype(self):
+ if self.is_taproot: return 2, 'Taproot P2TR'
+ if self.is_segwit: return 1, 'Segwit P2WPKH'
+ return 0, 'Classic P2PKH'
async def pick_template(self, *a):
fn = await file_picker(suffix='.pdf', min_size=20000, taster=template_taster,
@@ -62,17 +64,17 @@ async def pick_template(self, *a):
def addr_format_chooser(self, *a):
# simple bool choice
def set(idx, text):
- self.is_segwit = bool(idx)
+ self.is_segwit = idx == 1
+ self.is_taproot = idx == 2
self.update_menu()
- return int(self.is_segwit), ['Classic P2PKH', 'Segwit P2WPKH'], set
+ return self.atype()[0], ['Classic P2PKH', 'Segwit P2WPKH', 'Taproot P2TR'], set
def update_menu(self):
# Reconstruct the menu contents based on our state.
self.my_menu.replace_items([
MenuItem("Don't make PDF" if not self.template_fn else 'Making PDF',
f=self.pick_template),
- MenuItem('Classic P2PKH' if not self.is_segwit else 'Segwit P2WPKH',
- chooser=self.addr_format_chooser),
+ MenuItem(self.atype()[1], chooser=self.addr_format_chooser),
MenuItem('Use Dice', f=self.use_dice),
MenuItem('GENERATE WALLET', f=self.doit),
], keep_position=True)
@@ -83,7 +85,7 @@ async def doit(self, *a, have_key=None):
try:
import ngu
- from auth import write_sig_file
+ from msgsign import write_sig_file
from chains import current_chain
from serializations import hash160
from stash import blank_object
@@ -104,12 +106,16 @@ async def doit(self, *a, have_key=None):
dis.fullscreen("Rendering...")
# make payment address
- digest = hash160(pubkey)
- ch = current_chain()
+ ch = chains.current_chain()
if self.is_segwit:
- addr = ngu.codecs.segwit_encode(ch.bech32_hrp, 0, digest)
+ af = AF_P2WPKH
+ elif self.is_taproot:
+ af = AF_P2TR
+ pubkey = pubkey[1:]
else:
- addr = ngu.codecs.b58_encode(ch.b58_addr + digest)
+ af = AF_CLASSIC
+
+ addr = ch.pubkey_to_address(pubkey, af)
wif = ngu.codecs.b58_encode(ch.b58_privkey + privkey + b'\x01')
@@ -164,8 +170,10 @@ async def doit(self, *a, have_key=None):
else:
nice_pdf = ''
- nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename,
- addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC)
+ nice_sig = None
+ if af != AF_P2TR:
+ nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename,
+ addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC)
# Half-hearted attempt to cleanup secrets-contaminated memory
# - better would be force user to reboot
@@ -178,14 +186,14 @@ async def doit(self, *a, have_key=None):
await needs_microsd()
return
except Exception as e:
- from utils import problem_file_line
- await ux_show_story('Failed to write!\n\n\n'+problem_file_line(e))
+ await ux_show_story('Failed to write!\n\n'+problem_file_line(e))
return
story = "Done! Created file(s):\n\n%s" % nice_txt
if nice_pdf:
story += "\n\n%s" % nice_pdf
- story += "\n\n%s" % nice_sig
+ if nice_sig:
+ story += "\n\n%s" % nice_sig
await ux_show_story(story)
async def use_dice(self, *a):
@@ -214,10 +222,17 @@ def make_txt(self, fp, addr, wif, privkey, qr_addr=None, qr_wif=None):
fp.write('Bitcoin Core command:\n\n')
# new hotness: output descriptors
- desc = ('wpkh(%s)' if self.is_segwit else 'pkh(%s)') % wif
- multi = ujson.dumps(dict(timestamp=FEATURE_RELEASE_TIME, desc=append_checksum(desc)))
- fp.write(" bitcoin-cli importmulti '[%s]'\n\n" % multi)
- fp.write('# OR (more compatible, but slower)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif)
+ if self.is_taproot:
+ desc = 'tr(%s)'
+ elif self.is_segwit:
+ desc = 'wpkh(%s)'
+ else:
+ desc = 'pkh(%s)'
+ desc = desc % wif
+ descriptor = ujson.dumps(dict(timestamp="now", desc=append_checksum(desc)))
+ fp.write(" bitcoin-cli importdescriptors '[%s]'\n\n" % descriptor)
+ if not self.is_taproot:
+ fp.write('# OR (only supported with legacy wallets)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif)
if qr_addr and qr_wif:
fp.write('\n\n--- QR Codes --- (requires UTF-8, unicode, white background)\n\n\n\n')
diff --git a/shared/pincodes.py b/shared/pincodes.py
index e4160187f..7b6ca8118 100644
--- a/shared/pincodes.py
+++ b/shared/pincodes.py
@@ -3,8 +3,7 @@
# pincodes.py - manage PIN code (which map to wallet seeds)
#
import ustruct, ckcc, version, chains, stash
-# from ubinascii import hexlify as b2a_hex
-from callgate import enter_dfu
+from callgate import enter_dfu, get_is_bricked
from bip39 import wordlist_en
# See ../stm32/bootloader/pins.h for source of these constants.
@@ -127,17 +126,14 @@ def __init__(self):
self.private_state = 0 # opaque data, but preserve
self.cached_main_pin = bytearray(32)
+ # If set, a spending policy is in effect, and so even tho we know the master
+ # seed, we are not going to let them see it, nor sign things we dont like, etc.
+ self.hobbled_mode = False
- assert MAX_PIN_LEN == 32 # update FMT otherwise
- assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
- assert ustruct.calcsize(PIN_ATTEMPT_FMT_V2_ADDITIONS) == PIN_ATTEMPT_SIZE - PIN_ATTEMPT_SIZE_V1
-
- # check for bricked system early
- import callgate
- if callgate.get_is_bricked():
- # die right away if it's not going to work
- print("SE bricked")
- callgate.enter_dfu(3)
+ #assert MAX_PIN_LEN == 32 # update FMT otherwise
+ #assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
+ #assert ustruct.calcsize(PIN_ATTEMPT_FMT_V2_ADDITIONS) \
+ # == PIN_ATTEMPT_SIZE - PIN_ATTEMPT_SIZE_V1
def __repr__(self):
return '' % (
@@ -177,7 +173,7 @@ def marshal(self, msg, is_duress=False, is_brickme=False, new_secret=None,
old_pin = self.pin
assert len(new_pin) <= MAX_PIN_LEN
- assert old_pin != None
+ assert old_pin is not None
assert len(old_pin) <= MAX_PIN_LEN
else:
new_pin = b''
@@ -339,10 +335,6 @@ def setup(self, pin, secondary=False):
return self.state_flags
- def delay(self):
- # obsolete since Mk3, but called from login.py
- self.roundtrip(1)
-
def login(self):
# test we have the PIN code right, and unlock access if so.
chk = self.roundtrip(2)
@@ -418,9 +410,13 @@ def new_main_secret(self, raw_secret=None, chain=None, bip39pw='', blank=False,
# Main secret has changed: reset the settings+their key,
# and capture xfp/xpub
# if None is provided as raw_secret -> restore to main seed
+ import glob
from glob import settings, dis
stash.SensitiveValues.clear_cache()
+ # invalidate descriptor cache - upon new secret load
+ glob.DESC_CACHE.clear()
+
bypass_tmp = False
stash.bip39_passphrase = bool(bip39pw)
@@ -473,6 +469,7 @@ def new_main_secret(self, raw_secret=None, chain=None, bip39pw='', blank=False,
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 +480,9 @@ def tmp_secret(self, encoded, chain=None, bip39pw=''):
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
@@ -530,10 +529,24 @@ def is_deltamode(self):
from trick_pins import TC_DELTA_MODE
return bool(self.delay_required & TC_DELTA_MODE)
+
def get_tc_values(self):
# Mk4 only
# return (tc_flags, tc_arg)
return self.delay_required, self.delay_achieved
+
+ @staticmethod
+ async def enforce_brick():
+ # check for bricked system early
+ if get_is_bricked():
+ try:
+ # regardless of settings, become a forever calculator after brickage.
+ while version.has_qwerty:
+ from calc import login_repl
+ await login_repl()
+ finally:
+ # die right away if it's not going to work
+ enter_dfu(3)
# singleton
diff --git a/shared/precomp_tag_hash.py b/shared/precomp_tag_hash.py
new file mode 100644
index 000000000..7ba0a7c95
--- /dev/null
+++ b/shared/precomp_tag_hash.py
@@ -0,0 +1,12 @@
+# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# taproot precomputed tag hashes
+#
+# SHA256(TapLeaf)
+TAP_LEAF_H = b'\xae\xea\x8f\xdcB\x08\x981\x05sKX\x08\x1d\x1e&8\xd3_\x1c\xb5@\x08\xd4\xd3W\xca\x03\xbex\xe9\xee'
+# SHA256(TapBranch)
+TAP_BRANCH_H = b'\x19A\xa1\xf2\xe5n\xb9_\xa2\xa9\xf1\x94\xbe\\\x01\xf7!o3\xed\x82\xb0\x91F4\x90\xd0[\xf5\x16\xa0\x15'
+# SHA256(TapTweak)
+TAP_TWEAK_H = b'\xe8\x0f\xe1c\x9c\x9c\xa0P\xe3\xaf\x1b9\xc1C\xc6>B\x9c\xbc\xeb\x15\xd9@\xfb\xb5\xc5\xa1\xf4\xafW\xc5\xe9'
+# SHA256(TapSighash)
+TAP_SIGHASH_H = b'\xf4\nH\xdfK*p\xc8\xb4\x92K\xf2eFa\xed=\x95\xfdf\xa3\x13\xeb\x87#u\x97\xc6(\xe4\xa01'
\ No newline at end of file
diff --git a/shared/psbt.py b/shared/psbt.py
index 6cc30b88d..422790e14 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -2,35 +2,48 @@
#
# psbt.py - understand PSBT file format: verify and generate them
#
+import stash, gc, history, sys, ngu, ckcc, version, chains
+from ucollections import OrderedDict
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
-from utils import xfp2str, B2A, keypath_to_str, problem_file_line
+from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length, problem_file_line
from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str
-import stash, gc, history, sys, ngu, ckcc, chains
from uhashlib import sha256
from uio import BytesIO
+from charcodes import KEY_ENTER
from sffile import SizerFile
-from multisig import MultisigWallet, disassemble_multisig, disassemble_multisig_mn
+from chains import taptweak, tapleaf_hash, NLOCK_IS_TIME, AF_TO_STR_AF
+from wallet import MiniScriptWallet, TRUST_PSBT, TRUST_VERIFY
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
-from serializations import ser_compact_size, deser_compact_size, hash160, hash256
-from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, ser_uint256, COutPoint
+from serializations import ser_compact_size, deser_compact_size, hash160
+from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import ser_sig_der, uint256_from_str, ser_push_data
from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY
-from serializations import ALL_SIGHASH_FLAGS
+from serializations import ALL_SIGHASH_FLAGS, SIGHASH_DEFAULT
+from opcodes import OP_CHECKMULTISIG, OP_RETURN
from glob import settings
+from precomp_tag_hash import TAP_TWEAK_H, TAP_SIGHASH_H
from public_constants import (
PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
PSBT_IN_PARTIAL_SIG, PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT,
PSBT_IN_WITNESS_SCRIPT, PSBT_IN_BIP32_DERIVATION, PSBT_IN_FINAL_SCRIPTSIG,
PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT,
- PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION,
+ PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_TAP_BIP32_DERIVATION, PSBT_OUT_TAP_INTERNAL_KEY,
+ PSBT_IN_TAP_BIP32_DERIVATION, PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_KEY_SIG, PSBT_OUT_TAP_TREE,
+ PSBT_IN_TAP_MERKLE_ROOT, PSBT_IN_TAP_LEAF_SCRIPT, PSBT_IN_TAP_SCRIPT_SIG,
+ TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK,
+ PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION,
PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT,
PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID,
PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME,
- PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS
+ PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_SIGNERS,
+ AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH,
+ AFC_SEGWIT, AF_BARE_PK
)
+psbt_tmp256 = bytearray(256)
+
# PSBT proprietary keytype
PSBT_PROPRIETARY = const(0xFC)
@@ -91,6 +104,17 @@ def _skip_n_objs(fd, n, cls):
return rv
+def disassemble_multisig_mn(redeem_script):
+ # pull out just M and N from script. Simple, faster, no memory.
+
+ if not redeem_script or (redeem_script[-1] != OP_CHECKMULTISIG):
+ return None, None
+
+ M = redeem_script[0] - 80
+ N = redeem_script[-2] - 80
+
+ return M, N
+
def calc_txid(fd, poslen, body_poslen=None):
# Given the (pos,len) of a transaction in a file, return the txid for that txn.
# - doesn't validate data
@@ -201,48 +225,65 @@ def parse(self, fd):
if ks is None: break
if ks == 0: break
+ key_pos = fd.tell() + 1 # first element is ktype
+
key = fd.read(ks)
vs = deser_compact_size(fd)
- assert vs != None, 'eof'
+ assert vs is not None, 'eof'
kt = key[0]
if kt in self.no_keys:
- assert len(key) == 1 # not expecting key
+ assert len(key) == 1 # not expecting key
# storing offset and length only! Mostly.
if kt in self.short_values:
actual = fd.read(vs)
-
self.store(kt, bytes(key), actual)
else:
# skip actual data for now
# TODO: could this be stored more compactly?
proxy = (fd.tell(), vs)
fd.seek(vs, 1)
+ # store just coords for both key & val
+ if kt == PSBT_PROPRIETARY:
+ ident, subtype, _ = decode_prop_key(key[1:])
+ # examine only Coinkite proprietary keys
+ if (ident == PSBT_PROP_CK_ID) and (subtype == PSBT_ATTESTATION_SUBTYPE):
+ # prop key for attestation does not have keydata because the
+ # value is a recoverable signature (already contains pubkey)
+ # just save what we can handle
+ self.attestation = proxy
+
+ self.store(kt, (key_pos, ks-1), proxy)
+
+ def coord_write(self, out_fd, val, ktype=None):
+ pos, ll = val
+ if ktype is None:
+ out_fd.write(ser_compact_size(ll))
+ else:
+ out_fd.write(ser_compact_size(ll+1))
+ out_fd.write(bytes([ktype]))
- self.store(kt, bytes(key), proxy)
+ self.fd.seek(pos)
+ while ll:
+ t = self.fd.read(min(64, ll))
+ out_fd.write(t)
+ ll -= len(t)
def write(self, out_fd, ktype, val, key=b''):
# serialize helper: write w/ size and key byte
- out_fd.write(ser_compact_size(1 + len(key)))
- out_fd.write(bytes([ktype]) + key)
+ if isinstance(key, tuple):
+ self.coord_write(out_fd, key, ktype)
+ else:
+ out_fd.write(ser_compact_size(1 + len(key)))
+ out_fd.write(bytes([ktype]) + key)
if isinstance(val, tuple):
- (pos, ll) = val
- out_fd.write(ser_compact_size(ll))
- self.fd.seek(pos)
- while ll:
- t = self.fd.read(min(64, ll))
- out_fd.write(t)
- ll -= len(t)
-
- elif isinstance(val, list):
- # for subpaths lists (LE32 ints)
- assert ktype in (PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION)
- out_fd.write(ser_compact_size(len(val) * 4))
- for i in val:
- out_fd.write(pack(' [xfp, *path]
- # - will be single entry for non-p2sh ins and outs
-
- if not self.subpaths:
- return 0
+ def parse_xfp_path(self, coords):
+ # coords are expected to be value from subpaths or taproot subpaths
+ return list(unpack_from('<%dI' % (coords[1] // 4), self.get(coords)))
- if self.num_our_keys != None:
- # already been here once
- return self.num_our_keys
+ def handle_zero_xfp(self, xfp_path, my_xfp, warnings=None):
+ # Tricky & Useful: if xfp of zero is observed in file, assume that's a
+ # placeholder for my XFP value. Replace on the fly. Great when master
+ # XFP is unknown because PSBT built from derived XPUB only. Also privacy.
+ if xfp_path[0] == 0:
+ xfp_path[0] = my_xfp
- num_ours = 0
- for pk in self.subpaths:
- assert len(pk) in {33, 65}, "hdpath pubkey len"
+ if warnings is not None:
+ if not any(True for k, _ in warnings if 'XFP' in k):
+ warnings.append(('Zero XFP',
+ 'Assuming XFP of zero should be replaced by correct XFP'))
+ return xfp_path
+
+ def parse_taproot_subpaths(self, my_xfp, warnings, cosign_xfp=None):
+ my_sp_idxs = []
+ parsed_subpaths = OrderedDict()
+ for i in range(len(self.taproot_subpaths)):
+ key, val = self.taproot_subpaths[i]
+ assert key[1] == 32 # "PSBT_IN_TAP_BIP32_DERIVATION xonly-pubkey length != 32"
+ xonly_pk = self.get(key)
+ pos, length = val
+ end_pos = pos + length
+ self.fd.seek(pos)
+ leaf_hash_len = deser_compact_size(self.fd)
+ if leaf_hash_len:
+ self.fd.seek(32*leaf_hash_len, 1)
+ else:
+ self.ik_idx = i
+
+ curr_pos = self.fd.tell()
+ # this position is where actual xfp+path starts
+ # save it for faster access
+ to_read = end_pos - curr_pos
+ self.taproot_subpaths[i] = (key, (val[0], val[1], (curr_pos, to_read)))
+ # internal key is allowed to go from master
+ # unspendable path can be just a bare xonly pubkey
+ allow_master = True if not leaf_hash_len else False
+ validate_derivation_path_length(to_read, allow_master=allow_master)
+ v = self.fd.read(to_read)
+ here = list(unpack_from('<%dI' % (to_read // 4), v))
+ here = self.handle_zero_xfp(here, my_xfp, warnings)
+ parsed_subpaths[xonly_pk] = [leaf_hash_len] + here
+ if (here[0] == my_xfp) or (cosign_xfp and (here[0] == cosign_xfp)):
+ my_sp_idxs.append(i)
+
+ if my_sp_idxs:
+ self.sp_idxs = my_sp_idxs
+
+ return parsed_subpaths
+
+ def parse_non_taproot_subpaths(self, my_xfp, warnings, cosign_xfp=None):
+ parsed_subpaths = OrderedDict()
+ my_sp_idxs = []
+ for i, (key, val) in enumerate(self.subpaths):
+ # len pubkey 33 + 1 byte PSBT keys specifier
+ assert key[1] in {33, 65}, "hdpath pubkey len"
+ pk = self.get(key)
if len(pk) == 33:
assert pk[0] in {0x02, 0x03}, "uncompressed pubkey"
- vl = self.subpaths[pk][1]
-
- # force them to use a derived key, never the master
- assert vl >= 8, 'too short key path'
- assert (vl % 4) == 0, 'corrupt key path'
- assert (vl//4) <= MAX_PATH_DEPTH, 'too deep'
-
+ validate_derivation_path_length(val[1])
# promote to a list of ints
- v = self.get(self.subpaths[pk])
- here = list(unpack_from('<%dI' % (vl//4), v))
-
- # Tricky & Useful: if xfp of zero is observed in file, assume that's a
- # placeholder for my XFP value. Replace on the fly. Great when master
- # XFP is unknown because PSBT built from derived XPUB only. Also privacy.
- if here[0] == 0:
- here[0] = my_xfp
- if not any(True for k,_ in warnings if 'XFP' in k):
- warnings.append(('Zero XFP',
- 'Assuming XFP of zero should be replaced by correct XFP'))
+ here = self.parse_xfp_path(val)
+ here = self.handle_zero_xfp(here, my_xfp, warnings)
- # update in place
- self.subpaths[pk] = here
+ parsed_subpaths[pk] = here
+ if (here[0] == my_xfp) or (cosign_xfp and (here[0] == cosign_xfp)):
+ my_sp_idxs.append(i)
- if here[0] == my_xfp:
- num_ours += 1
- else:
- # Address that isn't based on my seed; might be another leg in a p2sh,
- # or an input we're not supposed to be able to sign... and that's okay.
- pass
+ # else:
+ # Address that isn't based on my seed; might be another leg in a p2sh,
+ # or an input we're not supposed to be able to sign... and that's okay.
- self.num_our_keys = num_ours
- return num_ours
+ if my_sp_idxs:
+ self.sp_idxs = my_sp_idxs
+ return parsed_subpaths
+ def parse_subpaths(self, my_xfp, warnings, cosign_xfp=None):
+ # - creates dictionary: pubkey => [xfp, *path] (self.subpaths)
+ # - creates dictionary: pubkey => [leaf_hash_list, xfp, *path] (self.taproot_subpaths)
+ if self.taproot_subpaths:
+ return self.parse_taproot_subpaths(my_xfp, warnings, cosign_xfp)
+ elif self.subpaths:
+ return self.parse_non_taproot_subpaths(my_xfp, warnings, cosign_xfp)
+ #return None in/output does not have any key-path info
# Track details of each output of PSBT
#
class psbtOutputProxy(psbtProxy):
- no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT }
+ no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, PSBT_OUT_TAP_INTERNAL_KEY, PSBT_OUT_TAP_TREE }
- blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script',
- 'is_change', 'num_our_keys', 'amount', 'script', 'attestation')
+ blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script', 'sp_idxs',
+ 'is_change', 'amount', 'script', 'attestation', 'proprietary',
+ 'taproot_internal_key', 'taproot_subpaths', 'taproot_tree', 'ik_idx')
def __init__(self, fd, idx):
super().__init__()
# things we track
- #self.subpaths = None # a dictionary if non-empty
+ #self.subpaths = None # a dictionary if non-empty
+ #self.taproot_subpaths = None # a dictionary if non-empty
+ #self.taproot_internal_key = None
+ #self.taproot_tree = None
+ #self.ik_idx = None # index of taproot internal key in taproot_subpaths
#self.redeem_script = None
#self.witness_script = None
#self.script = None
@@ -331,13 +413,29 @@ def __init__(self, fd, idx):
self.parse(fd)
+ # not needed
+ # def parse_taproot_tree(self):
+ # length = self.taproot_tree[1]
+ #
+ # res = []
+ # while length:
+ # tree = BytesIO(self.get(self.taproot_tree))
+ # depth = tree.read(1)
+ # leaf_version = tree.read(1)[0]
+ # assert (leaf_version & ~TAPROOT_LEAF_MASK) == 0
+ # script_len, nb = deser_compact_size(tree, ret_num_bytes=True)
+ # script = tree.read(script_len)
+ # res.append((depth, leaf_version, script))
+ # length -= (2 + nb + script_len)
+ #
+ # return res
def store(self, kt, key, val):
# do not forget that key[0] includes kt (type)
if kt == PSBT_OUT_BIP32_DERIVATION:
if not self.subpaths:
- self.subpaths = {}
- self.subpaths[key[1:]] = val
+ self.subpaths = []
+ self.subpaths.append((key,val))
elif kt == PSBT_OUT_REDEEM_SCRIPT:
self.redeem_script = val
elif kt == PSBT_OUT_WITNESS_SCRIPT:
@@ -347,26 +445,27 @@ def store(self, kt, key, val):
elif kt == PSBT_OUT_AMOUNT:
self.amount = val
elif kt == PSBT_PROPRIETARY:
- prefix, subtype, keydata = decode_prop_key(key[1:])
- # examine only Coinkite proprietary keys
- if prefix == PSBT_PROP_CK_ID:
- if subtype == PSBT_ATTESTATION_SUBTYPE:
- # prop key for attestation does not have keydata because the
- # value is a recoverable signature (already contains pubkey)
- self.attestation = self.get(val)
+ self.proprietary = self.proprietary or []
+ self.proprietary.append((key, val))
+ elif kt == PSBT_OUT_TAP_INTERNAL_KEY:
+ self.taproot_internal_key = val
+ elif kt == PSBT_OUT_TAP_BIP32_DERIVATION:
+ self.taproot_subpaths = self.taproot_subpaths or []
+ self.taproot_subpaths.append((key, val))
+ elif kt == PSBT_OUT_TAP_TREE:
+ self.taproot_tree = val
else:
- self.unknown = self.unknown or {}
- if key in self.unknown:
- raise FatalPSBTIssue("Duplicate key. Key for unknown value already provided in output.")
- self.unknown[key] = val
+ self.unknown = self.unknown or []
+ pos, length = key
+ self.unknown.append(((pos-1, length+1), val))
def serialize(self, out_fd, is_v2):
wr = lambda *a: self.write(out_fd, *a)
if self.subpaths:
- for k in self.subpaths:
- wr(PSBT_OUT_BIP32_DERIVATION, self.subpaths[k], k)
+ for k, v in self.subpaths:
+ wr(PSBT_OUT_BIP32_DERIVATION, v, k)
if self.redeem_script:
wr(PSBT_OUT_REDEEM_SCRIPT, self.redeem_script)
@@ -374,18 +473,29 @@ def serialize(self, out_fd, is_v2):
if self.witness_script:
wr(PSBT_OUT_WITNESS_SCRIPT, self.witness_script)
+ if self.taproot_internal_key:
+ wr(PSBT_OUT_TAP_INTERNAL_KEY, self.taproot_internal_key)
+
+ if self.taproot_subpaths:
+ for k, v in self.taproot_subpaths:
+ wr(PSBT_OUT_TAP_BIP32_DERIVATION, v, k)
+
+ if self.taproot_tree:
+ wr(PSBT_OUT_TAP_TREE, self.taproot_tree)
+
if is_v2:
wr(PSBT_OUT_SCRIPT, self.script)
wr(PSBT_OUT_AMOUNT, self.amount)
- if self.attestation:
- wr(PSBT_PROPRIETARY, self.attestation, encode_prop_key(PSBT_PROP_CK_ID, PSBT_ATTESTATION_SUBTYPE))
+ if self.proprietary:
+ for k, v in self.proprietary:
+ wr(PSBT_PROPRIETARY, v, k)
if self.unknown:
- for k, v in self.unknown.items():
- wr(k[0], v, k[1:])
+ for k, v in self.unknown:
+ wr(None, v, k)
- def validate(self, out_idx, txo, my_xfp, active_multisig, parent):
+ def determine_my_change(self, out_idx, txo, parsed_subpaths, parent):
# Do things make sense for this output?
# NOTE: We might think it's a change output just because the PSBT
@@ -395,141 +505,114 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent):
# any output info provided better be right, or fail as "fraud"
# - full key derivation and validation is done during signing, and critical.
# - we raise fraud alarms, since these are not innocent errors
- #
- num_ours = self.parse_subpaths(my_xfp, parent.warnings)
-
- if num_ours == 0:
- # - not considered fraud because other signers looking at PSBT may have them
- # - user will see them as normal outputs, which they are from our PoV.
- return
# - must match expected address for this output, coming from unsigned txn
- addr_type, addr_or_pubkey, is_segwit = txo.get_address()
+ af, addr_or_pubkey = txo.get_address()
- if len(self.subpaths) == 1:
- # p2pk, p2pkh, p2wpkh cases
- expect_pubkey, = self.subpaths.keys()
- else:
- # p2wsh/p2sh cases need full set of pubkeys, and therefore redeem script
- expect_pubkey = None
-
- if addr_type == 'p2pk':
- # output is public key (not a hash, much less common)
+ if (not self.sp_idxs) or (af in [OP_RETURN, None]):
+ # num_ours == 0
+ # - not considered fraud because other signers looking at PSBT may have them
+ # - user will see them as normal outputs, which they are from our PoV.
+ # OP_RETURN
+ # - nothing we can do with anchor outputs
+ # UNKNOWN
+ # - scripts that we do not understand
+ return af
+
+ msc = parent.active_miniscript
+ if msc and MiniScriptWallet.disable_checks:
+ # Without validation, we have to assume all outputs
+ # will be taken from us, and are not really change.
+ return af
+
+ # certain short-cuts
+ if msc:
+ if af in [AF_CLASSIC, AF_P2WPKH, AF_BARE_PK]:
+ # signing with miniscript wallet - single sig outputs definitely not change
+ return af
+
+ elif parent.active_singlesig and (af == AF_P2WSH):
+ # we are signing single sig inputs - p2wsh is def not a change
+ return af
+
+ def fraud(idx, af, err=""):
+ raise FraudulentChangeOutput(idx, "%s change output is fraudulent\n\n%s" % (
+ AF_TO_STR_AF[af], err
+ ))
+
+ if af == AF_BARE_PK:
+ # output is compressed public key (not a hash, much less common)
+ # uncompressed public keys not supported!
assert len(addr_or_pubkey) == 33
+ assert len(parsed_subpaths) == 1
+ target, = parsed_subpaths.keys()
- if addr_or_pubkey != expect_pubkey:
- raise FraudulentChangeOutput(out_idx, "P2PK change output is fraudulent")
-
- self.is_change = True
- return
-
- # Figure out what the hashed addr should be
- pkh = addr_or_pubkey
-
- if addr_type == 'p2sh':
- # P2SH or Multisig output
-
- # Can be both, or either one depending on address type
- redeem_script = self.get(self.redeem_script) if self.redeem_script else None
- witness_script = self.get(self.witness_script) if self.witness_script else None
-
- if expect_pubkey:
- # num_ours == 1 and len(subpaths) == 1, single sig, we only allow p2sh-p2wpkh
- if not redeem_script:
- # Perhaps an omission, so let's not call fraud on it
- # But definately required, else we don't know what script we're sending to.
- raise FatalPSBTIssue("Missing redeem script for output #%d" % out_idx)
-
- target_spk = bytes([0xa9, 0x14]) + hash160(redeem_script) + bytes([0x87])
- if not is_segwit and len(redeem_script) == 22 and \
- redeem_script[0] == 0 and redeem_script[1] == 20 and \
- txo.scriptPubKey == target_spk:
- # it's actually segwit p2wpkh inside p2sh
- pkh = redeem_script[2:22]
- expect_pkh = hash160(expect_pubkey)
- else:
- # unknown or wrong script
- # p2sh-p2pkh also fall into this category
- expect_pkh = None
-
+ elif af in (AF_CLASSIC, AF_P2WPKH):
+ # P2PKH & P2WPKH (public key has, whether witness v0 or legacy)
+ # input is hash160 of a single public key
+ assert len(addr_or_pubkey) == 20
+ assert len(parsed_subpaths) == 1
+ target, = parsed_subpaths.keys()
+ target = hash160(target)
+
+ elif af in (AF_P2SH, AF_P2WSH): # both p2sh & p2wsh covered here
+ if msc:
+ # scriptPubkey can be compared against script that we build
+ # if exact match change if not - not change
+ # no need for redeem/witness script
+ # for instance liana & core do not provide witness/redeem
+ try:
+ xfp_paths = list(parsed_subpaths.values())
+ # if subpaths do not match, it is not desired wallet - so no change
+ # but also not a fraud
+ if msc.matching_subpaths(xfp_paths):
+ msc.validate_script_pubkey(txo.scriptPubKey, xfp_paths)
+ self.is_change = True
+ except AssertionError as e:
+ # sys.print_exception(e)
+ fraud(out_idx, af, e)
+ return af
+
+ # we do not have active miniscript - must be single sig otherwise, not a change
+ if len(parsed_subpaths) == 1 and (af == AF_P2SH):
+ expect_pubkey, = parsed_subpaths.keys()
+ target_spk, _ = chains.current_chain().script_pubkey(AF_P2WPKH_P2SH,
+ pubkey=expect_pubkey)
+ af = AF_P2WPKH_P2SH
+ if txo.scriptPubKey != target_spk:
+ fraud(out_idx, af, "spk mismatch")
+ # it's actually segwit p2wpkh inside p2sh
+ target = target_spk[2:-1]
else:
- # Multisig change output, for wallet we're supposed to be a part of.
- # - our key must be part of it
- # - must look like input side redeem script (same fingerprints)
- # - assert M/N structure of output to match any inputs we have signed in PSBT!
- # - assert all provided pubkeys are in redeem script, not just ours
- # - we get all of that by re-constructing the script from our wallet details
- if not redeem_script and not witness_script:
- # Perhaps an omission, so let's not call fraud on it
- # But definately required, else we don't know what script we're sending to.
- raise FatalPSBTIssue(
- "Missing redeem/witness script for multisig output #%d" % out_idx
- )
-
- # it cannot be change if it doesn't precisely match our multisig setup
- if not active_multisig:
- # - might be a p2sh output for another wallet that isn't us
- # - not fraud, just an output with more details than we need.
- self.is_change = False
- return
-
- if MultisigWallet.disable_checks:
- # Without validation, we have to assume all outputs
- # will be taken from us, and are not really change.
- self.is_change = False
- return
+ # done, not a change, subpaths > 1 or p2wsh (and not active miniscript)
+ return af
- # redeem script must be exactly what we expect
- # - pubkeys will be reconstructed from derived paths here
- # - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor)
- # - p2sh-p2wsh needs witness script here, not redeem script value
- # - if details provided in output section, must our match multisig wallet
+ elif af == AF_P2TR:
+ if msc:
try:
- active_multisig.validate_script(witness_script or redeem_script,
- subpaths=self.subpaths)
- except BaseException as exc:
- raise FraudulentChangeOutput(out_idx,
- "P2WSH or P2SH change output script: %s" % exc)
-
- if is_segwit:
- # p2wsh case
- # - need witness script and check it's hash against proposed p2wsh value
- assert len(addr_or_pubkey) == 32
- expect_wsh = ngu.hash.sha256s(witness_script)
- if expect_wsh != addr_or_pubkey:
- raise FraudulentChangeOutput(out_idx, "P2WSH witness script has wrong hash")
-
- self.is_change = True
- return
-
- if witness_script:
- # p2sh-p2wsh case (because it had witness script)
- expect_rs = b'\x00\x20' + ngu.hash.sha256s(witness_script)
-
- if redeem_script and expect_rs != redeem_script:
- # iff they provide a redeeem script, then it needs to match
- # what we expect it to be
- raise FraudulentChangeOutput(out_idx,
- "P2SH-P2WSH redeem script provided, and doesn't match")
-
- expect_pkh = hash160(expect_rs)
- else:
- # old BIP-16 style; looks like payment addr
- expect_pkh = hash160(redeem_script)
-
- elif addr_type == 'p2pkh':
- # input is hash160 of a single public key
- assert len(addr_or_pubkey) == 20
- expect_pkh = hash160(expect_pubkey)
- else:
- # we don't know how to "solve" this type of input
- return
+ xfp_paths = [v[1:] for v in parsed_subpaths.values() if len(v[1:]) > 1]
+ if msc.matching_subpaths(xfp_paths):
+ msc.validate_script_pubkey(txo.scriptPubKey, xfp_paths)
+ self.is_change = True
+ except AssertionError as e:
+ fraud(out_idx, af, e)
+ return af
+
+ if len(parsed_subpaths) == 1:
+ expect_pubkey, = parsed_subpaths.keys()
+ target = taptweak(expect_pubkey)
+ else:
+ # done, not a change, subpaths > 1 (and not active miniscript)
+ return af
- if pkh != expect_pkh:
- raise FraudulentChangeOutput(out_idx, "Change output is fraudulent")
+ # only basic single signature, non-miniscript scripts get here
+ assert parent.active_singlesig
+ if addr_or_pubkey != target:
+ fraud(out_idx, af)
# We will check pubkey value at the last second, during signing.
self.is_change = True
+ return af
# Track details of each input of PSBT
@@ -540,15 +623,19 @@ class psbtInputProxy(psbtProxy):
short_values = { PSBT_IN_SIGHASH_TYPE }
# only part-sigs have a key to be stored.
- no_keys = { PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE,
- PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG,
- PSBT_IN_FINAL_SCRIPTWITNESS }
+ no_keys = {PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE,
+ PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG,
+ PSBT_IN_FINAL_SCRIPTWITNESS,PSBT_IN_TAP_KEY_SIG,
+ PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_MERKLE_ROOT}
blank_flds = (
- 'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script',
- 'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', 'num_our_keys',
- 'required_key', 'scriptSig', 'amount', 'scriptCode', 'added_sig', 'previous_txid',
- 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime'
+ 'unknown', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script', 'sp_idxs',
+ 'fully_signed', 'af', 'is_miniscript', "subpaths", 'utxo', 'utxo_spk',
+ 'amount', 'previous_txid', 'part_sigs', 'added_sigs', 'prevout_idx', 'sequence',
+ 'req_time_locktime', 'req_height_locktime',
+ 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', 'use_keypath',
+ 'taproot_subpaths', 'taproot_internal_key', 'taproot_key_sig', 'tr_added_sigs',
+ 'ik_idx',
)
def __init__(self, fd, idx):
@@ -556,30 +643,35 @@ def __init__(self, fd, idx):
#self.utxo = None
#self.witness_utxo = None
- self.part_sig = {}
+ #self.part_sigs = []
+ #self.added_sigs = [] # signatures that we added (current siging session)
#self.sighash = None
- self.subpaths = {} # will typically be non-empty for all inputs
+ #self.subpaths = [] # will be empty if taproot
#self.redeem_script = None
#self.witness_script = None
# Non-zero if one or more of our signing keys involved in input
- #self.num_our_keys = None
+ #self.sp_idxs = list of indexes leading to our key in self.subpaths
# things we've learned
#self.fully_signed = False
# we can't really learn this until we take apart the UTXO's scriptPubKey
- #self.is_segwit = None
- #self.is_multisig = None
- #self.is_p2sh = False
+ #self.af = None # string representation of address format aka. script type
- #self.required_key = None # which of our keys will be used to sign input
- #self.scriptSig = None
#self.amount = None
- #self.scriptCode = None # only expected for segwit inputs
-
- # after signing, we'll have a signature to add to output PSBT
- #self.added_sig = None
+ #self.utxo_spk = None # scriptPubKey for input utxo
+
+ # === will be empty if non-taproot ===
+ # self.taproot_subpaths = {}
+ # self.taproot_internal_key = None
+ # self.taproot_key_sig = None
+ # self.taproot_merkle_root = None
+ # self.taproot_script_sigs = None
+ # self.taproot_scripts = None
+ # self.use_keypath = None # signing taproot inputs that have script path with internal key
+ # self.ik_idx = None # index of taproot internal key in taproot_subpaths
+ # ===
#self.previous_txid = None
#self.prevout_idx = None
@@ -589,6 +681,31 @@ def __init__(self, fd, idx):
self.parse(fd)
+ @property
+ def is_segwit(self):
+ return self.af & AFC_SEGWIT
+
+ def get_taproot_script_sigs(self):
+ # returns set of (xonly, script) provided via PSBT_IN_TAP_SCRIPT_SIG
+ # we do not parse control blocks (k) not needed
+ parsed_taproot_script_sigs = set()
+ for k, v in self.taproot_script_sigs or []:
+ key = self.get(k)
+ xonly, script_hash = key[:32], key[32:]
+ parsed_taproot_script_sigs.add((xonly, script_hash))
+
+ return parsed_taproot_script_sigs
+
+ def get_taproot_scripts(self):
+ # returns set of scripts provided via PSBT_IN_TAP_LEAF_SCRIPT
+ # we do not parse control blocks (k) not needed
+ t_scr = {}
+ for k, v in self.taproot_scripts or []:
+ script = self.get(v)
+ t_scr[script[:-1]] = script[-1] # only script, and script version
+
+ return t_scr
+
def has_relative_timelock(self, txin):
# https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki
SEQUENCE_LOCKTIME_DISABLE_FLAG = (1 << 31)
@@ -615,47 +732,9 @@ def has_relative_timelock(self, txin):
return is_timebased, res
- def validate(self, idx, txin, my_xfp, parent):
- # Validate this txn input: given deserialized CTxIn and maybe witness
-
- # TODO: tighten these
- if self.witness_script:
- assert self.witness_script[1] >= 30
- if self.redeem_script:
- assert self.redeem_script[1] >= 22
-
- # require path for each addr, check some are ours
-
- # rework the pubkey => subpath mapping
- self.parse_subpaths(my_xfp, parent.warnings)
-
- if self.part_sig:
- # How complete is the set of signatures so far?
- # - assuming PSBT creator doesn't give us extra data not required
- # - seems harmless if they fool us into thinking already signed; we do nothing
- # - could also look at pubkey needed vs. sig provided
- # - could consider structure of MofN in p2sh cases
- self.fully_signed = (len(self.part_sig) >= len(self.subpaths))
- else:
- # No signatures at all yet for this input (typical non multisig)
- self.fully_signed = False
-
- if self.utxo:
- # Important: they might be trying to trick us with an un-related
- # funding transaction (UTXO) that does not match the input signature we're making
- # (but if it's segwit, the ploy wouldn't work, Segwit FtW)
- # - challenge: it's a straight dsha256() for old serializations, but not for newer
- # segwit txn's... plus I don't want to deserialize it here.
- try:
- observed = uint256_from_str(calc_txid(self.fd, self.utxo))
- except:
- raise AssertionError("Trouble parsing UTXO given for input #%d" % idx)
-
- assert txin.prevout.hash == observed, "utxo hash mismatch for input #%d" % idx
-
def handle_none_sighash(self):
if self.sighash is None:
- self.sighash = SIGHASH_ALL
+ self.sighash = SIGHASH_DEFAULT if self.taproot_subpaths else SIGHASH_ALL
def has_utxo(self):
# do we have a copy of the corresponding UTXO?
@@ -713,183 +792,262 @@ def get_utxo(self, idx):
return utxo
-
- def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
+ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_subpaths, utxo):
# See what it takes to sign this particular input
# - type of script
# - which pubkey needed
- # - scriptSig value
# - also validates redeem_script when present
+ merkle_root = redeem_script = None
- self.amount = utxo.nValue
-
- if not self.subpaths or self.fully_signed:
- # without xfp+path we will not be able to sign this input
- # - okay if fully signed
- # - okay if payjoin or other multi-signer (not multisig) txn
- self.required_key = None
+ if self.af == OP_RETURN:
return
- self.is_multisig = False
- self.is_p2sh = False
- which_key = None
+ if self.af is None:
+ # If this is reached, we do not understand the output well
+ # enough to allow the user to authorize the spend, so fail hard.
+ raise FatalPSBTIssue('Unhandled scriptPubKey: ' + b2a_hex(addr_or_pubkey).decode())
+
+ if psbt.active_miniscript or psbt.active_singlesig:
+ # we have already set one of these - sow we can use some short-cuts
+ if psbt.active_miniscript and (self.af in (AF_CLASSIC, AF_P2WPKH, AF_BARE_PK)):
+ # signing with miniscript wallet - ignore single sig utxos
+ self.sp_idxs = None
+ return
+ elif psbt.active_singlesig and (self.af == AF_P2WSH):
+ # we are signing single sig inputs - ignore p2wsh utxos
+ self.sp_idxs = None
+ return
- addr_type, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- if addr_is_segwit and not self.is_segwit:
- self.is_segwit = True
+ if self.af == AF_BARE_PK:
+ # input is single compressed public key (less common)
+ # uncompressed public keys not supported!
+ assert len(addr_or_pubkey) == 33
- if addr_type == 'p2sh':
- # multisig input
- self.is_p2sh = True
+ for i, pubkey in enumerate(parsed_subpaths):
+ if pubkey == addr_or_pubkey:
+ assert i == self.sp_idxs[0]
+ break
+ else:
+ # pubkey provided is just wrong vs. UTXO
+ raise FatalPSBTIssue('Input #%d: pubkey wrong' % my_idx)
+ elif self.af in (AF_CLASSIC, AF_P2WPKH):
+ # P2PKH & P2WPKH
+ # input is hash160 of a single public key
+
+ for i, pubkey in enumerate(parsed_subpaths):
+ if hash160(pubkey) == addr_or_pubkey:
+ assert i == self.sp_idxs[0]
+ break
+ else:
+ # none of the pubkeys provided hashes to that address
+ raise FatalPSBTIssue('Input #%d: pubkey vs. address wrong' % my_idx)
+
+ elif self.af in (AF_P2WSH, AF_P2SH):
# we must have the redeem script already (else fail)
ks = self.witness_script or self.redeem_script
if not ks:
raise FatalPSBTIssue("Missing redeem/witness script for input #%d" % my_idx)
redeem_script = self.get(ks)
- self.scriptSig = redeem_script
+ native_v0 = (self.af == AF_P2WSH)
+
+ if not native_v0 and (len(redeem_script) == 22) and \
+ redeem_script[0] == 0 and redeem_script[1] == 20 and \
+ len(parsed_subpaths) == 1:
+
+ for i, pubkey in enumerate(parsed_subpaths):
+ target_spk, _ = chains.current_chain().script_pubkey(AF_P2WPKH_P2SH,
+ pubkey=pubkey)
+ if target_spk == utxo.scriptPubKey:
+ # it's actually segwit p2wpkh inside p2sh
+ self.af = AF_P2WPKH_P2SH
+ assert i == self.sp_idxs[0]
- # new cheat: psbt creator probably telling us exactly what key
- # to use, by providing exactly one. This is ideal for p2sh wrapped p2pkh
- if len(self.subpaths) == 1:
- which_key, = self.subpaths.keys()
else:
# Assume we'll be signing with any key we know
- # - limitation: we cannot be two legs of a multisig
# - but if partial sig already in place, ignore that one
- for pubkey, path in self.subpaths.items():
- if self.part_sig and (pubkey in self.part_sig):
- # pubkey has already signed, so ignore
- continue
-
- if path[0] == my_xfp:
+ self.is_miniscript = True
+ # values will always be coords for both pubkey and signature at this point
+ done_keys = set()
+ if self.part_sigs:
+ done_keys = {self.get(k) for k,_ in self.part_sigs}
+
+ for i, (pubkey, path) in enumerate(parsed_subpaths.items()):
+ if pubkey in done_keys:
+ # pubkey has already signed, so - do not sign again
+ if i in self.sp_idxs:
+ # remove from sp_idxs so we do not attempt to sign again
+ self.sp_idxs.remove(i)
+
+ elif path[0] == my_xfp:
# slight chance of dup xfps, so handle
- if not which_key:
- which_key = set()
-
- which_key.add(pubkey)
-
- if not addr_is_segwit and \
- len(redeem_script) == 22 and \
- redeem_script[0] == 0 and redeem_script[1] == 20:
- # it's actually segwit p2pkh inside p2sh
- addr_type = 'p2sh-p2wpkh'
- addr = redeem_script[2:22]
- self.is_segwit = True
+ assert i in self.sp_idxs
+
+ if self.witness_script and (not native_v0) and (self.redeem_script[1] == 34):
+ # bugfix
+ self.af = AF_P2WSH_P2SH
+ assert self.redeem_script[1] == 34
+
+ if self.af in (AF_P2WSH, AF_P2WSH_P2SH):
+ # for both P2WSH & P2SH-P2WSH
+ if not self.witness_script:
+ raise FatalPSBTIssue('Need witness script for input #%d' % my_idx)
+
+ elif self.af == AF_P2TR:
+ if len(parsed_subpaths) == 1:
+ # keyspend without a script path
+ assert self.taproot_merkle_root is None, "merkle_root should not be defined for simple keyspend"
+ assert self.ik_idx is not None
+ xonly_pubkey, lhs_path = list(parsed_subpaths.items())[0]
+ lhs, path = lhs_path[0], lhs_path[1:]
+ assert not lhs, "LeafHashes have to be empty for internal key"
+ assert self.sp_idxs[0] == 0
+ assert taptweak(xonly_pubkey) == addr_or_pubkey
else:
- # multiple keys involved, we probably can't do the finalize step
- self.is_multisig = True
+ # tapscript (is always miniscript wallet)
+ self.is_miniscript = True
- if self.witness_script and not self.is_segwit and self.is_multisig:
- # bugfix
- addr_type = 'p2sh-p2wsh'
- self.is_segwit = True
+ if self.taproot_merkle_root is not None:
+ merkle_root = self.get(self.taproot_merkle_root)
- elif addr_type == 'p2pkh':
- # input is hash160 of a single public key
- self.scriptSig = utxo.scriptPubKey
- addr = addr_or_pubkey
+ for i, (xonly_pubkey, lhs_path) in enumerate(parsed_subpaths.items()):
+ if i not in self.sp_idxs:
+ # # ignore keys that does not have correct xfp specified in PSBT
+ continue
- for pubkey in self.subpaths:
- if hash160(pubkey) == addr:
- which_key = pubkey
- break
+ lhs, path = lhs_path[0], lhs_path[1:]
+ assert path[0] == my_xfp
+ assert merkle_root is not None, "Merkle root not defined"
+ if self.ik_idx == i:
+ assert not lhs
+ output_key = taptweak(xonly_pubkey, merkle_root)
+ if output_key == addr_or_pubkey:
+ # if we find a possibility to spend keypath (internal_key) - we do keypath
+ # even though script path is available
+ self.sp_idxs = [i]
+ self.use_keypath = True
+ break # done ignoring all other possibilities
+ else:
+ internal_key = self.get(self.taproot_internal_key)
+ output_pubkey = taptweak(internal_key, merkle_root)
+ if addr_or_pubkey == output_pubkey:
+ assert i in self.sp_idxs
+
+ if self.is_miniscript:
+ if not self.sp_idxs: return
+ if psbt.active_singlesig:
+ # if we already considered single signature inputs for signing
+ # do not even consider to sign with miniscript wallet(s)
+ # maybe we removed
+ self.sp_idxs = None
+ return # required key is None
+
+ if self.af == AF_P2TR:
+ xfp_paths = [item[1:]
+ for item in parsed_subpaths.values()
+ if len(item[1:]) > 1]
else:
- # none of the pubkeys provided hashes to that address
- raise FatalPSBTIssue('Input #%d: pubkey vs. address wrong' % my_idx)
-
- elif addr_type == 'p2pk':
- # input is single public key (less common)
- self.scriptSig = utxo.scriptPubKey
- assert len(addr_or_pubkey) == 33
-
- if addr_or_pubkey in self.subpaths:
- which_key = addr_or_pubkey
+ xfp_paths = list(parsed_subpaths.values())
+
+ if psbt.active_miniscript:
+ if not MiniScriptWallet.disable_checks:
+ if not psbt.active_miniscript.matching_subpaths(xfp_paths):
+ # not input from currently selected wallet
+ self.sp_idxs = None
+ return
else:
- # pubkey provided is just wrong vs. UTXO
- raise FatalPSBTIssue('Input #%d: pubkey wrong' % my_idx)
-
- else:
- # we don't know how to "solve" this type of input
- pass
-
- if self.is_multisig and which_key:
- # We will be signing this input, so
- # - find which wallet it is or
- # - check it's the right M/N to match redeem script
-
- #print("redeem: %s" % b2a_hex(redeem_script))
- M, N = disassemble_multisig_mn(redeem_script)
- xfp_paths = list(self.subpaths.values())
- xfp_paths.sort()
-
- if not psbt.active_multisig:
- # search for multisig wallet
- wal = MultisigWallet.find_match(M, N, xfp_paths)
+ # if we do have actual script at hand, guess M/N for better matching
+ # basic multisig matching
+ M, N = disassemble_multisig_mn(redeem_script)
+ wal = MiniScriptWallet.find_match(xfp_paths, self.af, M, N)
if not wal:
- raise FatalPSBTIssue('Unknown multisig wallet')
+ # not an input from wallet that we have enrolled
+ self.sp_idxs = None
+ return
- psbt.active_multisig = wal
- else:
- # check consistent w/ already selected wallet
- psbt.active_multisig.assert_matching(M, N, xfp_paths)
+ psbt.active_miniscript = wal
- # validate redeem script, by disassembling it and checking all pubkeys
try:
- psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths)
- except BaseException as exc:
- sys.print_exception(exc)
- raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc))
-
- if not which_key and DEBUG:
- print("no key: input #%d: type=%s segwit=%d a_or_pk=%s scriptPubKey=%s" % (
- my_idx, addr_type, self.is_segwit or 0,
- b2a_hex(addr_or_pubkey), b2a_hex(utxo.scriptPubKey)))
-
- self.required_key = which_key
-
- if self.is_segwit:
- if ('pkh' in addr_type):
- # This comment from :
- #
- # Please note that for a P2SH-P2WPKH, the scriptCode is always 26
- # bytes including the leading size byte, as 0x1976a914{20-byte keyhash}88ac,
- # NOT the redeemScript nor scriptPubKey
- #
- # Also need this scriptCode for native segwit p2pkh
- #
- assert not self.is_multisig
- self.scriptCode = b'\x19\x76\xa9\x14' + addr + b'\x88\xac'
- elif not self.scriptCode:
- # Segwit P2SH. We need the witness script to be provided.
- if not self.witness_script:
- raise FatalPSBTIssue('Need witness script for input #%d' % my_idx)
-
- # "scriptCode is witnessScript preceeded by a
- # compactSize integer for the size of witnessScript"
- self.scriptCode = ser_string(self.get(self.witness_script))
-
- # Could probably free self.subpaths and self.redeem_script now, but only if we didn't
- # need to re-serialize as a PSBT.
+ # contains PSBT merkle root verification (if taproot)
+ if not MiniScriptWallet.disable_checks:
+ psbt.active_miniscript.validate_script_pubkey(self.utxo_spk,
+ xfp_paths, merkle_root)
+ except BaseException as e:
+ # sys.print_exception(e)
+ raise FatalPSBTIssue('Input #%d: %s\n\n' % (my_idx, e) + problem_file_line(e))
+
+ else:
+ # single signature utxo
+ if psbt.active_miniscript:
+ # complex wallet is active - so this is not for us to sign
+ self.sp_idxs = None
+ return
+
+ psbt.active_singlesig = True
+
+ def segwit_v0_scriptCode(self):
+ # only v0 segwit
+ # only needed for sighash
+ assert self.is_segwit and (self.af != AF_P2TR)
+ if self.af == AF_P2WPKH:
+ return b'\x19\x76\xa9\x14' + self.utxo_spk[2:2+20] + b'\x88\xac'
+ elif self.af == AF_P2WPKH_P2SH:
+ return b'\x19\x76\xa9\x14' + self.get(self.redeem_script)[2:22] + b'\x88\xac'
+ elif self.af in (AF_P2WSH, AF_P2WSH_P2SH):
+ # "scriptCode is witnessScript preceeded by a
+ # compactSize integer for the size of witnessScript"
+ return ser_string(self.get(self.witness_script))
+
+ def get_scriptSig(self):
+ if self.af in [AF_BARE_PK, AF_CLASSIC]:
+ return self.utxo_spk
+ elif self.af in (AF_P2SH, AF_P2WSH_P2SH, AF_P2WPKH_P2SH):
+ return self.get(self.redeem_script)
+ else:
+ return b""
def store(self, kt, key, val):
# Capture what we are interested in.
-
if kt == PSBT_IN_NON_WITNESS_UTXO:
self.utxo = val
elif kt == PSBT_IN_WITNESS_UTXO:
self.witness_utxo = val
elif kt == PSBT_IN_PARTIAL_SIG:
- self.part_sig[key[1:]] = val
+ # taproot inputs do not have part sigs
+ # only populate the attribute if present
+ if not self.part_sigs:
+ self.part_sigs = []
+ # do not load anything (both key and val are coordinates)
+ # actual signatures (71 bytes) we do not need them until finalization
+ # public keys are enough for validation we will get them as needed
+ self.part_sigs.append((key, val))
elif kt == PSBT_IN_BIP32_DERIVATION:
- self.subpaths[key[1:]] = val
+ if self.subpaths is None:
+ self.subpaths = []
+ self.subpaths.append((key, val))
elif kt == PSBT_IN_REDEEM_SCRIPT:
self.redeem_script = val
elif kt == PSBT_IN_WITNESS_SCRIPT:
self.witness_script = val
elif kt == PSBT_IN_SIGHASH_TYPE:
self.sighash = unpack('= 1
- xfp_paths.append(h)
+ parsed_xpubs.append((xp, h))
if h[0] == self.my_xfp:
has_mine += 1
@@ -1227,63 +1434,58 @@ async def handle_xpubs(self):
if not has_mine:
raise FatalPSBTIssue('My XFP not involved')
- candidates = MultisigWallet.find_candidates(xfp_paths)
+ # don't want to guess M if not needed, but we need it
+ af, M, N = self.guess_M_of_N()
+ if not N:
+ # not multisig, but we can still verify:
+ # - miniscript cannot be imported from PSBT (we lack descriptor in PSBT)
+ # - XFP should be one of ours (checked above).
+ # - too slow to re-derive it here, so nothing more to validate at this point
+ return
- if len(candidates) == 1:
- # exact match (by xfp+deriv set) .. normal case
- self.active_multisig = candidates[0]
- else:
- # don't want to guess M if not needed, but we need it
- M, N = self.guess_M_of_N()
+ assert N == len(self.xpubs)
- if not N:
- # not multisig, but we can still verify:
- # - XFP should be one of ours (checked above).
- # - too slow to re-derive it here, so nothing more to validate at this point
- return
+ # Validate good match here. The xpubs must be exactly right, but
+ # we're going to use our own values from setup time anyway and not trusting
+ # new values without user interaction.
+ # Check:
+ # - chain codes match what we have stored already
+ # - pubkey vs. path will be checked later
+ # - xfp+path already checked above when selecting wallet
+ # Any issue here is a fraud attempt in some way, not innocent.
+ wal = MiniScriptWallet.find_match([i[1] for i in parsed_xpubs], af, M, N)
- assert N == len(xfp_paths)
-
- for c in candidates:
- if c.M == M and c.N == N:
- self.active_multisig = c
- break
- # if not active_multisig set in this loop
- # appropriate candidate was not found
- # --> continue to import from psbt prompt
+ if wal:
+ # exact match (by xfp+deriv set) .. normal case
+ self.active_miniscript = wal
+ # now proper check should follow - matching actual master pubkeys
+ # but is it needed?, we just matched the wallet
+ # and are going to use our own data for verification anyway
+ if not self.active_miniscript.disable_checks:
+ self.active_miniscript.validate_psbt_xpubs(parsed_xpubs)
- del candidates
+ else:
+ trust_mode = MiniScriptWallet.get_trust_policy()
+ # already checked for existing import and wasn't found, so fail
+ if trust_mode == TRUST_VERIFY:
+ raise FatalPSBTIssue("XPUBs in PSBT do not match any existing wallet")
- if not self.active_multisig:
# Maybe create wallet, for today, forever, or fail, etc.
- proposed, need_approval = MultisigWallet.import_from_psbt(M, N, self.xpubs)
- if need_approval:
+ proposed = MiniScriptWallet.import_from_psbt(af, M, N, parsed_xpubs)
+ if trust_mode != TRUST_PSBT:
# do a complex UX sequence, which lets them save new wallet
from glob import hsm_active
if hsm_active:
raise FatalPSBTIssue("MS enroll not allowed in HSM mode")
- ch = await proposed.confirm_import()
- if ch != 'y':
+ approved = await proposed.confirm_import()
+ if not approved:
raise FatalPSBTIssue("Refused to import new wallet")
- self.active_multisig = proposed
- else:
- # Validate good match here. The xpubs must be exactly right, but
- # we're going to use our own values from setup time anyway and not trusting
- # new values without user interaction.
- # Check:
- # - chain codes match what we have stored already
- # - pubkey vs. path will be checked later
- # - xfp+path already checked above when selecting wallet
- # Any issue here is a fraud attempt in some way, not innocent.
- self.active_multisig.validate_psbt_xpubs(self.xpubs)
-
- if not self.active_multisig:
- # not clear if an error... might be part-way to importing, and
- # the data is optional anyway, etc. If they refuse to import,
- # we should not reach this point (ie. raise something to abort signing)
- return
+ self.active_miniscript = proposed
+
+ # must have wallet at this point
+ assert self.active_miniscript
def ux_relative_timelocks(self, tb, bb):
# visualize 10 largest timelock to user
@@ -1312,11 +1514,11 @@ def ux_relative_timelocks(self, tb, bb):
# Block height relative lock-time
if num_bb == 1:
idx, val = bb[0]
- msg = "Input %d. has relative block height timelock of %d blocks" % (
+ msg = "Input %d. has relative block height timelock of %d blocks\n" % (
idx, val
)
elif all(bb[0][1] == i[1] for i in bb):
- msg = "%d inputs have relative block height timelock of %d blocks" % (
+ msg = "%d inputs have relative block height timelock of %d blocks\n" % (
num_bb, bb[0][1]
)
else:
@@ -1334,11 +1536,11 @@ def ux_relative_timelocks(self, tb, bb):
if num_tb == 1:
idx, val = tb[0]
val = seconds2human_readable(val)
- msg = "Input %d. has relative time-based timelock of:\n %s" % (
+ msg = "Input %d. has relative time-based timelock of:\n %s\n" % (
idx, val
)
elif all(tb[0][1] == i[1] for i in tb):
- msg = "%d inputs have relative time-based timelock of:\n %s" % (
+ msg = "%d inputs have relative time-based timelock of:\n %s\n" % (
num_tb, seconds2human_readable(tb[0][1])
)
else:
@@ -1352,6 +1554,15 @@ def ux_relative_timelocks(self, tb, bb):
self.ux_notes.append(("Time-based RTL", msg))
+ def validate_unkonwn(self, obj, label):
+ # find duplicate unknown values in different PSBT parts
+ if not obj.unknown:
+ return
+
+ if len({self.get(k) for k,_ in obj.unknown}) < len(obj.unknown):
+ raise FatalPSBTIssue("Duplicate key. Key for unknown value"
+ " already provided in %s." % label)
+
async def validate(self):
# Do a first pass over the txn. Raise assertions, be terse tho because
# these messages are rarely seen. These are syntax/fatal errors.
@@ -1376,113 +1587,202 @@ async def validate(self):
assert not self.has_goc, "v0 requires exclusion of global output count"
assert not self.has_gtv, "v0 requires exclusion of global txn version"
assert self.txn, "v0 requires inclusion of global unsigned tx"
- assert self.txn[1] > 63, 'txn too short'
+ assert self.txn[1] > 61, 'txn too short'
assert self.fallback_locktime is None, "v0 requires exclusion of global fallback locktime"
assert self.txn_modifiable is None, "v0 requires exclusion of global txn modifiable"
- for idx, txo in self.output_iter():
- out = self.outputs[idx]
+ assert len(self.inputs) == self.num_inputs, 'ni mismatch'
+
+ assert self.num_outputs >= 1, 'need outputs'
+
+ self.validate_unkonwn(self, "global namespace")
+
+ inp_have_subpath = False
+ for i in self.inputs:
+ if i.subpaths or i.taproot_subpaths:
+ inp_have_subpath = True
+
if self.is_v2:
# v2 requires inclusion
- assert out.amount
- assert out.script
+ assert i.prevout_idx is not None
+ assert i.previous_txid
+ if i.req_time_locktime is not None:
+ assert i.req_time_locktime >= NLOCK_IS_TIME
+ if i.req_height_locktime is not None:
+ assert 0 < i.req_height_locktime < NLOCK_IS_TIME
else:
# v0 requires exclusion
- assert out.amount is None
- assert out.script is None
-
- # time based relative locks
- tb_rel_locks = []
- # block height based relative locks
- bb_rel_locks = []
- smallest_nsequence = 0xffffffff
- # this parses the input TXN in-place
- for idx, txin in self.input_iter():
- inp = self.inputs[idx]
+ assert i.prevout_idx is None
+ assert i.previous_txid is None
+ assert i.sequence is None
+ assert i.req_time_locktime is None
+ assert i.req_height_locktime is None
+
+ if i.witness_script:
+ assert i.witness_script[1] >= 30
+ if i.redeem_script:
+ assert i.redeem_script[1] >= 22
+
+ if i.taproot_internal_key:
+ assert i.taproot_internal_key[1] == 32 # "PSBT_IN_TAP_INTERNAL_KEY length != 32"
+
+ if i.taproot_key_sig:
+ # "PSBT_IN_TAP_KEY_SIG length != 64 or 65"
+ assert i.taproot_key_sig[1] in (64, 65)
+
+ if i.part_sigs:
+ for k, v in i.part_sigs:
+ assert k[1] == 33
+ # valid signature can also be 60 bytes or less (needs grinding)
+ # 69 bytes - where both r & s are 31 bytes
+ # 73 -> high-s & high-r
+ assert v[1] <= 73, "DER sig len"
+
+ if i.taproot_script_sigs:
+ for k, v in i.taproot_script_sigs:
+ # PSBT_IN_TAP_SCRIPT_SIG + 32 bytes xonly pubkey + leafhash 32 bytes
+ assert k[1] == 64
+ # The 64 or 65 byte Schnorr signature for this pubkey and leaf combination
+ assert v[1] in (64, 65)
+
+ if i.taproot_scripts:
+ for k, v in i.taproot_scripts:
+ assert k[1] > 32 # "PSBT_IN_TAP_LEAF_SCRIPT control block is too short"
+ assert (k[1] - 1) % 32 == 0 # "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid"
+ assert v[1] != 0 # "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty"
+
+ if i.sighash and (i.sighash not in ALL_SIGHASH_FLAGS):
+ raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % i.sighash)
+
+ self.validate_unkonwn(i, "input")
+
+ for o in self.outputs:
if self.is_v2:
# v2 requires inclusion
- assert inp.prevout_idx is not None
- assert inp.previous_txid
- if inp.req_time_locktime is not None:
- assert inp.req_time_locktime >= 500000000
- if inp.req_height_locktime is not None:
- assert 0 < inp.req_height_locktime < 500000000
+ assert o.amount
+ assert o.script
else:
# v0 requires exclusion
- assert inp.prevout_idx is None
- assert inp.previous_txid is None
- assert inp.sequence is None
- assert inp.req_time_locktime is None
- assert inp.req_height_locktime is None
-
- self.inputs[idx].validate(idx, txin, self.my_xfp, self)
- if self.txn_version >= 2:
- has_rtl = self.inputs[idx].has_relative_timelock(txin)
- if has_rtl:
- if has_rtl[0]:
- tb_rel_locks.append((idx, has_rtl[1]))
- else:
- bb_rel_locks.append((idx, has_rtl[1]))
-
- if txin.nSequence < smallest_nsequence:
- smallest_nsequence = txin.nSequence
-
- if isinstance(self.lock_time, int) and self.lock_time > 0:
- if smallest_nsequence == 0xffffffff:
- self.warnings.append((
- "Bad Locktime",
- "Locktime has no effect! None of the nSequences decremented."
- ))
- else:
- msg = "This tx can only be spent after "
- if self.lock_time < 500000000:
- msg += "block height of %d" % self.lock_time
- else:
- try:
- dt = datetime_from_timestamp(self.lock_time)
- msg += datetime_to_str(dt)
- except:
- msg += "%d (unix timestamp)" % self.lock_time
+ assert o.amount is None
+ assert o.script is None
- msg += " (MTP)" # median time past
- msg += "\n"
- self.ux_notes.append(("Abs Locktime", msg))
+ if o.taproot_internal_key:
+ assert o.taproot_internal_key[1] == 32 # "PSBT_OUT_TAP_INTERNAL_KEY length != 32"
- # create UX for users about tx level relative timelocks (nSequence)
- self.ux_relative_timelocks(tb_rel_locks, bb_rel_locks)
+ self.validate_unkonwn(o, "output")
- assert len(self.inputs) == self.num_inputs, 'ni mismatch'
+ if not inp_have_subpath:
+ # Can happen w/ Electrum in watch-mode on XPUB. It doesn't know XFP and
+ # so doesn't insert that into PSBT.
+ # or PSBT provider forgot to include subpaths
+ raise FatalPSBTIssue('PSBT inputs do not contain any key path information.')
# if multisig xpub details provided, they better be right and/or offer import
if self.xpubs:
await self.handle_xpubs()
- assert self.num_outputs >= 1, 'need outputs'
-
if DEBUG:
- our_keys = sum(1 for i in self.inputs if i.num_our_keys)
-
- print("PSBT: %d inputs, %d output, %d fully-signed, %d ours" % (
- self.num_inputs, self.num_outputs,
- sum(1 for i in self.inputs if i and i.fully_signed), our_keys))
+ print("PSBT: %d inputs, %d output" % (self.num_inputs, self.num_outputs))
- def consider_outputs(self):
+ def consider_outputs(self, len_pths, hard_p, prefix_pths, idx_max, cosign_xfp=None):
+ from glob import dis
# scan ouputs:
# - is it a change address, defined by redeem script (p2sh) or key we know is ours
# - mark change outputs, so perhaps we don't show them to users
total_out = 0
total_change = 0
+ num_op_return = 0
+ num_op_return_size = 0
+ num_unknown_scripts = 0
+ zero_val_outs = 0 # only those that are not OP_RETURN are considered
self.num_change_outputs = 0
+ validate_inp_pths = False
+ path_len = None
+ max_gap = idx_max + 200
+
+ # We aren't seeing shared input path lengths.
+ # They are probably doing weird stuff, so leave them alone
+ # and do not validate against inputs paths
+ if len(len_pths) == 1:
+ path_len = 0
+ for pl in len_pths:
+ path_len = pl
+ break
+ if path_len > 2:
+ validate_inp_pths = True
+
+ dis.fullscreen("Validating...", line2="Outputs")
+
for idx, txo in self.output_iter():
+ dis.progress_sofar(idx, self.num_outputs)
output = self.outputs[idx]
+
+ parsed_subpaths = output.parse_subpaths(self.my_xfp, self.warnings, cosign_xfp)
+
# perform output validation
- output.validate(idx, txo, self.my_xfp, self.active_multisig, self)
+ af = output.determine_my_change(idx, txo, parsed_subpaths, self)
+ assert txo.nValue >= 0, "negative output value: o%d" % idx
total_out += txo.nValue
+
+ if (txo.nValue == 0) and (af != OP_RETURN):
+ # OP_RETURN outputs have nValue=0 standard
+ zero_val_outs += 1
+
if output.is_change:
self.num_change_outputs += 1
total_change += txo.nValue
+ if validate_inp_pths:
+ # Enforce some policy on change outputs:
+ # - need to "look like" they are going to same wallet as inputs came from
+ # - range limit last two path components (numerically)
+ # - same pattern of hard/not hardened components
+ # - MAX_PATH_DEPTH already enforced before this point
+ # - (single-sig only) check ther is only 0,1 at change index
+ is_cmplx = (len(parsed_subpaths) > 1)
+ for i, xpath in enumerate(parsed_subpaths.values()):
+ if i not in output.sp_idxs: continue
+ p = xpath[2:] if output.taproot_subpaths else xpath[1:]
+
+ iss = None
+ if len(p) != path_len:
+ iss = "has wrong path length (%d not %d)" % (len(p), path_len)
+ elif tuple(bool(i & 0x80000000) for i in p) not in hard_p:
+ iss = "has different hardening pattern"
+ elif tuple(p[:-2]) not in prefix_pths:
+ iss = "goes to diff path prefix"
+ elif not is_cmplx and ((p[-2] & 0x7fffffff) not in {0,1}):
+ iss = "2nd last component not 0 or 1"
+ elif (p[-1] & 0x7fffffff) > max_gap:
+ iss = "last component beyond reasonable gap"
+
+ if iss:
+ msg = "Output#%d: %s: %s" % (idx, iss, keypath_to_str(p, skip=0))
+ if len(hard_p) == 1 and len(prefix_pths) == 1:
+ # message can be more verbose
+ # fastest way to get first element from the set
+ # without modifying the set is for-loop
+ for hp in hard_p:
+ break
+ for pp in prefix_pths:
+ break
+ msg += " not %s/{0~1}%s/{0~%d}%s expected" % (
+ keypath_to_str(pp, skip=0),
+ "'" if hp[-2] else "",
+ max_gap,
+ "'" if hp[-1] else ""
+ )
+ self.warnings.append(('Troublesome Change Outs', msg))
+
+ if af == OP_RETURN:
+ num_op_return += 1
+ if len(txo.scriptPubKey) > 83:
+ num_op_return_size += 1
+
+ elif af is None:
+ num_unknown_scripts += 1
+
if self.total_value_out is None:
self.total_value_out = total_out
else:
@@ -1496,16 +1796,16 @@ def consider_outputs(self):
'%s != %s' % (self.total_change_value, total_change)
# check fee is reasonable
- if self.total_value_out == 0:
- per_fee = 100
- else:
- the_fee = self.calculate_fee()
- if the_fee is None:
- return
- if the_fee < 0:
- raise FatalPSBTIssue("Outputs worth more than inputs!")
+ the_fee = self.calculate_fee()
+ if the_fee is None:
+ return
+ if the_fee < 0:
+ raise FatalPSBTIssue("Outputs worth more than inputs!")
+ if self.total_value_out:
per_fee = the_fee * 100 / self.total_value_out
+ else:
+ per_fee = 100
fee_limit = settings.get('fee_limit', DEFAULT_MAX_FEE_PERCENTAGE)
@@ -1516,168 +1816,175 @@ def consider_outputs(self):
self.warnings.append(('Big Fee', 'Network fee is more than '
'5%% of total value (%.1f%%).' % per_fee))
- self.consolidation_tx = (self.num_change_outputs == self.num_outputs)
-
- # Enforce policy related to change outputs
- self.consider_dangerous_change(self.my_xfp)
-
- def consider_dangerous_sighash(self):
- # Check sighash flags are legal, useful, and safe. Warn about
- # some risks if user has enabled special sighash values.
-
- sh_unusual = False
- none_sh = False
-
- for input in self.inputs:
- # only if it is our input - one that will be eventually sign
- if input.num_our_keys:
- if input.sighash is not None:
- # All inputs MUST have SIGHASH that we are able to sign.
- if input.sighash not in ALL_SIGHASH_FLAGS:
- raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % input.sighash)
-
- if input.sighash != SIGHASH_ALL:
- sh_unusual = True
-
- if input.sighash in (SIGHASH_NONE, SIGHASH_NONE|SIGHASH_ANYONECANPAY):
- none_sh = True
-
- if sh_unusual and not settings.get("sighshchk"):
- if self.consolidation_tx:
- # policy: all inputs must be sighash ALL in purely consolidation txn
- raise FatalPSBTIssue("Only sighash ALL is allowed for pure consolidation transactions.")
-
- if none_sh:
- # sighash NONE or NONE|ANYONECANPAY is proposed: block
- raise FatalPSBTIssue("Sighash NONE is not allowed as funds could be going anywhere.")
+ if (num_op_return > 1) or num_op_return_size:
+ mm = ""
+ if num_op_return > 1:
+ mm += "\nMultiple OP_RETURN outputs: %d" % num_op_return
+ if num_op_return_size:
+ mm += "\nOP_RETURN > 80 bytes"
+ self.warnings.append(
+ ("OP_RETURN",
+ "TX may not be relayed by some nodes.%s" % mm))
- if none_sh:
+ if num_unknown_scripts:
self.warnings.append(
- ("Danger", "Destination address can be changed after signing (sighash NONE).")
+ ('Output?',
+ 'Sending to %d not well understood script(s).' % num_unknown_scripts)
)
- elif sh_unusual:
+
+ if zero_val_outs:
self.warnings.append(
- ("Caution", "Some inputs have unusual SIGHASH values not used in typical cases.")
+ ('Zero Value',
+ 'Non-standard zero value output(s).')
)
- def consider_dangerous_change(self, my_xfp):
- # Enforce some policy on change outputs:
- # - need to "look like" they are going to same wallet as inputs came from
- # - range limit last two path components (numerically)
- # - same pattern of hard/not hardened components
- # - MAX_PATH_DEPTH already enforced before this point
- #
- in_paths = []
- for inp in self.inputs:
- if inp.fully_signed: continue
- if not inp.required_key: continue
- if not inp.subpaths: continue # not expected if we're signing it
- for path in inp.subpaths.values():
- if path[0] == my_xfp:
- in_paths.append(path[1:])
-
- if not in_paths:
- # We aren't adding any signatures? Can happen but we're going to be
- # showing a warning about that elsewhere.
- return
-
- shortest = min(len(i) for i in in_paths)
- longest = max(len(i) for i in in_paths)
- if shortest != longest or shortest <= 2:
- # We aren't seeing shared input path lengths.
- # They are probbably doing weird stuff, so leave them alone.
- return
-
- # Assumption: hard/not hardened depths will match for all address in wallet
- def hard_bits(p):
- return [bool(i & 0x80000000) for i in p]
-
- # Assumption: common wallets modulate the last two components only
- # of the path. Typically m/.../change/index where change is {0, 1}
- # and index changes slowly over lifetime of wallet (increasing)
- path_len = shortest
- path_prefix = in_paths[0][0:-2]
- idx_max = max(i[-1]&0x7fffffff for i in in_paths) + 200
- hard_pattern = hard_bits(in_paths[0])
-
- probs = []
- for nout, out in enumerate(self.outputs):
- if not out.is_change: continue
- # it's a change output, okay if a p2sh change; we're looking at paths
- for path in out.subpaths.values():
- if path[0] != my_xfp: continue # possible in p2sh case
-
- path = path[1:]
- if len(path) != path_len:
- iss = "has wrong path length (%d not %d)" % (len(path), path_len)
- elif hard_bits(path) != hard_pattern:
- iss = "has different hardening pattern"
- elif path[0:len(path_prefix)] != path_prefix:
- iss = "goes to diff path prefix"
- elif (path[-2]&0x7fffffff) not in {0, 1}:
- iss = "2nd last component not 0 or 1"
- elif (path[-1]&0x7fffffff) > idx_max:
- iss = "last component beyond reasonable gap"
- else:
- # looks ok
- continue
-
- probs.append("Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected"
- % (nout, iss, keypath_to_str(path, skip=0),
- keypath_to_str(path_prefix, skip=0),
- "'" if hard_pattern[-2] else "",
- idx_max, "'" if hard_pattern[-1] else "",
- ))
- break
+ self.consolidation_tx = (self.num_change_outputs == self.num_outputs)
+ dis.progress_bar_show(1)
- for p in probs:
- self.warnings.append(('Troublesome Change Outs', p))
+ if DEBUG:
+ print("PSBT change outputs: %d out of %d" % (
+ self.num_change_outputs, len(self.outputs)
+ ))
- def consider_inputs(self):
+ def consider_inputs(self, cosign_xfp=None):
# Look at the UTXO's that we are spending. Do we have them? Do the
# hashes match, and what values are we getting?
# Important: parse incoming UTXO to build total input value
+ # check nSequences & nLockTime and warn about TX level locktimes
+ from glob import dis
+
foreign = []
total_in = 0
+ presigned_inputs = set()
+ # time based relative locks
+ tb_rel_locks = []
+ # block height based relative locks
+ bb_rel_locks = []
+ smallest_nsequence = 0xffffffff
+
+ # collect some input path data from subapths
+ # later used for change outputs path validation
+ length_p = set()
+ hard_pattern = set()
+ prefix_p = set()
+ idx_max = 0
+ my_cnt = 0
+
+ dis.fullscreen("Validating...", line2="Inputs")
for i, txi in self.input_iter():
+ dis.progress_sofar(i, self.num_inputs)
inp = self.inputs[i]
- if inp.fully_signed:
- self.presigned_inputs.add(i)
+
+ if inp.part_sigs:
+ # How complete is the set of signatures so far?
+ # - assuming PSBT creator doesn't give us extra data not required
+ # - seems harmless if they fool us into thinking already signed; we do nothing
+ # - could also look at pubkey needed vs. sig provided
+ # - could consider structure of MofN in p2sh cases
+ if len(inp.part_sigs) >= len(inp.subpaths):
+ inp.fully_signed = True
+
+ if inp.taproot_key_sig:
+ inp.fully_signed = True
+
+ if inp.utxo:
+ # Important: they might be trying to trick us with an un-related
+ # funding transaction (UTXO) that does not match the input signature we're making
+ # (but if it's segwit, the ploy wouldn't work, Segwit FtW)
+ # - challenge: it's a straight dsha256() for old serializations, but not for newer
+ # segwit txn's... plus I don't want to deserialize it here.
+ try:
+ observed = uint256_from_str(calc_txid(self.fd, inp.utxo))
+ except:
+ raise AssertionError("Trouble parsing UTXO given for input #%d" % i)
+
+ assert txi.prevout.hash == observed, "utxo hash mismatch for input #%d" % i
+
+ if self.txn_version >= 2:
+ has_rtl = inp.has_relative_timelock(txi)
+ if has_rtl:
+ if has_rtl[0]:
+ tb_rel_locks.append((i, has_rtl[1]))
+ else:
+ bb_rel_locks.append((i, has_rtl[1]))
+
+ if txi.nSequence < smallest_nsequence:
+ smallest_nsequence = txi.nSequence
+
+ parsed_subpaths = inp.parse_subpaths(self.my_xfp, self.warnings, cosign_xfp)
if not inp.has_utxo():
- if inp.num_our_keys and not inp.fully_signed:
+ if inp.sp_idxs and not inp.fully_signed:
# we cannot proceed if the input is ours and there is no UTXO
raise FatalPSBTIssue('Missing own UTXO(s). Cannot determine value being signed')
- else:
- # input clearly not ours
- foreign.append(i)
- continue
- # pull out just the CTXOut object (expensive)
+ # input clearly not ours
+ foreign.append(i)
+ continue
+
+ # pull out just the CTXOut object
+ # very expensive for non-witness utxo (whole tx)
+ # less expensive for witness UTXO (just necessary TxOut)
+ #
utxo = inp.get_utxo(txi.prevout.n)
+ inp.amount = utxo.nValue
+ assert inp.amount >= 0, "negative input value: i%d" % i
+ total_in += inp.amount
+
+ inp.af, addr_or_pubkey = utxo.get_address()
+ # save scriptPubKey of utxo for later use
+ # needed for P2WPKH scriptCode calculation
+ # needed for P2PK & P2PKH scriptSig (when finalizing)
+ # needed for each input if we sign at least one P2TR input
+ inp.utxo_spk = utxo.scriptPubKey
+
+ if inp.sp_idxs:
+ my_cnt += 1
+ if inp.fully_signed:
+ presigned_inputs.add(i)
+ if inp.sp_idxs and (not inp.fully_signed):
+ # Look at what kind of input this will be, and therefore what
+ # type of signing will be required, and which key we need.
+ # - also validates redeem_script when present
+ # - also finds appropriate miniscript wallet to be used
+ inp.determine_my_signing_key(i, addr_or_pubkey, self.my_xfp, self,
+ parsed_subpaths, utxo)
+
+ # determine_my_signing_key may have removed sp_idxs
+ # meaning we're not going to sign this input - other wallet in use
+ if not inp.sp_idxs:
+ continue
- assert utxo.nValue > 0
- total_in += utxo.nValue
+ # parsed subpaths are OrderedDict - matches sp_idxs
+ for ii, xpath in enumerate(parsed_subpaths.values()):
+ if ii not in inp.sp_idxs: continue
+ p = xpath[2:] if inp.taproot_subpaths else xpath[1:]
+ length_p.add(len(p)) # ignore xfp
+ hard_pattern.add(tuple(bool(i & 0x80000000) for i in p))
+ prefix_p.add(tuple(p[:-2]))
- # Look at what kind of input this will be, and therefore what
- # type of signing will be required, and which key we need.
- # - also validates redeem_script when present
- # - also finds appropriate multisig wallet to be used
- inp.determine_my_signing_key(i, utxo, self.my_xfp, self)
+ index = p[-1] & 0x7fffffff
+ if index > idx_max:
+ idx_max = index
- # iff to UTXO is segwit, then check it's value, and also
- # capture that value, since it's supposed to be immutable
- if inp.is_segwit:
- history.verify_amount(txi.prevout, inp.amount, i)
+ # iff to UTXO is segwit, then check it's value, and also
+ # capture that value, since it's supposed to be immutable
+ if inp.af and inp.is_segwit:
+ history.verify_amount(txi.prevout, inp.amount, i)
- del utxo
+ if inp.af == AF_P2TR:
+ # based on this we know whether we can drop inp.utxo_xpk
+ # attribute after creating sighash
+ self.my_tr_in = True
- # XXX scan witness data provided, and consider those ins signed if not multisig?
+ if not my_cnt:
+ raise FatalPSBTIssue('None of the keys involved in this transaction '
+ 'belong to this Coldcard (need %s).' % xfp2str(self.my_xfp))
if not foreign:
# no foreign inputs, we can calculate the total input value
- assert total_in > 0
+ assert total_in > 0, "zero value txn"
self.total_value_in = total_in
else:
# 1+ inputs don't belong to us, we can't calculate the total input value
@@ -1687,61 +1994,113 @@ def consider_inputs(self):
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
)
- if len(self.presigned_inputs) == self.num_inputs:
- # Maybe wrong for multisig cases? Maybe they want to add their
+ if len(presigned_inputs) == self.num_inputs:
+ # Maybe wrong f cases? Maybe they want to add their
# own signature, even tho N of M is satisfied?!
raise FatalPSBTIssue('Transaction looks completely signed already?')
# We should know pubkey required for each input now.
# - but we may not be the signer for those inputs, which is fine.
# - TODO: but what if not SIGHASH_ALL
- no_keys = set(n for n,inp in enumerate(self.inputs)
- if inp.required_key == None and not inp.fully_signed)
+ no_keys = set(
+ n
+ for n,inp in enumerate(self.inputs)
+ if (not inp.sp_idxs) and (not inp.fully_signed)
+ )
+ # HWI blocker
+ # if len(no_keys) == self.num_inputs:
+ # # nothing to sign for us
+ # raise FatalPSBTIssue("Nothing to sign here")
+
if no_keys:
# This is seen when you re-sign same signed file by accident (multisig)
- # - case of len(no_keys)==num_inputs is handled by consider_keys
+ # - case of len(no_keys)==num_inputs is handled by consider_inputs
self.warnings.append(('Limited Signing',
- 'We are not signing these inputs, because we do not know the key: ' +
- seq_to_str(no_keys)))
+ "We are not signing these inputs, because we either don't know the key,"
+ " inputs belong to different wallet, or we have already signed: " + seq_to_str(no_keys)))
- if self.presigned_inputs:
+ if presigned_inputs:
# this isn't really even an issue for some complex usage cases
self.warnings.append(('Partly Signed Already',
'Some input(s) provided were already completely signed by other parties: ' +
- seq_to_str(self.presigned_inputs)))
+ seq_to_str(presigned_inputs)))
- if MultisigWallet.disable_checks:
- self.warnings.append(('Danger', 'Some multisig checks are disabled.'))
+ if isinstance(self.lock_time, int) and self.lock_time > 0:
+ if smallest_nsequence == 0xffffffff:
+ self.warnings.append((
+ "Bad Locktime",
+ "Locktime has no effect! None of the nSequences decremented."
+ ))
+ else:
+ msg = "This tx can only be spent after "
+ if self.lock_time < NLOCK_IS_TIME:
+ msg += "block height of %d" % self.lock_time
+ else:
+ try:
+ dt = datetime_from_timestamp(self.lock_time)
+ msg += datetime_to_str(dt)
+ except:
+ msg += "%d (unix timestamp)" % self.lock_time
- def calculate_fee(self):
- # what miner's reward is included in txn?
- if self.total_value_in is None:
- return None
- return self.total_value_in - self.total_value_out
+ msg += " (MTP)" # median time past
+ msg += "\n"
+ self.ux_notes.append(("Abs Locktime", msg))
- def consider_keys(self):
- # check we posess the right keys for the inputs
- cnt = sum(1 for i in self.inputs if i.num_our_keys)
- if cnt: return
+ # create UX for users about tx level relative timelocks (nSequence)
+ self.ux_relative_timelocks(tb_rel_locks, bb_rel_locks)
+
+ if MiniScriptWallet.disable_checks:
+ self.warnings.append(('Danger', 'Some miniscript checks are disabled.'))
+
+ if DEBUG:
+ print("PSBT inputs: %d inputs contain our key, %d fully-signed" % (
+ my_cnt, len(presigned_inputs)))
- # collect a list of XFP's given in file that aren't ours
- others = set()
+ dis.progress_bar_show(1)
+
+ # useful info from all our parsed paths - will be validated against change outputs
+ return length_p, hard_pattern, prefix_p, idx_max
+
+ def consider_dangerous_sighash(self):
+ # Check sighash flags are legal, useful, and safe. Warn about
+ # some risks if user has enabled special sighash values.
+ # can only be run after consider_outputs is done
+ sh_unusual = False
+ none_sh = False
for inp in self.inputs:
- if not inp.subpaths: continue
- for path in inp.subpaths.values():
- others.add(path[0])
+ if inp.sp_idxs and not inp.fully_signed:
+ if inp.sighash:
+ if inp.sighash is not None:
+ if inp.sighash not in (SIGHASH_ALL, SIGHASH_DEFAULT):
+ sh_unusual = True
- if not others:
- # Can happen w/ Electrum in watch-mode on XPUB. It doesn't know XFP and
- # so doesn't insert that into PSBT.
- raise FatalPSBTIssue('PSBT does not contain any key path information.')
+ if inp.sighash in (SIGHASH_NONE, SIGHASH_NONE | SIGHASH_ANYONECANPAY):
+ none_sh = True
- others.discard(self.my_xfp)
- msg = ', '.join(xfp2str(i) for i in others)
+ if sh_unusual and not settings.get("sighshchk"):
+ if self.consolidation_tx:
+ # policy: all inputs must be sighash ALL in purely consolidation txn
+ raise FatalPSBTIssue("Only sighash ALL/DEFAULT is allowed"
+ " for pure consolidation transactions.")
- raise FatalPSBTIssue('None of the keys involved in this transaction '
- 'belong to this Coldcard (need %s, found %s).'
- % (xfp2str(self.my_xfp), msg))
+ if none_sh:
+ # sighash NONE or NONE|ANYONECANPAY is proposed: block
+ raise FatalPSBTIssue("Sighash NONE is not allowed as funds could be going anywhere.")
+
+ if none_sh:
+ self.warnings.append(
+ ("Danger", "Destination address can be changed after signing (sighash NONE).")
+ )
+ elif sh_unusual:
+ self.warnings.append(
+ ("Caution", "Some inputs have unusual SIGHASH values not used in typical cases.")
+ )
+
+ def calculate_fee(self):
+ # what miner's reward is included in txn?
+ if self.total_value_in is None:
+ return None
+ return self.total_value_in - self.total_value_out
@classmethod
def read_psbt(cls, fd):
@@ -1802,12 +2161,12 @@ def serialize(self, out_fd, upgrade_txn=False):
wr(PSBT_GLOBAL_VERSION, pack(' single key
- which_key = inp.required_key
+ assert len(inp.sp_idxs) == 1
+ sp_idx = inp.sp_idxs[0]
-
- assert not inp.added_sig, "already done??"
- assert which_key in inp.subpaths, 'unk key'
+ assert not inp.added_sigs, "already done??"
+ assert not inp.taproot_key_sig, "already done taproot??"
- if inp.subpaths[which_key][0] != self.my_xfp:
- # we don't have the key for this subkey
- # (redundant, required_key wouldn't be set)
- continue
+ if inp.taproot_subpaths:
+ schnorrsig = True
+ pubk = inp.taproot_subpaths[sp_idx][0]
+ sp = inp.taproot_subpaths[sp_idx][1][2]
+ else:
+ pubk = inp.subpaths[sp_idx][0]
+ sp = inp.subpaths[sp_idx][1]
+ int_pth = self.handle_zero_xfp(self.parse_xfp_path(sp), self.my_xfp, None)
+ skp = keypath_to_str(int_pth)
# get node required
- skp = keypath_to_str(inp.subpaths[which_key])
node = sv.derive_path(skp, register=False)
-
# expensive test, but works... and important
pu = node.pubkey()
- assert pu == which_key, \
+ if schnorrsig:
+ pu = pu[1:]
+
+ assert pu == self.get(pubk), \
"Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx)
+ to_sign.append((node, pubk))
+
# track wallet usage
- OWNERSHIP.note_subpath_used(inp.subpaths[which_key])
+ OWNERSHIP.note_subpath_used(int_pth)
+
+ # normal operation with valid sighash
+ if not inp.is_segwit:
+ # Hash by serializing/blanking various subparts of the transaction
+ txi.scriptSig = inp.get_scriptSig()
+ digest = self.make_txn_sighash(in_idx, txi, inp.sighash)
+ else:
+ # Hash the inputs and such in totally new ways, based on BIP-143
+ if not inp.taproot_subpaths:
+ digest = self.make_txn_segwit_sighash(in_idx, txi, inp.amount,
+ inp.segwit_v0_scriptCode(),
+ inp.sighash)
+ elif not tr_sh:
+ # taproot keyspend
+ digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash)
+ # else:
+ # sighashes for tapscript spend are calculated later
if sv.deltamode:
# Current user is actually a thug with a slightly wrong PIN, so we
# do have access to the private keys and could sign txn, but we
# are going to silently corrupt our signatures.
- digest = bytes(range(32))
- else:
- if not inp.is_segwit:
- # Hash by serializing/blanking various subparts of the transaction
- digest = self.make_txn_sighash(in_idx, txi, inp.sighash)
- else:
- # Hash the inputs and such in totally new ways, based on BIP-143
- digest = self.make_txn_segwit_sighash(in_idx, txi,
- inp.amount, inp.scriptCode, inp.sighash)
-
- # The precious private key we need
- pk = node.privkey()
-
- #print("privkey %s" % b2a_hex(pk).decode('ascii'))
- #print(" pubkey %s" % b2a_hex(which_key).decode('ascii'))
- #print(" digest %s" % b2a_hex(digest).decode('ascii'))
-
- # Do the ACTUAL signature ... finally!!!
-
- # We need to grind sometimes to get a positive R
- # value that will encode (after DER) into a shorter string.
- # - saves on miner's fee (which might be expected/required)
- # - blends in with Bitcoin Core signatures which do this from 0.17.0
-
- n = 0 # retry num
- while True:
- # time to produce signature on stm32: ~25.1ms
- result = ngu.secp256k1.sign(pk, digest, n).to_bytes()
-
- if result[1] < 0x80:
- # - no need to check for low S value as those are generated by default
- # by secp256k1 lib
- # - to produce 71 bytes long signature (both low S low R values),
- # we need on average 2 retries
- # - worst case ~25 grinding iterations need to be performed total
- break
-
- n += 1
+ digest = ngu.hash.sha256d(digest)
- # DER serialization after we have low S and low R values in our signature
- r = result[1:33]
- s = result[33:65]
- der_sig = ser_sig_der(r, s, inp.sighash)
-
- # private key no longer required
- stash.blank_object(pk)
- stash.blank_object(node)
- del pk, node, pu, skp, n
-
- inp.added_sig = (which_key, der_sig)
+ # we no longer need utxo_spk if:
+ # - none of the inputs that we're signing is P2TR
+ # - this input is not P2PK or P2PKH, otherwise we need utxo_spk for scriptSig
+ if not self.my_tr_in and (inp.af not in (AF_BARE_PK, AF_CLASSIC)):
+ try:
+ del inp.utxo_spk
+ except AttributeError: pass # may not have UTXO
- # 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
+ # The precious private key we need
+ for i, (node, pk_coord) in enumerate(to_sign):
+ sk = node.privkey()
+ # Do the ACTUAL signature ... finally!!!
+ if schnorrsig:
+ kp = ngu.secp256k1.keypair(sk)
+ xonly_pk = kp.xonly_pubkey().to_bytes()
+ if tr_sh:
+ # in tapscript keys are not tweaked, just sign with the key in the script
+ taproot_script_sigs = inp.get_taproot_script_sigs()
+ inp.tr_added_sigs = inp.tr_added_sigs or {}
+
+ for taproot_script, leaf_ver in tr_sh[i]:
+ _key = (xonly_pk, tapleaf_hash(taproot_script, leaf_ver))
+ if _key in taproot_script_sigs:
+ continue # already done ?
+
+ digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash,
+ scriptpath=True,
+ script=taproot_script, leaf_ver=leaf_ver)
+
+ if sv.deltamode:
+ digest = ngu.hash.sha256d(digest)
+
+ sig = ngu.secp256k1.sign_schnorr(sk, digest, ngu.random.bytes(32))
+ # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by
+ # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed
+ if inp.sighash != SIGHASH_DEFAULT:
+ sig += bytes([inp.sighash])
+
+ # separate container for PSBT_IN_TAP_SCRIPT_SIG that we added
+ inp.tr_added_sigs[_key] = sig
+ else:
+ # BIP 341 states: "If the spending conditions do not require a script path,
+ # the output key should commit to an unspendable script path instead of having no script path.
+ # This can be achieved by computing the output key point as Q = P + int(hashTapTweak(bytes(P)))G."
+ tweak = xonly_pk
+ if inp.taproot_merkle_root and inp.use_keypath:
+ # we have a script path but internal key is spendable by us
+ # merkle root needs to be added to tweak with internal key
+ # merkle root was already verified against registered script in determine_my_signing_key
+ tweak += self.get(inp.taproot_merkle_root)
+
+ tweak = ngu.hash.sha256t(TAP_TWEAK_H, tweak, True)
+ kpt = kp.xonly_tweak_add(tweak)
+ sig = ngu.secp256k1.sign_schnorr(kpt, digest, ngu.random.bytes(32))
+ if inp.sighash != SIGHASH_DEFAULT:
+ sig += bytes([inp.sighash])
+
+ # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by
+ # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed
+ inp.taproot_key_sig = sig
+
+ del kpt
+
+ del kp
+ else:
+ der_sig = self.ecdsa_grind_sign(sk, digest, inp.sighash)
+ inp.added_sigs = inp.added_sigs or []
+ inp.added_sigs.append((pk_coord, der_sig))
- success.add(in_idx)
+ # private key no longer required
+ stash.blank_object(sk)
+ stash.blank_object(node)
+ del sk, node
- if self.is_v2:
- self.set_modifiable_flag(inp)
+ if self.is_v2:
+ self.set_modifiable_flag(inp)
- # memory cleanup
- del result, r, s
+ if drop_sighash:
+ # only drop after modifiable is set, in case of PSBTv2
+ # SIGHASH_DEFAULT if taproot
+ # SIGHASH_ALL if non-taproot
+ inp.sighash = None
+ del to_sign
gc.collect()
# done.
@@ -2092,6 +2600,108 @@ def make_txn_sighash(self, replace_idx, replacement, sighash_type):
# double SHA256
return ngu.hash.sha256s(rv.digest())
+ def make_txn_taproot_sighash(self, input_index, hash_type=SIGHASH_DEFAULT, scriptpath=False, script=None,
+ codeseparator_pos=-1, annex=None, leaf_ver=TAPROOT_LEAF_TAPSCRIPT):
+ # BIP-341
+ fd = self.fd
+ old_pos = fd.tell()
+
+ out_type = SIGHASH_ALL if (hash_type == SIGHASH_DEFAULT) else (hash_type & 3)
+ in_type = hash_type & SIGHASH_ANYONECANPAY
+
+ if not self.hashValues and in_type != SIGHASH_ANYONECANPAY:
+ hashPrevouts = sha256()
+ hashSequence = sha256()
+ hashValues = sha256()
+ hashScriptPubKeys = sha256()
+ # input side
+ for in_idx, txi in self.input_iter():
+ hashPrevouts.update(txi.prevout.serialize())
+ hashSequence.update(pack("
@@ -2176,26 +2786,153 @@ def make_txn_segwit_sighash(self, replace_idx, replacement, amount, scriptCode,
# double SHA256
return ngu.hash.sha256s(rv.digest())
+ def miniscript_input_complete(self, inp):
+ desc = self.active_miniscript.to_descriptor()
+ if desc.is_basic_multisig:
+ # we can only finalize multisig inputs from all miniscript set
+ M, N = desc.miniscript.m_n()
+ ll = 0
+ if inp.part_sigs:
+ ll += len(inp.part_sigs)
+ if inp.added_sigs:
+ ll += len(inp.added_sigs)
+ if ll >= M:
+ return True
+ return False
+
def is_complete(self):
# Are all the inputs (now) signed?
- # some might have been given as signed
- signed = len(self.presigned_inputs)
-
# plus we added some signatures
- for inp in self.inputs:
- if inp.is_multisig:
- # but we can't combine/finalize multisig stuff, so will never't be 'final'
+ for i, inp in enumerate(self.inputs):
+ if inp.fully_signed:
+ # was fully signed before (fully signed works with part_sigs only)
+ continue
+ elif inp.taproot_key_sig:
+ continue
+ elif inp.is_miniscript and self.active_miniscript:
+ if self.miniscript_input_complete(inp):
+ continue
return False
- if inp.added_sig:
- signed += 1
+ ll = len(inp.added_sigs) if inp.added_sigs else 0
+ ll += len(inp.part_sigs) if inp.part_sigs else 0
+ if inp.subpaths and (len(inp.subpaths) == ll):
+ continue
+
+ # input is not signed - and therefore tx is not complete
+ return False
+
+ return True
+
+ def multisig_signatures(self, inp):
+ assert self.active_miniscript
+ desc = self.active_miniscript.to_descriptor()
+ assert desc.is_basic_multisig
+ M, N = desc.miniscript.m_n()
+
+ # collect all signatures and parse them if some just coords
+ full_sigs = {}
+ if inp.added_sigs:
+ # what we add is always in memory (not coordinates to PSRAM)
+ for pk_coord, sig in inp.added_sigs:
+ full_sigs[self.get(pk_coord)] = sig
+
+ if inp.part_sigs:
+ # what others added is always just coordinates
+ for k, v in inp.part_sigs:
+ full_sigs[self.get(k)] = self.get(v)
+ # ===
+
+ if desc.is_sortedmulti:
+ # BIP-67 easy just sort by public keys
+ sigs = [sig for pk, sig in sorted(full_sigs.items())]
+ else:
+ # need to respect the order of keys in actual descriptor
+ sigs = []
+ for key in desc.keys:
+ for k, v in inp.subpaths:
+ pk = self.get(k)
+ xfp = self.handle_zero_xfp(self.parse_xfp_path(v), self.my_xfp, None)[0]
+ # if xfp matches but pk not in all_sigs -> signer haven't signed
+ # it is ok in threshold multisig - just skip
+ if (key.origin.cc_fp == xfp) and (pk in full_sigs):
+ sigs.append(full_sigs[pk])
+ break
+
+ # save space and only provide necessary amount of signatures (smaller tx, less fees)
+ return sigs[:M]
- return signed == self.num_inputs
+ def singlesig_signature(self, inp):
+ # return signature that we added
+ # or one signature from partial sigs if input is fully sign
+ if inp.added_sigs:
+ assert len(inp.added_sigs) == 1
+ return self.get(inp.added_sigs[0][0]), inp.added_sigs[0][1]
+
+ if inp.part_sigs:
+ assert len(inp.part_sigs) == 1
+ pk, sig = inp.part_sigs[0]
+ return self.get(pk), self.get(sig)
+
+ def miniscript_xfps_needed(self):
+ # provide the set of xfp's that still need to sign PSBT
+ # - used to find which multisig-signer needs to go next
+ rv = set()
+ done_keys = set()
+
+ for inp in self.inputs:
+ if inp.fully_signed:
+ continue
+
+ if inp.taproot_subpaths:
+ if inp.taproot_key_sig:
+ # already signed
+ continue
+
+ # only get this once for each input
+ if inp.taproot_script_sigs:
+ for xo, _ in inp.get_taproot_script_sigs():
+ done_keys.add(xo)
+
+ if inp.tr_added_sigs:
+ for (xo, _) in inp.tr_added_sigs:
+ done_keys.add(xo)
+
+ for i, (k, v) in enumerate(inp.taproot_subpaths):
+ xpk = self.get(k)
+ if inp.ik_idx == i:
+ # internal key
+ if self.active_miniscript.ik_u:
+ # no way to sign with unspend
+ continue
+ else:
+ if xpk in done_keys:
+ continue
+
+ # add xfp
+ xfp = self.handle_zero_xfp(self.parse_xfp_path(v[2]), self.my_xfp, None)[0]
+ rv.add(xfp)
+
+ else:
+ if inp.part_sigs:
+ for k, _ in inp.part_sigs:
+ done_keys.add(self.get(k))
+
+ if inp.added_sigs:
+ for k, _ in inp.added_sigs:
+ done_keys.add(self.get(k))
+
+ for k, v in inp.subpaths:
+ if self.get(k) not in done_keys:
+ xfp = self.handle_zero_xfp(self.parse_xfp_path(v), self.my_xfp, None)[0]
+ rv.add(xfp)
+
+ return rv
def finalize(self, fd):
# Stream out the finalized transaction, with signatures applied
- # - assumption is it's complete already.
+ # - raise if not complete already
# - returns the TXID of resulting transaction
# - but in segwit case, needs to re-read to calculate it
# - fd must be read/write and seekable to support txid calc
@@ -2218,29 +2955,39 @@ def finalize(self, fd):
for in_idx, txi in self.input_iter():
inp = self.inputs[in_idx]
- if inp.is_segwit:
-
- if inp.is_p2sh:
- # multisig (p2sh) segwit still requires the script here.
- txi.scriptSig = ser_string(inp.scriptSig)
+ # first check - if no signature(s) - fail soon
+ if inp.is_miniscript and not inp.use_keypath:
+ assert self.miniscript_input_complete(inp), 'Incomplete signature set on input #%d' % in_idx
+ else:
+ # single signature
+ if inp.af == AF_P2TR:
+ assert inp.taproot_key_sig, 'No signature on input #%d' % in_idx
else:
- # major win for segwit (p2pkh): no redeem script bloat anymore
- txi.scriptSig = b''
+ ssig = self.singlesig_signature(inp)
+ assert ssig, 'No signature on input #%d' % in_idx
- # Actual signature will be in witness data area
+ if inp.is_segwit:
+ # p2sh-p2wsh & p2sh-p2wpkh still need redeem here (redeem is witness scriptPubKey)
+ txi.scriptSig = inp.get_scriptSig()
+ # for p2wpkh & p2wsh inp.scriptSig is b'' (no redeem script bloat anymore) - do not ser_string
+ if txi.scriptSig:
+ txi.scriptSig = ser_string(inp.get_scriptSig())
+ # Actual signature will be in witness data area
else:
# insert the new signature(s), assuming fully signed txn.
- assert inp.added_sig, 'No signature on input #%d'%in_idx
- assert not inp.is_multisig, 'Multisig PSBT combine not supported'
-
- pubkey, der_sig = inp.added_sig
-
- s = b''
- s += ser_push_data(der_sig)
- s += ser_push_data(pubkey)
-
- txi.scriptSig = s
+ if inp.is_miniscript:
+ # p2sh multisig (non-segwit)
+ sigs = self.multisig_signatures(inp)
+ ss = b"\x00"
+ for sig in sigs:
+ ss += ser_push_data(sig)
+
+ ss += ser_push_data(self.get(inp.redeem_script))
+ txi.scriptSig = ss
+ else:
+ pubkey, der_sig = ssig
+ txi.scriptSig = ser_push_data(der_sig) + ser_push_data(pubkey)
fd.write(txi.serialize())
@@ -2261,14 +3008,25 @@ def finalize(self, fd):
for in_idx, wit in self.input_witness_iter():
inp = self.inputs[in_idx]
- if inp.is_segwit and inp.added_sig:
+ if inp.is_segwit:
# put in new sig: wit is a CTxInWitness
assert not wit.scriptWitness.stack, 'replacing non-empty?'
- assert not inp.is_multisig, 'Multisig PSBT combine not supported'
-
- pubkey, der_sig = inp.added_sig
- assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey"
- wit.scriptWitness.stack = [der_sig, pubkey]
+ if inp.taproot_key_sig:
+ # segwit v1 (taproot)
+ w = inp.taproot_key_sig
+ if isinstance(w, tuple):
+ w = self.get(w)
+ # can be 65 bytes if sighash != SIGHASH_DEFAULT (0x00)
+ assert len(w) in (64, 65)
+ wit.scriptWitness.stack = [w]
+ elif inp.is_miniscript:
+ sigs = self.multisig_signatures(inp)
+ wit.scriptWitness.stack = [b""] + sigs + [self.get(inp.witness_script)]
+ else:
+ # segwit v0
+ pubkey, der_sig = self.singlesig_signature(inp)
+ assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey"
+ wit.scriptWitness.stack = [der_sig, pubkey]
fd.write(wit.serialize())
diff --git a/shared/pwsave.py b/shared/pwsave.py
index ebc297975..81355fb49 100644
--- a/shared/pwsave.py
+++ b/shared/pwsave.py
@@ -7,6 +7,7 @@
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 @@ async def apply(menu, idx, item):
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 @@ def filename(self, card):
@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 @@ def enforce_policy(cls):
except:
# die. wrong
import callgate
- from glob import settings
settings.remove_key("sd2fa")
settings.save()
callgate.fast_wipe(silent=False)
@@ -353,8 +351,6 @@ async def enroll(self):
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 9f55d720b..6afd5e2b2 100644
--- a/shared/qrs.py
+++ b/shared/qrs.py
@@ -3,11 +3,11 @@
# qrs.py - QR Display related UX
#
import framebuf, uqr
-from ux import UserInteraction, ux_wait_keyup, the_ux
-from charcodes import (KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_HOME, KEY_NFC,
- KEY_END, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_ENTER, KEY_CANCEL)
+from ux import UserInteraction, ux_wait_keyup, the_ux
from version import has_qwerty
-
+from exceptions import QRTooBigError
+from charcodes import (KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_HOME, KEY_NFC,
+ KEY_END, KEY_ENTER, KEY_CANCEL)
# TODO: This class has a terrible API!
@@ -17,15 +17,24 @@
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, force_msg=False, allow_nfc=True, is_secret=False,
+ change_idxs=None, can_raise=True):
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
+ self.force_msg = force_msg
+ self.allow_nfc = allow_nfc
+ # only used for NFC sharing secret material - full chip wipe if is_secret=True
+ self.is_secret = is_secret
+ self.change_idxs = change_idxs or []
+ self.can_raise = can_raise
def calc_qr(self, msg):
# Version 2 would be nice, but can't hold what we need, even at min error correction,
@@ -56,6 +65,11 @@ def idx_hint(self):
# numbers, letters, etc.
return str(self.start_n + self.idx) if len(self.addrs) > 1 else None
+ def is_change(self):
+ if self.idx in self.change_idxs:
+ return True
+ return False
+
def redraw(self):
# Redraw screen.
from glob import dis
@@ -63,17 +77,36 @@ def redraw(self):
# what we are showing inside the QR
body = self.addrs[self.idx]
+ idx_hint = self.idx_hint()
+
+ msg = None
+ if self.msg:
+ msg = self.msg
+ else:
+ if isinstance(body, str):
+ # sanity check
+ msg = body
# make the QR, if needed.
if not self.qr_data:
dis.busy_bar(True)
+ try:
+ self.calc_qr(body)
+ except Exception:
+ dis.busy_bar(False)
+ if not self.can_raise:
+ dis.draw_qr_error(idx_hint, msg)
+ return
- self.calc_qr(body)
+ # other code paths require raise to switch to BBQr
+ raise QRTooBigError
# 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)
+ dis.draw_qr_display(self.qr_data, msg, self.is_alnum,
+ self.sidebar, idx_hint, self.invert,
+ is_addr=self.is_addrs, force_msg=self.force_msg,
+ is_change=self.is_change())
async def interact_bare(self):
from glob import NFC, dis
@@ -88,13 +121,15 @@ async def interact_bare(self):
self.redraw()
continue
elif NFC and (ch == '3' or ch == KEY_NFC):
- # Share any QR over NFC!
- await NFC.share_text(self.addrs[self.idx])
- self.redraw()
+ if not self.allow_nfc:
+ # not a valid as text over NFC sometimes; treat as cancel
+ break
+ else:
+ # Share any QR over NFC!
+ await NFC.share_text(self.addrs[self.idx], is_secret=self.is_secret)
+ self.redraw()
continue
elif ch in 'xy'+KEY_ENTER+KEY_CANCEL:
- if dis.has_lcd:
- dis.real_clear() # bugfix
break
elif len(self.addrs) == 1:
continue
@@ -116,6 +151,10 @@ async def interact_bare(self):
self.qr_data = None
self.redraw()
+ # bugfix
+ if dis.has_lcd:
+ dis.real_clear()
+
async def interact(self):
await self.interact_bare()
the_ux.pop()
diff --git a/shared/queues.py b/shared/queues.py
index bea6301a7..f27223fbc 100644
--- a/shared/queues.py
+++ b/shared/queues.py
@@ -72,7 +72,7 @@ def qsize(self): # Number of items in the queue.
return len(self._queue)
def empty(self): # Return True if the queue is empty, False otherwise.
- return len(self._queue) == 0
+ return not self._queue
def full(self): # Return True if there are maxsize items in the queue.
# Note: if the Queue was initialized with maxsize=0 (the default) or
diff --git a/shared/scanner.py b/shared/scanner.py
index 02dd939ce..4dd91c79c 100644
--- a/shared/scanner.py
+++ b/shared/scanner.py
@@ -201,7 +201,7 @@ async def scan_once(self):
if not rv: continue
if rv[0:2] == 'B$' and bbqr.collect(rv):
- # BBQr protocol detected; collect more data
+ # BBQr protocol detected, accepted need to collect more data
continue
break
diff --git a/shared/seed.py b/shared/seed.py
index 38627a4cf..b2a47a014 100644
--- a/shared/seed.py
+++ b/shared/seed.py
@@ -10,30 +10,46 @@
# - 'abandon' * 17 + 'agent'
# - 'abandon' * 11 + 'about'
#
-import ngu, uctypes, bip39, random, stash, version
+import ngu, uctypes, bip39, random, version, ure, chains
from ucollections import OrderedDict
from menu import MenuItem, MenuSystem
-from utils import xfp2str, parse_extended_key, swab32, pad_raw_secret, problem_file_line
+from utils import xfp2str, swab32
+from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
from uhashlib import sha256
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
-from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code
+from ux import PressRelease, ux_input_text, show_qr_code
from actions import goto_top_menu
-from stash import SecretStash, ZeroSecretException
+from stash import SecretStash, SensitiveValues
from ubinascii import hexlify as b2a_hex
from pwsave import PassphraseSaver, PassphraseSaverMenu
from glob import settings, dis
from pincodes import pa
from nvstore import SettingsObject
-from files import CardMissingError, needs_microsd, CardSlot
-from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR
-
+from files import CardMissingError, needs_microsd
+from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_NFC
+from uasyncio import sleep_ms
+from ucollections import namedtuple
# seed words lengths we support: 24=>256 bits, and recommended
VALID_LENGTHS = (24, 18, 12)
# bit flag that means "also include bare prefix as a valid word"
_PREFIX_MARKER = const(1<<26)
-
+
+# what we store (in JSON as a tuple) for each seed vault key.
+# - 'encoded' is hex, and has is trimmed of right side zeros
+VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin')
+
+def not_hobbled_mode():
+ # used as menu predicate and similar
+ return not pa.hobbled_mode
+
+def seed_vault_iter():
+ # iterate over all seeds in the vault; returns VaultEntry instances.
+ # raw vault entries are list type when json.loaded from flash
+ for lst in settings.master_get("seeds", []):
+ yield VaultEntry(*lst)
+
def letter_choices(sofar='', depth=0, thres=5):
# make a list of word completions based on indicated prefix
if not sofar:
@@ -138,23 +154,62 @@ class WordNestMenu(MenuSystem):
done_cb = None
def __init__(self, num_words=None, has_checksum=True, done_cb=commit_new_words,
- items=None, is_commit=False):
+ items=None, is_commit=False, menu_cbf=None, prefix="", words=None):
if num_words is not None:
WordNestMenu.target_words = num_words
WordNestMenu.has_checksum = has_checksum
WordNestMenu.words = []
- assert done_cb
WordNestMenu.done_cb = done_cb
is_commit = True
+ if words:
+ WordNestMenu.words = words
+
if not items:
- items = [MenuItem(i, menu=self.next_menu) for i in letter_choices()]
+ ch = letter_choices(prefix)
+ if menu_cbf:
+ items = [MenuItem(i, f=menu_cbf) for i in ch]
+ else:
+ items = [MenuItem(i, menu=self.next_menu) for i in ch]
self.is_commit = is_commit
super(WordNestMenu, self).__init__(items)
+ @classmethod
+ async def get_n_words(cls, num_words):
+ rv = []
+ for _ in range(num_words):
+ rv = await cls.get_word(rv, num_words)
+
+ return rv
+
+ @classmethod
+ async def get_word(cls, words=None, target_words=None):
+ # Just block until N words are provided. May only work before menus start?
+ from glob import numpad
+
+ async def menu_done_cbf(menu, b, c):
+ # duplicates some of the logic of next_menu
+ if c.label[-1] == '-':
+ lc = c.label[0:-1]
+ else:
+ cls.words.append(c.label)
+ numpad.abort_ux()
+ return
+
+ m = cls(prefix=lc, menu_cbf=menu_done_cbf)
+ the_ux.push(m)
+ await the_ux.interact()
+
+ m = cls(num_words=target_words, menu_cbf=menu_done_cbf, has_checksum=False, words=words)
+
+ the_ux.push(m)
+ await the_ux.interact()
+
+ return cls.words
+
@staticmethod
async def next_menu(self, idx, choice):
@@ -215,7 +270,7 @@ def pop_all(cls):
while isinstance(the_ux.top_of_stack(), cls):
the_ux.pop()
- def on_cancel(self):
+ async def on_cancel(self):
# user pressed cancel on a menu (so he's going upwards)
# - if it's a step where we added to the word list, undo that.
# - but keep them in our system until:
@@ -273,9 +328,16 @@ def late_draw(self, dis):
async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False):
- msg = (prompt or 'Record these %d secret words!\n') % len(words)
-
from ux import ux_render_words
+ from glob import NFC
+
+ if prompt:
+ title = None
+ msg = prompt
+ else:
+ m = 'Record these %d secret words!' % len(words)
+ title, msg = (m, "") if version.has_qwerty else (None, m+"\n")
+
msg += ux_render_words(words)
msg += '\n\nPlease check and double check your notes.'
@@ -283,22 +345,30 @@ async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False)
# user can skip quiz for ephemeral secrets
msg += " There will be a test!"
+ escape = (escape or '') + '1'
if not version.has_qwerty:
- escape = (escape or '') + '1'
- extra += 'Press (1) to view as QR Code. '
- else:
- escape = (escape or '') + KEY_QR
- extra += 'Press '+ KEY_QR + ' to view as QR Code. '
+ title = None
+ extra += 'Press (1) to view as QR Code'
+ if NFC:
+ extra += ", (3) to share via NFC"
+ escape += "3"
+ extra += "."
if extra:
msg += '\n\n'
msg += extra
while 1:
- ch = await ux_show_story(msg, escape=escape, sensitive=True)
- if ch == '1' or ch == KEY_QR:
- await show_qr_code(' '.join(w[0:4] for w in words), True)
+ rv = ' '.join(w[0:4] for w in words)
+ ch = await ux_show_story(msg, title=title, escape=escape, sensitive=True,
+ hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
+ if ch in ('1'+KEY_QR):
+ await show_qr_code(rv, True, is_secret=True)
+ continue
+ if NFC and (ch in "3"+KEY_NFC):
+ await NFC.share_text(rv, is_secret=True)
continue
+
break
return ch
@@ -411,27 +481,35 @@ async def new_from_dice(nwords):
await commit_new_words(words)
def in_seed_vault(encoded):
- # Test if indicated xfp (or currently active XFP) is in the seed vault already.
- seeds = settings.master_get("seeds", [])
- if seeds:
- ss = stash.SecretStash.storage_serialize(encoded)
- if ss in [s[1] for s in seeds]:
+ # Test if indicated secret is in the seed vault already.
+ hss = None
+ for rec in seed_vault_iter():
+ if not hss:
+ hss = SecretStash.storage_serialize(encoded)
+ if hss == rec.encoded:
return True
+
return False
-async def add_seed_to_vault(encoded, meta=None):
+async def add_seed_to_vault(encoded, origin=None, label=None):
if not settings.master_get("seedvault", False):
# seed vault disabled
+ # this can be re-enabled by attacker in deltamode
return
- if pa.is_secret_blank():
+ if pa.is_secret_blank() or pa.is_deltamode():
# do not save anything if no SE secret yet
+ # do not offer any access to SV in deltamode
return
# do not offer to store secrets that are already in vault
if in_seed_vault(encoded):
return
+ # stay "read only" in hobbled mode
+ if pa.hobbled_mode:
+ return
+
main_xfp = settings.master_get("xfp", 0)
# parse encoded
@@ -457,10 +535,9 @@ async def add_seed_to_vault(encoded, meta=None):
return
# Save it into master settings
- seeds.append((new_xfp_str,
- stash.SecretStash.storage_serialize(encoded),
- xfp_ui,
- meta))
+ rec = VaultEntry(xfp=new_xfp_str, encoded=SecretStash.storage_serialize(encoded),
+ label=(label or xfp_ui), origin=origin)
+ seeds.append(list(rec))
settings.master_set("seeds", seeds)
@@ -469,9 +546,10 @@ async def add_seed_to_vault(encoded, meta=None):
return True
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
- is_restore=False, meta=None):
- if not is_restore:
- await add_seed_to_vault(encoded, meta=meta)
+ is_restore=False, origin=None, label=None):
+ # Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp.
+ if not is_restore and not_hobbled_mode():
+ await add_seed_to_vault(encoded, origin=origin, label=label)
dis.fullscreen("Wait...")
applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw)
@@ -488,11 +566,11 @@ async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
return applied
-async def set_ephemeral_seed_words(words, meta):
+async def set_ephemeral_seed_words(words, origin):
dis.progress_bar_show(0.1)
encoded = seed_words_to_encoded_secret(words)
dis.progress_bar_show(0.5)
- await set_ephemeral_seed(encoded, meta=meta)
+ await set_ephemeral_seed(encoded, origin=origin)
goto_top_menu()
async def ephemeral_seed_generate_from_dice(nwords):
@@ -509,7 +587,7 @@ async def ephemeral_seed_generate_from_dice(nwords):
words = await approve_word_list(seed, nwords, ephemeral=True)
if words:
dis.fullscreen("Applying...")
- await set_ephemeral_seed_words(words, meta='Dice')
+ await set_ephemeral_seed_words(words, origin='Dice')
def generate_seed():
# Generate 32 bytes of best-quality high entropy TRNG bytes.
@@ -532,7 +610,7 @@ async def make_new_wallet(nwords):
async def ephemeral_seed_import(nwords):
async def import_done_cb(words):
dis.fullscreen("Applying...")
- await set_ephemeral_seed_words(words, meta='Imported')
+ await set_ephemeral_seed_words(words, origin='Imported')
if version.has_qwerty:
from ux_q1 import seed_word_entry
@@ -546,17 +624,17 @@ async def ephemeral_seed_generate(nwords):
words = await approve_word_list(seed, nwords, ephemeral=True)
if words:
dis.fullscreen("Applying...")
- await set_ephemeral_seed_words(words, meta="TRNG Words")
+ await set_ephemeral_seed_words(words, origin="TRNG Words")
async def set_seed_extended_key(extended_key):
encoded, chain = xprv_to_encoded_secret(extended_key)
set_seed_value(encoded=encoded, chain=chain)
goto_top_menu(first_time=True)
-async def set_ephemeral_seed_extended_key(extended_key, meta=None):
+async def set_ephemeral_seed_extended_key(extended_key, origin=None):
encoded, chain = xprv_to_encoded_secret(extended_key)
dis.fullscreen("Applying...")
- await set_ephemeral_seed(encoded=encoded, chain=chain, meta=meta)
+ await set_ephemeral_seed(encoded=encoded, chain=chain, origin=origin)
goto_top_menu()
async def approve_word_list(seed, nwords, ephemeral=False):
@@ -625,17 +703,25 @@ def seed_words_to_encoded_secret(words):
return nv
def xprv_to_encoded_secret(xprv):
- node, chain, _ = parse_extended_key(xprv, private=True)
- if node is None:
- raise ValueError("Failed to parse extended private key.")
+ # read an xprv/tprv/etc and return BIP-32 node and what chain it's on.
+ # - can handle any garbage line
+ # - returns (node, chain)
+ # - people are using SLIP132 so we need this
+ ln = xprv.strip()
+ pat = ure.compile('.prv[A-Za-z0-9]+')
+ found = pat.search(ln)
+ assert found, "not extended privkey"
+ # serialize, and note version code
+ node, chain, addr_fmt, is_private = chains.slip132_deserialize(found.group(0))
+ assert node, "wrong extended privkey"
nv = SecretStash.encode(xprv=node)
node.blank()
return nv, chain # need to know chain
def set_seed_value(words=None, encoded=None, chain=None):
- # Save the seed words (or other encoded private key) into secure element,
- # and reboot. BIP-39 passphrase is not set at this point (empty string).
+ # Save the seed words (or other encoded private key) into secure element.
+ # BIP-39 passphrase is not set at this point (empty string).
if words:
nv = seed_words_to_encoded_secret(words)
else:
@@ -660,13 +746,12 @@ def set_seed_value(words=None, encoded=None, chain=None):
async def calc_bip39_passphrase(pw, bypass_tmp=False):
from glob import dis, settings
- from pincodes import pa
dis.fullscreen("Working...")
current_xfp = settings.get("xfp", 0)
- with stash.SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv:
+ with SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv:
# can't do it without original seed words (late, but caller has checked)
assert sv.mode == 'words', sv.mode
nv = SecretStash.encode(xprv=sv.node)
@@ -677,7 +762,7 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False):
async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True):
nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp)
ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw,
- meta="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp))
+ origin="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp))
dis.draw_status(bip39=int(bool(pw)), xfp=xfp, tmp=1)
return ret
@@ -707,7 +792,7 @@ async def remember_ephemeral_seed():
# address cache, settings from tmp seeds / seedvault seeds
# rebuild fs as we want to save current tmp settings immediately
from files import wipe_flash_filesystem
- wipe_flash_filesystem(True)
+ wipe_flash_filesystem()
dis.draw_status(bip39=0, tmp=0)
dis.fullscreen('Saving...')
@@ -818,7 +903,7 @@ async def _set(menu, label, item):
from glob import dis
dis.fullscreen("Applying...")
- xfp, encoded = item.arg
+ encoded = item.arg # 72 bytes binary
await set_ephemeral_seed(encoded, is_restore=True)
@@ -828,42 +913,40 @@ async def _set(menu, label, item):
async def _remove(menu, label, item):
from glob import dis, settings
- idx, xfp_str, encoded = item.arg
+ esc = ""
+ tmp_val = False
+ idx, rec, encoded = item.arg
+ current_active = (pa.tmp_value == bytes(encoded))
+
+ msg = "Remove seed from seed vault"
+ if pa.tmp_value and current_active:
+ tmp_val = True
+ msg += "?\n\n"
+ else:
+ msg += (" and delete its settings?\n\n"
+ "Press %s to continue, press (1) to "
+ "only remove from seed vault and keep "
+ "encrypted settings for later use.\n\n") % OK
+ esc += "1"
- msg = ("Remove seed from seed vault and delete its "
- "settings?\n\nPress %s to continue, press (1) to "
- "only remove from seed vault and keep "
- "encrypted settings for later use.\n\n"
- "WARNING: Funds will be lost if wallet is"
- " not backed-up elsewhere.") % OK
+ msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere."
- ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape="1")
+ ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
if ch == "x": return
- dis.fullscreen("Saving...")
+ assert not_hobbled_mode()
- wipe_slot = (ch != "1")
- tmp_val = False
+ dis.fullscreen("Saving...")
- if pa.tmp_value:
- tmp_val = True
+ wipe_slot = not current_active and (ch != "1")
if wipe_slot:
- # are we deleting current active ephemeral wallet
- # and its settings ?
- # slot wiping
- if tmp_val:
- # wipe current settings
- settings.blank()
- pa.tmp_value = False
- settings.return_to_master_seed()
- else:
- # in main settings
- xs = SettingsObject()
- xs.set_key(encoded)
- xs.load()
- xs.blank()
- del xs
+ xs = SettingsObject()
+ xs.set_key(encoded)
+ xs.load()
+ xs.blank()
+ del xs
+
# CAUTION: will get shadow copy if in tmp seed mode already
seeds = settings.master_get("seeds", [])
@@ -885,13 +968,13 @@ async def _remove(menu, label, item):
@staticmethod
async def _detail(menu, label, item):
- xfp_str, encoded, name, meta = item.arg
+ rec, encoded = item.arg
- # - first byte represents type of secret (internal encoding flag)
+ # - first byte represents type of secret (internal encoding flags)
txt = SecretStash.summary(encoded[0])
- detail = "Name:\n%s\n\nMaster XFP:\n%s\n\nOrigin:\n%s\n\nSecret Type:\n%s" \
- % (name, xfp_str, meta, txt)
+ detail = "Name:\n%s\n\nMaster XFP: %s\nSecret Type: %s\n\nOrigin:\n%s\n\n" \
+ % (rec.label, rec.xfp, txt, rec.origin)
await ux_show_story(detail)
@@ -901,30 +984,30 @@ async def _rename(menu, label, item):
from glob import dis
from ux import ux_input_text
- idx, xfp_str = item.arg
-
- seeds = settings.master_get("seeds", [])
- chk_xfp, encoded, old_name, meta = seeds[idx]
- assert chk_xfp == xfp_str
+ assert not_hobbled_mode()
- new_name = await ux_input_text(old_name, confirm_exit=False, max_len=40)
+ idx, old = item.arg
+ new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
- if not new_name:
+ if not new_label:
return
dis.fullscreen("Saving...")
+ seeds = settings.master_get("seeds", [])
# save it
- seeds[idx] = (chk_xfp, encoded, new_name, meta)
-
+ seeds[idx] = (old.xfp, old.encoded, new_label, old.origin)
# need to load and work on master secrets, will be slow if on tmp seed
settings.master_set("seeds", seeds)
# update label in sub-menu
- menu.items[0].label = new_name
- menu.items[0].arg = menu.items[0].arg[0:2] + (new_name,) + menu.items[0].arg[3:]
+ menu.items[0].label = new_label
+ # take old arg, in rename we cannot change encoded value, so it can be used without
+ # the need to deserialize it again
+ _, encoded = menu.items[0].arg
+ menu.items[0].arg = VaultEntry(*seeds[idx]), encoded
- # .. and name in parent menu too
+ # and name in parent menu too
parent = the_ux.parent_of(menu)
if parent:
parent.update_contents()
@@ -933,6 +1016,8 @@ async def _rename(menu, label, item):
async def _add_current_tmp(*a):
from pincodes import pa
+ assert not_hobbled_mode()
+
assert pa.tmp_value
main_xfp = settings.master_get("xfp", 0)
@@ -952,10 +1037,9 @@ async def _add_current_tmp(*a):
seeds = settings.master_get("seeds", [])
# Save it into master settings
- seeds.append((new_xfp_str,
- stash.SecretStash.storage_serialize(pa.tmp_value),
- xfp_ui,
- "unknown origin"))
+ seeds.append(list(VaultEntry(new_xfp_str,
+ SecretStash.storage_serialize(pa.tmp_value),
+ xfp_ui, "unknown origin")))
settings.master_set("seeds", seeds)
@@ -967,31 +1051,38 @@ async def _add_current_tmp(*a):
@classmethod
def construct(cls):
# Dynamic menu with user-defined names of seeds shown
- from glob import settings
from pincodes import pa
rv = []
add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp)
- seeds = settings.master_get("seeds", [])
+ seeds = list(seed_vault_iter())
if not seeds:
rv.append(MenuItem('(none saved yet)'))
- if pa.tmp_value:
- rv.append(add_current_tmp)
- rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
+ if not_hobbled_mode():
+ if pa.tmp_value:
+ rv.append(add_current_tmp)
+ rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
else:
+ wipe_if_deltamode()
+
tmp_in_sv = False
- for i, (xfp_str, encoded, name, meta) in enumerate(seeds):
+ for i, rec in enumerate(seeds):
is_active = False
- encoded = pad_raw_secret(encoded)
+
+ # de-serialize encoded secret
+ encoded = deserialize_secret(rec.encoded)
if encoded == pa.tmp_value:
is_active = tmp_in_sv = True
+
submenu = [
- MenuItem(name, f=cls._detail, arg=(xfp_str, encoded, name, meta)),
- MenuItem('Use This Seed', f=cls._set, arg=(xfp_str, encoded)),
- MenuItem('Rename', f=cls._rename, arg=(i, xfp_str)),
- MenuItem('Delete', f=cls._remove, arg=(i, xfp_str, encoded)),
+ MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
+ MenuItem('Use This Seed', f=cls._set, arg=encoded),
+ MenuItem('Rename', f=cls._rename, arg=(i, rec),
+ predicate=not_hobbled_mode),
+ MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded),
+ predicate=not_hobbled_mode),
]
if is_active:
submenu[1] = MenuItem("Seed In Use")
@@ -1002,14 +1093,14 @@ def construct(cls):
# DO NOT offer any modification api (rename/delete)
submenu = submenu[:2]
- item = MenuItem('%2d: %s' % (i+1, name), menu=MenuSystem(submenu))
+ item = MenuItem('%2d: %s' % (i+1, rec.label), menu=MenuSystem(submenu))
if is_active:
item.is_chosen = lambda: True
rv.append(item)
if pa.tmp_value:
- if seeds and (not tmp_in_sv):
+ if seeds and (not tmp_in_sv) and not_hobbled_mode():
# give em chance to store current active
rv.append(add_current_tmp)
@@ -1024,6 +1115,44 @@ def update_contents(self):
tmp = self.construct()
self.replace_items(tmp)
+class SeedVaultChooserMenu(MenuSystem):
+ def __init__(self, words_only=False):
+ self.result = None
+
+ items = []
+ for i, rec in enumerate(seed_vault_iter()):
+ if words_only and not SecretStash.is_words(deserialize_secret(rec.encoded)):
+ continue
+
+ item = MenuItem('%2d: %s' % (i+1, rec.label), arg=rec, f=self.picked)
+ items.append(item)
+
+ if not items:
+ items.append(MenuItem("(none suitable)"))
+
+ super().__init__(items)
+
+ async def picked(self, menu, idx, mi):
+ assert menu == self
+
+ # show as "checked", for a touch
+ menu.chosen = idx
+ menu.show()
+ await sleep_ms(100)
+
+ self.result = mi.arg
+ the_ux.pop() # causes interact to stop
+
+ @classmethod
+ async def pick(cls, **kws):
+ # nice simple blocking menu present and pick
+ m = cls(**kws)
+
+ the_ux.push(m)
+ await m.interact()
+
+ return m.result
+
class EphemeralSeedMenu(MenuSystem):
@staticmethod
@@ -1042,8 +1171,9 @@ async def ephemeral_seed_generate_from_dice(menu, label, item):
def construct(cls):
from glob import NFC
from actions import nfc_recv_ephemeral, import_xprv
- from actions import restore_temporary, scan_any_qr
+ from actions import restore_backup, scan_any_qr
from tapsigner import import_tapsigner_backup_file
+ from xor_seed import xor_restore_start
from charcodes import KEY_QR
import_ephemeral_menu = [
@@ -1060,32 +1190,31 @@ def construct(cls):
]
rv = [
- MenuItem("Generate Words", menu=gen_ephemeral_menu),
+ MenuItem("Generate Words", menu=gen_ephemeral_menu, predicate=not_hobbled_mode),
MenuItem('Import from QR Scan', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
MenuItem("Import Words", menu=import_ephemeral_menu),
MenuItem("Import XPRV", f=import_xprv, arg=True), # ephemeral=True
MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True
- MenuItem("Coldcard Backup", f=restore_temporary),
+ MenuItem("Coldcard Backup", f=restore_backup, arg=True), # tmp=True
+ MenuItem("Restore Seed XOR", f=xor_restore_start),
]
return rv
async def make_ephemeral_seed_menu(*a):
+
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
# force a warning on them, unless they are already doing it.
- ch = await ux_show_story(
+ if not await ux_confirm(
"Temporary seed is a secret completely separate "
"from the master seed, typically held in device RAM and "
"not persisted between reboots in the Secure Element. "
- "Enable the Seed Vault feature to store these secrets longer-term."
- "\n\nPress (4) to prove you read to the end"
- " of this message and accept all consequences.",
+ "Enable the Seed Vault feature to store these secrets longer-term.",
title="WARNING",
- escape="4"
- )
- if ch != "4":
+ confirm_key="4"
+ ):
return
rv = EphemeralSeedMenu.construct()
@@ -1191,7 +1320,7 @@ async def restore_saved(*a):
return PassphraseSaverMenu(items)
- def on_cancel(self):
+ async def on_cancel(self):
if not version.has_qwerty:
# zip to cancel item when they fail to exit via X button
self.goto_idx(self.count - 1)
@@ -1206,7 +1335,9 @@ async def word_menu(self, *a):
@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)
+ from ux_mk4 import ux_input_digits
+
+ pw = await ux_input_digits(cls.pp_sofar)
if pw is not None:
cls.pp_sofar = pw
cls.check_length()
@@ -1285,7 +1416,7 @@ async def apply_pass_value(new_pp):
return
await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=new_pp,
- meta="BIP-39 Passphrase on [%s]" % parent_xfp_str)
+ origin="BIP-39 Passphrase on [%s]" % parent_xfp_str)
if ch == '1':
try:
diff --git a/shared/selftest.py b/shared/selftest.py
index 8600328ee..4550b1716 100644
--- a/shared/selftest.py
+++ b/shared/selftest.py
@@ -194,6 +194,7 @@ async def test_secure_element():
dis.fullscreen("Wait...")
set_genuine()
ux_clear_keys()
+ dis.busy_bar(False)
ng = get_genuine()
assert ng != gg # "Could not invert LED"
@@ -321,14 +322,25 @@ async def test_microsd():
from files import CardSlot
import os
+ def _is_inserted(slot_num):
+ if num_sd_slots > 1:
+ if slot_num == 0:
+ return CardSlot.sd_detect() == 0
+ elif slot_num == 1:
+ return CardSlot.sd_detect2() == 0
+ else:
+ assert False
+ else:
+ return CardSlot.is_inserted()
+
async def wait_til_state(num, want):
title = 'MicroSD Card'
if num_sd_slots > 1:
title += ' ' + chr(65+num)
- label_test(title +':', 'Remove' if CardSlot.is_inserted() else 'Insert')
+ label_test(title +':', 'Remove' if _is_inserted(num) else 'Insert')
while 1:
- if want == CardSlot.is_inserted(): return
+ if want == _is_inserted(num): return
await sleep_ms(100)
if ux_poll_key():
raise RuntimeError("MicroSD test aborted")
@@ -336,23 +348,23 @@ async def wait_til_state(num, want):
for slot_num in range(num_sd_slots):
# test presence switch
for ph in range(7):
- await wait_til_state(slot_num, not CardSlot.is_inserted())
+ await wait_til_state(slot_num, not _is_inserted(slot_num))
- if ph >= 2 and CardSlot.is_inserted():
+ if ph >= 2 and _is_inserted(slot_num):
# debounce
await sleep_ms(100)
- if CardSlot.is_inserted(): break
+ if _is_inserted(slot_num): break
if ux_poll_key():
raise RuntimeError("MicroSD test aborted")
label_test('MicroSD Card:', 'Testing')
# card inserted
- assert CardSlot.is_inserted() #, "SD not present?"
+ assert _is_inserted(slot_num) #, "SD not present?"
with CardSlot(slot_b=slot_num) as card:
- _, fn = card.pick_filename('test-delme.txt')
+ fn, _ = card.pick_filename('test-delme.txt')
with open(fn, 'wt') as fd:
fd.write("Hello")
@@ -365,9 +377,7 @@ async def wait_til_state(num, want):
await wait_til_state(slot_num, False)
-
async def start_selftest():
-
try:
if version.has_battery:
await test_battery()
@@ -403,6 +413,5 @@ async def start_selftest():
except (RuntimeError, AssertionError) as e:
e = str(e) or problem_file_line(e)
await ux_show_story("Test failed:\n" + str(e), 'FAIL')
-
# EOF
diff --git a/shared/serializations.py b/shared/serializations.py
index 33de34543..df28bae60 100755
--- a/shared/serializations.py
+++ b/shared/serializations.py
@@ -16,10 +16,10 @@
"""
from ubinascii import hexlify as b2a_hex
-from ubinascii import unhexlify as a2b_hex
import ustruct as struct
import ngu
from opcodes import *
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2SH, AF_P2WSH, AF_BARE_PK, AF_P2TR
# single-shot hash functions
sha256 = ngu.hash.sha256s
@@ -30,6 +30,7 @@
def bytes_to_hex_str(s):
return str(b2a_hex(s), 'ascii')
+SIGHASH_DEFAULT = const(0) # in taproot meaning same as SIGHASH_ALL (over whole TX)
SIGHASH_ALL = const(1)
SIGHASH_NONE = const(2)
SIGHASH_SINGLE = const(3)
@@ -37,6 +38,7 @@ def bytes_to_hex_str(s):
# list containing all flags that we support signing for
ALL_SIGHASH_FLAGS = [
+ SIGHASH_DEFAULT,
SIGHASH_ALL,
SIGHASH_NONE,
SIGHASH_SINGLE,
@@ -56,14 +58,23 @@ def ser_compact_size(l):
else:
return struct.pack("= 253
+ num_bytes += 2
elif nit == 254:
nit = struct.unpack("= 0x1_0000
+ num_bytes += 4
elif nit == 255:
nit = struct.unpack("= 0x1_0000_0000
+ num_bytes += 8
+ if ret_num_bytes:
+ return nit, num_bytes
return nit
def deser_string(f):
@@ -80,7 +91,6 @@ def deser_uint256(f):
r += t << (i * 32)
return r
-
def ser_uint256(u):
rs = b""
for i in range(8):
@@ -88,7 +98,6 @@ def ser_uint256(u):
u >>= 32
return rs
-
def uint256_from_str(s):
r = 0
t = struct.unpack("> 24) & 0xFF
v = (c & 0xFFFFFF) << (8 * (nbytes - 3))
return v
-
def deser_vector(f, c):
nit = deser_compact_size(f)
r = []
@@ -112,7 +119,6 @@ def deser_vector(f, c):
r.append(t)
return r
-
# ser_function_name: Allow for an alternate serialization function on the
# entries in the vector (we use this for serializing the vector of transactions
# for a witness block).
@@ -125,7 +131,6 @@ def ser_vector(l, ser_function_name=None):
r += i.serialize()
return r
-
def deser_uint256_vector(f):
nit = deser_compact_size(f)
r = []
@@ -134,29 +139,22 @@ def deser_uint256_vector(f):
r.append(t)
return r
-
def ser_uint256_vector(l):
r = ser_compact_size(len(l))
for i in l:
r += ser_uint256(i)
return r
-
def deser_string_vector(f):
nit = deser_compact_size(f)
- r = []
- for i in range(nit):
- t = deser_string(f)
- r.append(t)
- return r
-
+ return [deser_string(f) for _ in range(nit)]
def ser_string_vector(l):
r = ser_compact_size(len(l))
for sv in l:
r += ser_string(sv)
- return r
+ return r
def deser_int_vector(f):
nit = deser_compact_size(f)
@@ -166,7 +164,6 @@ def deser_int_vector(f):
r.append(t)
return r
-
def ser_int_vector(l):
r = ser_compact_size(len(l))
for i in l:
@@ -177,16 +174,18 @@ def ser_push_data(dd):
# "compile" data to be pushed on the script stack
# - will be minimal sized, but only supports size ranges we're likely to see
ll = len(dd)
- assert 2 <= ll <= 255
-
- if ll <= 75:
+ if ll < 0x4c:
return bytes([ll]) + dd # OP_PUSHDATAn + data
+ elif ll <= 0xff:
+ return bytes([0x4c, ll]) + dd # 0x4c = 76 => OP_PUSHDATA1 + size + data
+ elif ll <= 0xffff:
+ return bytes([0x4d]) + struct.pack(b' OP_PUSHDATA2
else:
- return bytes([76, ll]) + dd # 0x4c = 76 => OP_PUSHDATA1 + size + data
+ assert False
def ser_push_int(n):
# push a small integer onto the stack
- from opcodes import OP_0, OP_1, OP_16, OP_PUSHDATA1
+ from opcodes import OP_0, OP_1
if n == 0:
return bytes([OP_0])
@@ -220,11 +219,13 @@ def disassemble(script):
#print('dis %d: number=%d' % (offset, (c - OP_1 + 1)))
yield (c - OP_1 + 1, None)
elif c == OP_PUSHDATA1:
- cnt = script[offset]; offset += 1
+ cnt = script[offset]
+ offset += 1
yield (script[offset:offset+cnt], None)
offset += cnt
elif c == OP_PUSHDATA2:
- cnt = struct.unpack_from("H", script, offset)
+ # up to 65535 bytes
+ cnt, = struct.unpack_from("H", script, offset)
offset += 2
yield (script[offset:offset+cnt], None)
offset += cnt
@@ -237,11 +238,12 @@ def disassemble(script):
# OP_0 included here
#print('dis %d: opcode=%d' % (offset, c))
yield (None, c)
- except:
+ except Exception as e:
+ # import sys;sys.print_exception(e)
raise ValueError("bad script")
-def ser_sig_der(r, s, sighash_type=1):
+def ser_sig_der(r, s, sighash_type=SIGHASH_ALL):
# Take R and S values from a signature and encode into DER format.
sig = b"\x30"
@@ -358,33 +360,48 @@ def serialize(self):
return r
def get_address(self):
- # Detect type of output from scriptPubKey, and return 3-tuple:
- # (addr_type_code, addr, is_segwit)
+ # Detect type of output from scriptPubKey, and return 2-tuple:
+ # (addr_type_code, pubkey/pubkeyhash/scripthash)
# 'addr' is byte string, either 20 or 32 long
+ if self.is_p2tr():
+ return AF_P2TR, self.scriptPubKey[2:2+32]
- if len(self.scriptPubKey) == 22 and \
- self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 20:
- # aka. P2WPKH
- return 'p2pkh', self.scriptPubKey[2:2+20], True
+ if self.is_p2wpkh():
+ return AF_P2WPKH, self.scriptPubKey[2:2+20]
- if len(self.scriptPubKey) == 34 and \
- self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 32:
- # aka. P2WSH
- return 'p2sh', self.scriptPubKey[2:2+32], True
+ if self.is_p2wsh():
+ return AF_P2WSH, self.scriptPubKey[2:2+32]
if self.is_p2pkh():
- return 'p2pkh', self.scriptPubKey[3:3+20], False
+ return AF_CLASSIC, self.scriptPubKey[3:3+20]
if self.is_p2sh():
- return 'p2sh', self.scriptPubKey[2:2+20], False
+ # can be:
+ # * bare P2SH
+ # * P2SH-P2WPKH
+ # * P2SH-P2WSH
+ return AF_P2SH, self.scriptPubKey[2:2+20]
if self.is_p2pk():
# rare, pay to full pubkey
- return 'p2pk', self.scriptPubKey[2:2+33], False
+ return AF_BARE_PK, self.scriptPubKey[2:2+33]
+
+ if self.scriptPubKey[0] == OP_RETURN:
+ return OP_RETURN, self.scriptPubKey
+
+ return None, self.scriptPubKey
+
+ def is_p2tr(self):
+ return len(self.scriptPubKey) == 34 and \
+ (OP_1 <= self.scriptPubKey[0] <= OP_16) and self.scriptPubKey[1] == 0x20
+
+ def is_p2wpkh(self):
+ return len(self.scriptPubKey) == 22 and \
+ self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 0x14
- # If this is reached, we do not understand the output well
- # enough to allow the user to authorize the spend, so fail hard.
- raise ValueError('scriptPubKey template fail: ' + b2a_hex(self.scriptPubKey).decode())
+ def is_p2wsh(self):
+ return len(self.scriptPubKey) == 34 and \
+ self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 0x20
def is_p2sh(self):
return len(self.scriptPubKey) == 23 and self.scriptPubKey[0] == 0xa9 \
@@ -489,7 +506,7 @@ def deserialize(self, f):
self.nVersion = struct.unpack(" number of words
@@ -138,9 +140,34 @@ def decode(secret, _bip39pw=''):
return 'master', ms, hd
+ @staticmethod
+ def is_words(secret):
+ # return False or number of words: 12, 18, 24
+ marker = secret[0]
+ if marker & 0x80:
+ return len_to_numwords(_len_from_marker(marker))
+ return False
+
+ @staticmethod
+ def decode_words(secret, bin_mode=False):
+ # Give a list of BIP-39 words from an encoded secret. Must be "words" type.
+ # - if bin_mode, return binary string representing the words, based on BIP-39
+ ll = _len_from_marker(secret[0])
+
+ # note:
+ # - byte length > number of words
+ # - not storing checksum
+ assert ll in [16, 24, 32]
+
+ # make master secret, using the memonic words, and passphrase (or empty string)
+ seed_bits = secret[1:1+ll]
+
+ return bip39.b2a_words(seed_bits).split() if not bin_mode else seed_bits
+
@staticmethod
def storage_serialize(secret):
# make it a JSON-compatible field
+ # - converse: utils.deserialize_secret()
return B2A(bytes(secret).rstrip(b"\x00"))
@staticmethod
@@ -153,7 +180,7 @@ def summary(marker):
if marker & 0x80:
# seed phrase
- ll = len_from_marker(marker)
+ ll = _len_from_marker(marker)
return '%d words' % len_to_numwords(ll)
if marker == 0x00:
@@ -177,7 +204,7 @@ class SensitiveValues:
_cache_secret = None
_cache_used = None
- def __init__(self, secret=None, bip39pw='', bypass_tmp=False):
+ def __init__(self, secret=None, bip39pw='', bypass_tmp=False, enforce_delta=False):
self.spots = []
self._bip39pw = bip39pw
@@ -195,7 +222,12 @@ def __init__(self, secret=None, bip39pw='', bypass_tmp=False):
if not pa.has_secrets():
raise ZeroSecretException
+
self.deltamode = pa.is_deltamode()
+ if self.deltamode and enforce_delta:
+ # wipe self before fetching secret
+ import callgate
+ callgate.fast_wipe()
if self._cache_secret and not bypass_tmp:
# they are using new BIP39 passphrase but we already have raw secret
@@ -326,6 +358,9 @@ def capture_xpub(self):
return xfp
+ def get_xfp(self):
+ return swab32(self.node.my_fp())
+
def register(self, item):
# Caller can add his own sensitive (derived?) data to our wiper
# typically would be byte arrays or byte strings, but also
@@ -388,13 +423,4 @@ def encryption_key(self, salt):
self.register(pk)
return pk
- def encoded_secret(self):
- # we do not support master as secret - only extended keys and mnemonics
- if self.mode == "xprv":
- nv = SecretStash.encode(xprv=self.node)
- else:
- assert self.mode == "words"
- nv = SecretStash.encode(seed_phrase=self.raw)
- return nv
-
# EOF
diff --git a/shared/tapsigner.py b/shared/tapsigner.py
index 76f9d11b5..9bce68d4d 100644
--- a/shared/tapsigner.py
+++ b/shared/tapsigner.py
@@ -33,7 +33,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
from pincodes import pa
assert pa.is_secret_blank() # "must not have secret"
- meta = "from "
+ origin = "from "
label = "TAPSIGNER encrypted backup file"
choice = await import_export_prompt(label, is_import=True)
@@ -67,9 +67,9 @@ async def import_tapsigner_backup_file(_1, _2, item):
continue
break
else:
- fn = await file_picker(suffix="aes", min_size=100, max_size=160, **choice)
+ fn = await file_picker(suffix=".aes", min_size=100, max_size=160, **choice)
if not fn: return
- meta += (" (%s)" % fn)
+ origin += (" (%s)" % fn)
try:
with CardSlot(**choice) as card:
with open(fn, 'rb') as fp:
@@ -103,6 +103,6 @@ async def import_tapsigner_backup_file(_1, _2, item):
await ux_show_story(title="FAILURE", msg=str(e))
continue
- await import_extended_key_as_secret(extended_key, ephemeral, meta=meta)
+ await import_extended_key_as_secret(extended_key, ephemeral, origin=origin)
# EOF
diff --git a/shared/teleport.py b/shared/teleport.py
new file mode 100644
index 000000000..44d5487d4
--- /dev/null
+++ b/shared/teleport.py
@@ -0,0 +1,789 @@
+# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# teleport.py - Magically transport extremely sensitive data between the
+# secure environment of two Q's.
+#
+import ngu, aes256ctr, bip39, json, ndef, chains
+from utils import xfp2str, deserialize_secret
+from ubinascii import unhexlify as a2b_hex
+from ubinascii import hexlify as b2a_hex
+from glob import settings, dis
+from ux import ux_show_story, ux_confirm, the_ux, ux_dramatic_pause
+from ux_q1 import show_bbqr_codes, QRScannerInteraction, ux_input_text
+from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
+from bbqr import b32encode, b32decode
+from menu import MenuItem, MenuSystem
+from notes import NoteContentBase
+from sffile import SFFile
+from wallet import MiniScriptWallet
+from stash import SensitiveValues, SecretStash, blank_object, bip39_passphrase
+
+# One page github-hosted static website that shows QR based on URL contents pushed by NFC
+KT_DOMAIN = 'keyteleport.com'
+
+# No length/size worries with simple secrets, but massive notes and big PSBT,
+# with lots of UTXO, cannot be passed via NFC URL, because we are limited by
+# NFC chip (8k) and URL length (4k or less) inside. BBQr is not limited however.
+# - but the website is ready to make animated BBQr nicely
+NFC_SIZE_LIMIT = const(4096)
+
+def short_bbqr(type_code, data):
+ # Short-circuit basic BBQr encoding here: always Base32, single part: 1 of 1
+ # - used only for NFC link, where website may split again into parts
+ hdr = 'B$2%s0100' % type_code
+
+ return hdr + b32encode(data)
+
+def txt_grouper(txt):
+ # split into 2-char groups and add spaces -- to make it easier to read/remember
+ return ' '.join(txt[n:n+2] for n in range(0, len(txt), 2))
+
+async def nfc_push_kt(qrdata):
+ # NFC push to send them to our QR-rendering website
+
+ url = KT_DOMAIN + '/#' + qrdata
+
+ n = ndef.ndefMaker()
+ n.add_url(url, https=True)
+
+ from glob import NFC
+ await NFC.share_loop(n, prompt="View QR on web", line2=KT_DOMAIN)
+
+async def kt_start_rx(*a):
+ # menu item to "start a receive" operation
+
+ rx_key = settings.get("ktrx")
+
+ if rx_key:
+ # Maybe re-use same one? Vaguely risky? Concern is they are confused and
+ # we don't want to lose the pubkey if they should be scanning not here.
+ ch = await ux_show_story('''Looks like last attempt wasn't completed. \
+You need to do QR scan of data from the sender to move to the next step. \
+We will re-use same values as last try, unless you press (R) for new values to be picked.''',
+ title='Reuse Pubkey?', escape='r'+KEY_QR, hint_icons=KEY_QR)
+
+ if ch == KEY_QR:
+ # help them scan now!
+ x = QRScannerInteraction()
+ await x.scan_anything(expect_secret=False, tmp=False)
+ return
+ elif ch == 'r':
+ # wipe and restart; sender's work might be lost
+ rx_key = None
+ else:
+ # keep old keypair -- they might be confused
+ kp = ngu.secp256k1.keypair(a2b_hex(rx_key))
+
+ if not rx_key:
+ # pick a random key pair, just for this session
+ kp = ngu.secp256k1.keypair()
+
+ settings.set("ktrx", b2a_hex(kp.privkey()))
+ settings.save()
+
+ short_code, payload = generate_rx_code(kp)
+
+ msg = '''To receive sensitive data from another COLDCARD, \
+share this Receiver Password with sender:
+
+ %s = %s
+
+and show the QR on next screen to the sender. ENTER or %s to show here''' % (
+ short_code, txt_grouper(short_code), KEY_QR)
+
+ await tk_show_payload('R', payload, 'Key Teleport: Receive', msg, cta='Show to Sender')
+
+def generate_rx_code(kp):
+ # Receiver-side password: given a pubkey (33 bytes, compressed format)
+ # - construct an 8-digit decimal "password"
+ # - it's a AES key, but only 26 bits worth
+ pubkey = bytearray(kp.pubkey().to_bytes()) # default: compressed format
+ #assert len(pubkey) == 33
+
+ # - want the code to be deterministic, but I also don't want to save it
+ nk = ngu.hash.sha256d(kp.privkey() + b'COLCARD4EVER')
+
+ # first byte will be 0x02 or 0x03 (Y coord) -- remove those known 7 bits
+ pubkey[0] ^= nk[20] & 0xfe
+
+ num = '%08d' % (int.from_bytes(nk[4:8], 'big') % 1_0000_0000)
+
+ # encryption after baby key stretch
+ kk = ngu.hash.sha256s(num.encode())
+ enc = aes256ctr.new(kk).cipher(pubkey)
+
+ return num, enc
+
+def decrypt_rx_pubkey(code, payload):
+ # given a 8-digit numeric code, make the key and then decrypt/checksum check
+ # - every value works, there is no fail.
+ kk = ngu.hash.sha256s(code.encode())
+ rx_pubkey = bytearray(aes256ctr.new(kk).cipher(payload))
+
+ # first byte will be 0x02 or 0x03 but other 7 bits are noise
+ rx_pubkey[0] &= 0x01
+ rx_pubkey[0] |= 0x02
+
+ # validate that it's on the curve... otherwise the code is wrong
+ try:
+ ngu.secp256k1.pubkey(rx_pubkey)
+
+ return rx_pubkey
+ except:
+ return None
+
+async def tk_show_payload(type_code, payload, title, msg, cta=None):
+ # show the QR and/or NFC
+ # - MAYBE: make easier/faster to pick NFC from QR screen and vice-versa
+ from glob import NFC
+
+ hints = KEY_QR
+ if NFC and len(payload) < NFC_SIZE_LIMIT:
+ hints += KEY_NFC
+ msg += ' or %s to view on your phone' % KEY_NFC
+
+ msg += '. CANCEL to stop.'
+
+ # simply show the QR
+ while 1:
+ ch = await ux_show_story(msg, title=title, hint_icons=hints)
+
+ if ch == KEY_NFC and NFC:
+ await nfc_push_kt(short_bbqr(type_code, payload))
+ elif ch == KEY_QR or ch == 'y':
+ # NOTE: CTA rarely seen, but maybe sometimes?
+ await show_bbqr_codes(type_code, payload, msg=cta)
+ elif ch == 'x':
+ return
+
+async def kt_start_send(rx_data):
+ # a QR was scanned and it held (most of) a pubkey
+ # - they want to send to this guy
+ # - ask them what to send, etc
+
+ while 1:
+ # - ask for the sender's password -- nearly any value will be accepted
+ code = await ux_input_text('', confirm_exit=False, hex_only=True, max_len=8,
+ prompt='Teleport Password (number)', min_len=8, b39_complete=False, scan_ok=False,
+ placeholder='########', funct_keys=None, force_xy=None)
+ if not code: return
+
+ rx_pubkey = decrypt_rx_pubkey(code, rx_data)
+
+ if rx_pubkey:
+ break
+
+ # I think only about 50% odds of catching an incorrect code. Not sure.
+ ch = await ux_show_story(
+ "Incorrect Teleport Password. You can try again or CANCEL to stop.")
+ if ch == 'x': return
+
+ msg = '''You can now Key Teleport secrets! Choose what to share on next screen.\
+\n
+WARNING: Receiver will have full access to all Bitcoin controlled by these keys!'''
+
+ ch = await ux_show_story(msg, title="Key Teleport: Send")
+ if ch != 'y': return
+
+ # pick what to send from a series of submenus
+ menu = SecretPickerMenu(rx_pubkey)
+ the_ux.push(menu)
+
+async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None, prefix=b'', rx_label='the receiver', kp=None):
+ # We are rendering a QR and showing it to them for sending to another Q
+ dis.fullscreen("Wait...")
+ cleartext = dtype.encode() + (raw or json.dumps(obj).encode())
+ dis.progress_bar_show(0.1)
+
+ # Pick and show noid key to sender
+ noid_key, txt = pick_noid_key()
+
+ dis.progress_bar_show(0.25)
+
+ # all new EC key
+ my_keypair = kp or ngu.secp256k1.keypair()
+
+ dis.progress_bar_show(0.75)
+
+ payload = prefix + encode_payload(my_keypair, rx_pubkey, noid_key, cleartext,
+ for_psbt=bool(prefix))
+
+ dis.progress_bar_show(1)
+
+ msg = "Share this password with %s, via some different channel:"\
+ "\n\n %s = %s\n\n" % (rx_label, txt, txt_grouper(txt))
+ msg += "ENTER to view QR"
+
+ await tk_show_payload('S' if not prefix else 'E', payload,
+ 'Teleport Password', msg, cta='Show to Receiver')
+
+ if not prefix:
+ # not PSBT case ... reset menus, we are deep!
+ from actions import goto_top_menu
+ goto_top_menu()
+
+def pick_noid_key():
+ # pick an 40 bit password, shown as base32
+ # - on rx, libngu base32 decoder will convert '018' into 'OLB'
+ # - but a little tempted to removed vowels here?
+ k = ngu.random.bytes(5)
+ txt = b32encode(k).upper()
+
+ return k, txt
+
+async def kt_decode_rx(is_psbt, payload):
+ # we are getting data back from a sender, decode it.
+ dis.fullscreen("Wait...")
+
+ prompt = 'Teleport Password (text)'
+
+ if not is_psbt:
+ rx_key = settings.get("ktrx")
+ if not rx_key:
+ await ux_show_story("Not expecting any teleports. You need to start over.")
+
+ await kt_start_rx() # help them to start over? idk maybe not.
+ return
+
+ his_pubkey = payload[0:33]
+ body = payload[33:]
+ pair = ngu.secp256k1.keypair(a2b_hex(rx_key))
+
+ ses_key, body = decode_step1(pair, his_pubkey, body)
+ else:
+ # Multisig PSBT: will need to iterate over a few wallets and each N-1 possible senders
+ if not MiniScriptWallet.exists():
+ await ux_show_story("Incoming PSBT requires miniscript wallet(s) to be already setup, but you have none.")
+ return
+
+ ses_key, body, sender_xfp = MiniScriptWallet.kt_search_rxkey(payload)
+
+ if sender_xfp is not None:
+ prompt = 'Teleport Password from [%s]' % xfp2str(sender_xfp)
+
+ if not ses_key:
+ # when ECDH fails, it's truncation or wrong RX key (due to sender using old rx key,
+ # or the numeric code the sender entered was wrong, etc)
+ await ux_show_story("QR code was damaged, "+
+ ("numeric password was wrong, " if not is_psbt else "")+
+ "or it was sent to a different user. "
+ "Sender must start again.", title="Teleport Fail")
+ return
+
+ while 1:
+ # ask for noid key
+ pw = await ux_input_text('', confirm_exit=False, hex_only=False, max_len=8,
+ prompt=prompt, min_len=8, b39_complete=False, scan_ok=False,
+ placeholder='********', funct_keys=None, force_xy=None)
+ if not pw: return
+
+ dis.fullscreen("Wait...")
+ try:
+ assert len(pw) == 8
+ noid_key = b32decode(pw) # case insenstive, and smart about confused chars
+ final = decode_step2(ses_key, noid_key, body)
+ if final is not None:
+ break
+ except: pass
+
+ ch = await ux_show_story(
+ "Incorrect Teleport Password. You can try again or CANCEL to stop.")
+ if ch == 'x': return
+ # will ask again
+
+ # success w/ decoding. but maybe something goes wrong or they reject a confirm step
+ # so keep the rx key alive still
+
+ await kt_accept_values(chr(final[0]), final[1:])
+
+async def kt_accept_values(dtype, raw):
+ # We got some secret, decode it more, and save it.
+ '''
+ - `s` - secret, encoded per stash.py
+ - `r` - raw XPRV mode - 64 bytes follow which are the chain code then master privkey
+ - `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows
+ - `n` - one or many notes export (JSON array)
+ - `v` - seed vault export (JSON: one secret key but includes includes name, source of key)
+ - `p` - binary PSBT to be signed
+ - `b` - complete system backup file (text, internal format)
+ '''
+ from flow import has_se_secrets, goto_top_menu
+ from pincodes import pa
+
+ enc = None
+ origin = 'Teleported'
+ label = None
+
+ if pa.hobbled_mode and dtype != 'p':
+ await ux_show_story('Only PSBT for multisig accepted in this mode.', title='FAILED')
+ return
+
+
+ if dtype == 's':
+ # words / bip 32 master / xprv, etc
+ enc = bytearray(72)
+ enc[0:len(raw)] = raw
+
+ elif dtype == 'x':
+ # it's an XPRV, but in binary.. some extra data we throw away here; sigh
+ # XXX no way to send this .. but was thinking of address explorer
+ txt = ngu.codecs.b58_encode(raw)
+ node, ch, _, _ = chains.slip132_deserialize(txt)
+ assert ch.name == chains.current_chain().name, 'wrong chain'
+ enc = SecretStash.encode(xprv=node)
+
+ elif dtype == 'p':
+ # raw PSBT -- much bigger more complex
+ from auth import sign_transaction, TXN_INPUT_OFFSET
+
+ psbt_len = len(raw)
+
+ # copy into PSRAM
+ with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
+ out.write(raw)
+
+ # This will take over UX w/ the signing process
+ # flags=None --> whether to finalize is decided based on psbt.is_complete
+ sign_transaction(psbt_len, flags=None)
+ return
+
+ elif dtype == 'b':
+ # full system backup, including master: text lines
+ from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict, extract_raw_secret
+
+ vals = text_bk_parser(raw)
+ assert vals # empty?
+
+ raw_sec, _ = extract_raw_secret(vals)
+
+ from flow import has_secrets
+
+ if has_secrets():
+ # restores as tmp secret and/or offers to save to SeedVault
+ # need to remove key before I get into tmp seed settings
+ # so even if this errors out, new ktrx is needed
+ settings.remove_key("ktrx")
+ prob = await restore_tmp_from_dict_ll(vals, raw_sec)
+ else:
+ # we have no secret, so... reboot if it works, else errors shown, etc.
+ prob = await restore_from_dict(vals, raw_sec)
+
+ if prob:
+ await ux_show_story(prob, title='FAILED')
+ else:
+ # force new rx key because this tfr worked
+ # only has effect if in master seed settings
+ settings.remove_key("ktrx")
+ return
+
+ elif dtype in 'nv':
+ # all are JSON things
+ js = json.loads(raw)
+
+ if dtype == 'v':
+ # one key export from a seed vault
+ # - watch for incompatibility here if we ever change VaultEntry
+ from seed import VaultEntry
+ rec = VaultEntry(*js)
+ enc = deserialize_secret(rec.encoded)
+ origin = rec.origin
+ label = rec.label
+ elif dtype == 'n':
+ # import secure note(s)
+ from notes import import_from_json, make_notes_menu, NoteContent
+
+ settings.remove_key("ktrx") # force new rx key after this point
+ await import_from_json(dict(coldcard_notes=js))
+
+ await ux_dramatic_pause('Imported.', 2)
+
+ # force them into notes submenu so they can see result right away
+ # - highlight to last note, which should be the just-added one(s)
+ goto_top_menu()
+ nm = await make_notes_menu()
+ nm.goto_idx(NoteContent.count()-1)
+ the_ux.push(nm)
+
+ return
+ else:
+ raise ValueError(dtype)
+
+ # key material is arriving; offer to use as main secret, or tmp, or seed vault?
+ settings.remove_key("ktrx") # force new rx key after this point
+ assert enc
+
+ from seed import set_ephemeral_seed, set_seed_value
+
+ if not has_se_secrets():
+ # unit has nothing, so this will be the master seed
+ set_seed_value(encoded=enc)
+ ok = True
+ else:
+ ok = await set_ephemeral_seed(enc, origin=origin, label=label)
+
+ if ok:
+ goto_top_menu()
+
+def noid_stretch(session_key, noid_key):
+ # TODO: measure timing of this on real Q
+ return ngu.hash.pbkdf2_sha512(session_key, noid_key, 5000)[0:32]
+
+def encode_payload(my_keypair, his_pubkey, noid_key, body, for_psbt=False):
+ # do all the encryption for sender
+ assert len(his_pubkey) == 33
+ assert len(noid_key) == 5
+
+ # this can fail with ValueError: secp256k1_ec_pubkey_parse
+ # if the user has provided the wrong value for numeric password
+ # - better to catch this sooner in decrypt_rx_pubkey
+ session_key = my_keypair.ecdh_multiply(his_pubkey)
+
+ # stretch noid key out -- will be slow
+ pk = noid_stretch(session_key, noid_key)
+
+ b1 = aes256ctr.new(pk).cipher(body)
+ b1 += ngu.hash.sha256s(body)[-2:]
+
+ b2 = aes256ctr.new(session_key).cipher(b1)
+ b2 += ngu.hash.sha256s(b1)[-2:]
+
+ if for_psbt:
+ # no need to share pubkey for PSBT files
+ return b2
+
+ return my_keypair.pubkey().to_bytes() + b2
+
+def decode_step1(my_keypair, his_pubkey, body):
+ # Do ECDH and remove top layer of encryption
+ try:
+ assert len(body) >= 3
+
+ session_key = my_keypair.ecdh_multiply(his_pubkey)
+
+ rv = aes256ctr.new(session_key).cipher(body[:-2])
+ chk = ngu.hash.sha256s(rv)[-2:]
+
+ assert chk == body[-2:] # likely means wrong rx key, or truncation
+ except:
+ return None, None
+
+ return session_key, rv
+
+def decode_step2(session_key, noid_key, body):
+ # After we have the noid key, can decode true payload
+ assert len(noid_key) == 5
+
+ pk = noid_stretch(session_key, noid_key)
+
+ msg = aes256ctr.new(pk).cipher(body[:-2])
+ chk = ngu.hash.sha256s(msg)[-2:]
+
+ return msg if chk == body[-2:] else None
+
+
+async def kt_incoming(type_code, payload):
+ # incoming BBQr was scanned (via main menu, etc)
+
+ from pincodes import pa
+ if pa.hobbled_mode and type_code != 'E':
+ # only PSBT rx is supported in hobbled mode
+ # fail silently, this is second check, see decoders.py
+ return
+
+ if type_code == 'R':
+ # they want to send to this guy
+ return await kt_start_send(payload)
+
+ elif type_code == 'S':
+ # we are receiving something, let's try to decode
+ return await kt_decode_rx(False, payload)
+
+ elif type_code == 'E':
+ # incoming PSBT!
+ return await kt_decode_rx(True, payload)
+
+ else:
+ raise ValueError(type_code)
+
+
+class SecretPickerMenu(MenuSystem):
+ def __init__(self, rx_pubkey):
+ self.rx_pubkey = rx_pubkey
+
+ # this menu should be unreachable in hobbled mode.
+ from pincodes import pa
+ assert not pa.hobbled_mode
+
+ from flow import word_based_seed, is_tmp, has_se_secrets
+ has_notes = bool(NoteContentBase.count())
+ has_sv = bool(settings.get('seedvault', False))
+
+ # Q-only feature, so menu can be W I D E
+ # - in increasing order of importance & sensitivity!
+ # - pinned-virgin mode is supported, so might not have any secrets to share yet,
+ # but can do secret notes still
+ m = [
+ MenuItem('Quick Text Message', f=self.quick_note),
+ MenuItem('Single Note / Password', predicate=has_notes, menu=self.pick_note_submenu),
+ MenuItem('Export All Notes & Passwords', predicate=has_notes, f=self.picked_note),
+ ]
+
+ if has_sv:
+ m.append( MenuItem('From Seed Vault', menu=self.pick_vault_submenu) )
+
+ msg = None
+ if is_tmp():
+ # tmp seed, or maybe bip39 is in effect
+ # - share the current master secret, not the real master
+ msg = 'Temp Secret (words)' if word_based_seed() else (
+ 'XPRV from Words+Passphrase' if bip39_passphrase else 'Temp XPRV Secret')
+ elif has_se_secrets():
+ # sharing real master secret
+ msg = 'Master Seed Words' if word_based_seed() else 'Master XPRV'
+
+ if msg:
+ m.append( MenuItem(msg, f=self.share_master_secret) )
+ m.append( MenuItem("Full COLDCARD Backup", f=self.share_full_backup) )
+
+ super().__init__(m)
+
+ async def pick_vault_submenu(self, *a):
+ # pick a secret from seed vault
+ from seed import SeedVaultChooserMenu
+ rec = await SeedVaultChooserMenu.pick()
+ if rec:
+ await kt_do_send(self.rx_pubkey, 'v', obj=list(rec))
+
+ async def pick_note_submenu(self, *a):
+ # Make a submenu to select a single note/password
+ rv = []
+ for note in NoteContentBase.get_all():
+ rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), f=self.picked_note, arg=note))
+
+ return rv
+
+ async def quick_note(self, _, _2, item):
+ # accept a text string, and send as a note
+ from notes import NoteContent
+ txt = await ux_input_text('', max_len=100,
+ prompt='Enter your message', min_len=1, b39_complete=True, scan_ok=True,
+ placeholder='Attack at dawn.')
+
+ if not txt: return
+
+ n = NoteContent(dict(title="Quick Note", misc=txt))
+ await kt_do_send(self.rx_pubkey, 'n', obj=[n.serialize()])
+
+ async def picked_note(self, _, _2, item):
+ # exporting note(s)
+
+ if item.arg is None:
+ # export all
+ body = [n.serialize() for n in NoteContentBase.get_all()]
+ else:
+ # single note/password
+ body = [item.arg.serialize()]
+
+ await kt_do_send(self.rx_pubkey, 'n', obj=body)
+
+ async def share_full_backup(self, *a):
+ # context, and warn them
+ ch = await ux_show_story("Sending complete backup, including master secret, "
+ "seed vault (if any), miniscript wallets, notes/passwords, and all settings! "
+ "The receiving "
+ "COLDCARD must already have the master seed wiped to be able to install "
+ "everything, otherwise only master secret and miniscripts are saved into a tmp seed. "
+ "OK to proceed?")
+ if ch != 'y': return
+
+ from backups import render_backup_contents
+
+ dis.fullscreen("Buiding Backup...")
+
+ # renders a text file, with rather a lot of comments; strip them
+ bkup = render_backup_contents(bypass_tmp=True)
+ out = []
+ for ln in bkup.split('\n'):
+ if not ln: continue
+ if ln[0] == '#': continue
+ out.append(ln)
+
+ await kt_do_send(self.rx_pubkey, 'b', raw=b'\n'.join(ln.encode() for ln in out))
+
+ async def share_master_secret(self, _, _2, item):
+ # altho menu items look different we are sharing same thing:
+ # - up to 72 bytes from secure elements
+
+ dis.fullscreen("Wait...")
+
+ with SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv:
+ raw = bytearray(sv.secret)
+ xfp = xfp2str(sv.get_xfp())
+
+ # rtrim zeros
+ while raw[-1] == 0:
+ raw = raw[0:-1]
+
+ summary = SecretStash.summary(raw[0])
+
+ from pincodes import pa
+ scale = 'your MASTER secret' if not pa.tmp_value else 'a temporary secret'
+
+ msg = "Sharing %s [%s] (%s)." % (scale, xfp, summary)
+ msg += "\n\nWARNING: Allows full control over all associated Bitcoin!"
+
+ if not await ux_confirm(msg):
+ blank_object(raw)
+ return
+
+ await kt_do_send(self.rx_pubkey, 's', raw=raw)
+
+
+async def kt_send_psbt(psbt, psbt_len):
+ # We just finished adding our signature to an incomplete PSBT.
+ # User wants to send to one or more other senders for them to complete signing.
+
+ # who remains to sign? look at inputs
+ # all_xfps is set, no need to list one master xfp more than once - assuming CC can sign it all
+ assert psbt.active_miniscript
+ ms = psbt.active_miniscript
+ all_xfps = {x for x,*p in ms.to_descriptor().xfp_paths(skip_unspend_ik=True)}
+
+ need = [x for x in psbt.miniscript_xfps_needed() if x in all_xfps]
+ # maybe it's not really a PSBT where we know the other signers? might be
+ # a weird coinjoin we don't fully understand
+ if not need:
+ await ux_show_story("No more signers?")
+ return
+
+ # move out of PSRAM
+ from auth import TXN_OUTPUT_OFFSET
+
+ with SFFile(TXN_OUTPUT_OFFSET, psbt_len) as fd:
+ bin_psbt = fd.read(psbt_len)
+
+ my_xfp = settings.get('xfp')
+
+ # if my_xfp in need:
+ # - we haven't signed yet? let's do that now .. except we've lost some of the
+ # data we need such as filename to save back into.
+ # - so just keep going instead... maybe they want to be last signer?
+
+ # Make them pick a single next signer. It's not helpful to do multiple at once
+ # here, since we need signatures to be added serially so that last
+ # signer can do finalization. We don't have a general purpose combiner.
+
+ async def done_cb(m, idx, item):
+ m.next_xfp = item.arg
+ the_ux.pop()
+
+ ci = []
+ next_signer = None
+ for idx, x in enumerate(all_xfps):
+ txt = '[%s] Co-signer #%d' % (xfp2str(x), idx+1)
+ f = done_cb
+ if x == my_xfp:
+ txt += ': YOU'
+ f = None
+ if x in need:
+ # we haven't signed ourselves yet, so allow that
+ from auth import sign_transaction, TXN_INPUT_OFFSET
+
+ async def sign_now(*a):
+ # this will reset the UX stack:
+ # flags=None --> whether to finalize is decided based on psbt.is_complete
+ sign_transaction(psbt_len, flags=None)
+
+ f = sign_now
+
+ elif x not in need:
+ txt += ': DONE'
+ f = None
+
+ mi = MenuItem(txt, f=f, arg=x)
+
+ if x not in need:
+ # show check if we've got sig
+ mi.is_chosen = lambda: True
+ elif next_signer is None:
+ next_signer = idx
+
+ ci.append(mi)
+
+ m = MenuSystem(ci)
+ m.next_xfp = None
+ m.goto_idx(next_signer) # position cursor on next candidate
+ the_ux.push(m)
+ await m.interact()
+
+ if m.next_xfp:
+ assert m.next_xfp != my_xfp
+ ri, rx_pubkey, kp = ms.kt_make_rxkey(m.next_xfp)
+ await kt_do_send(rx_pubkey, 'p', raw=bin_psbt, prefix=ri, kp=kp,
+ rx_label='[%s] co-signer' % xfp2str(m.next_xfp))
+
+ return True
+
+ return None
+
+async def kt_send_file_psbt(*a):
+ # Menu item: choose a PSBT file from SD card, and send to co-signers.
+ # Heavy code re-use here. Need to find the multisig wallet associated w/ file,
+ # so we need to parse it and we must be one of the co-signers.
+
+ from actions import is_psbt, file_picker
+ from auth import sign_psbt_file, TXN_INPUT_OFFSET
+ from version import MAX_TXN_LEN
+ from ux import import_export_prompt
+ from psbt import psbtObject
+
+ # choose any PSBT from SD
+ picked = await import_export_prompt("PSBT", is_import=True, no_nfc=True, no_qr=True)
+ if picked == KEY_CANCEL:
+ return
+ choices = await file_picker(suffix='.psbt', min_size=50, ux=False,
+ max_size=MAX_TXN_LEN, taster=is_psbt, **picked)
+ if not choices:
+ # error msg already shown
+ return
+
+ if len(choices) == 1:
+ # single - skip the menu
+ label,path,fn = choices[0]
+ input_psbt = path + '/' + fn
+ else:
+ # multiples - make them pick one
+ input_psbt = await file_picker(choices=choices)
+ if not input_psbt:
+ return
+
+ # read into PSRAM from wherever
+ psbt_len = await sign_psbt_file(input_psbt, just_read=True, **picked)
+
+ dis.fullscreen("Validating...")
+ try:
+ dis.progress_sofar(1, 4)
+ with SFFile(TXN_INPUT_OFFSET, length=psbt_len, message='Reading...') as fd:
+ # NOTE: psbtObject captures the file descriptor and uses it later
+ psbt = psbtObject.read_psbt(fd)
+
+ await psbt.validate() # might do UX: accept multisig import
+
+ dis.progress_sofar(2, 4)
+ psbt.consider_inputs()
+ dis.progress_sofar(3, 4)
+
+ except Exception as exc:
+ # not going to do full reporting here, use our other code for that!
+ await ux_show_story("Cannot validate PSBT?\n\n"+str(exc), "PSBT Load Failed")
+ return
+ finally:
+ dis.progress_bar_show(1)
+
+ if not psbt.active_miniscript:
+ await ux_show_story("We are not part of this wallet.", "Cannot Teleport PSBT")
+ return
+
+ await kt_send_psbt(psbt, psbt_len=psbt_len)
+
+# EOF
diff --git a/shared/trick_pins.py b/shared/trick_pins.py
index 1d7a19c6c..8dc89b5d1 100644
--- a/shared/trick_pins.py
+++ b/shared/trick_pins.py
@@ -12,6 +12,8 @@
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_enter_number, the_ux
from stash import SecretStash
from drv_entro import bip85_derive
+from glob import settings
+
# see from mk4-bootloader/se2.h
NUM_TRICKS = const(14)
@@ -32,7 +34,7 @@
TC_XPRV_WALLET = const(0x0800)
TC_DELTA_MODE = const(0x0400)
TC_REBOOT = const(0x0200)
-TC_RFU = const(0x0100)
+TC_FW_DEFINED = const(0x0100)
# for our use, not implemented in bootrom
TC_BLANK_WALLET = const(0x0080)
TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay
@@ -40,6 +42,10 @@
# tc_args encoding:
# TC_WORD_WALLET -> BIP-85 index, 1001..1003 for 24 words, 2001..2003 for 12-words
+# If TC_FW_DEFINED is true, then we can do anything with this PIN at the firmware
+# level. First application is to unlock spending stuff.
+TCA_SP_UNLOCK = const(0x0001) # spending policy unlock
+
# special "pin" used as catch-all for wrong pins
WRONG_PIN_CODE = '!p'
@@ -94,22 +100,6 @@ class TrickPinMgmt:
def __init__(self):
assert uctypes.sizeof(TRICK_SLOT_LAYOUT) == 128
- self.reload()
-
- def reload(self):
- # we track known PINS as a dictionary:
- # pin (in ascii) => (slot_num, tc_flags, arg)
- from glob import settings
- self.tp = settings.get('tp', {})
-
- def save_record(self):
- # commit changes back to settings
- from glob import settings
- if self.tp:
- settings.set('tp', self.tp)
- else:
- settings.remove_key('tp')
- settings.save()
def roundtrip(self, method_num, slot_buf=None):
from pincodes import pa
@@ -129,26 +119,36 @@ def roundtrip(self, method_num, slot_buf=None):
return rc
+ def get_all(self):
+ return settings.get("tp", {})
+
+ def commit(self, trick_pins):
+ settings.set("tp", trick_pins)
+ settings.save()
+
def clear_all(self):
# get rid of them all
self.roundtrip(0)
- self.tp = {}
- self.save_record()
+ settings.remove_key('tp')
+ settings.save()
def forget_pin(self, pin):
# forget about settings for a PIN
- self.tp.pop(pin, None)
- self.save_record()
+ t_pins = self.get_all()
+ t_pins.pop(pin, None)
+ self.commit(t_pins)
def restore_pin(self, new_pin):
# remember/restore PIN that we "forgot", return T if worked
- b, slot = tp.get_by_pin(new_pin)
+ b, slot = self.get_by_pin(new_pin)
if slot is None: return False
record = (slot.slot_num, slot.tc_flags,
0xffff if slot.tc_flags & TC_DELTA_MODE else slot.tc_arg)
- self.tp[new_pin] = record
- self.save_record()
+
+ t_pins = self.get_all()
+ t_pins[new_pin] = record
+ self.commit(t_pins)
return True
@@ -221,17 +221,18 @@ def update_slot(self, pin, new=False, new_pin=None, tc_flags=None, tc_arg=None,
# pick a free slot
sn = self.find_empty_slots(1 if not secret else 1+(len(secret)//32))
- if sn == None:
+ if sn is None:
# we are full
raise RuntimeError("no space left")
slot.slot_num = sn
+ t_pins = self.get_all()
if new_pin is not None:
slot.pin_len = len(new_pin)
slot.pin[0:slot.pin_len] = new_pin
if new_pin != pin:
- self.tp.pop(pin.decode(), None)
+ t_pins.pop(pin.decode(), None)
pin = new_pin
if tc_flags is not None:
@@ -265,14 +266,18 @@ def update_slot(self, pin, new=False, new_pin=None, tc_flags=None, tc_arg=None,
assert rc == 0
# record key details.
- self.tp[pin.decode()] = record
- self.save_record()
+ t_pins[pin.decode()] = record
+ self.commit(t_pins)
return b, slot
def all_tricks(self):
# put them in order, with "wrong" last
- return sorted(self.tp.keys(), key=lambda i: i if (i != WRONG_PIN_CODE) else 'Z')
+ return sorted(self.get_all().keys(), key=lambda i: i if (i != WRONG_PIN_CODE) else 'Z')
+
+ def define_unlock_pin(self, new_pin):
+ # user is setting the bypass PIN for first time.
+ self.update_slot(new_pin.encode(), new=True, tc_flags=TC_FW_DEFINED, tc_arg=TCA_SP_UNLOCK)
def was_countdown_pin(self):
# was the trick pin just used? if so how much delay needed (or zero if not)
@@ -284,24 +289,51 @@ def was_countdown_pin(self):
else:
return 0
+ def was_sp_unlock(self):
+ # was a trick pin just used that enables acess to spending policy?
+ # - ok if it's also a trick PIN .. a wiping bypass for example
+ from pincodes import pa
+ tc_flags, tc_arg = pa.get_tc_values()
+ return bool(tc_flags & TC_FW_DEFINED) and (tc_arg == TCA_SP_UNLOCK)
+
+ def has_sp_unlock(self):
+ # if spending policy defined, this PIN allows adjustment
+ # - not TRICK bypass choices, like ones that wipe
+ # - could be multiple, but only first returned.
+ for k, (sn,flags,arg) in self.get_all().items():
+ if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
+ return k
+ return None
+
+ def delete_sp_unlock_pins(self):
+ # remove all bypass pins, they are done w/ feature
+ for k, (sn,flags,arg) in self.get_all().items():
+ if (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
+ self.clear_slots([sn])
+ self.forget_pin(k)
+
+
def get_deltamode_pins(self):
# iterate over all delta-mode PIN's defined.
- for k, (sn,flags,args) in self.tp.items():
+ for k, (sn,flags,args) in self.get_all().items():
if flags & TC_DELTA_MODE:
yield k
def get_duress_pins(self):
# iterate over all duress wallets
- for k, (sn,flags,args) in self.tp.items():
+ for k, (sn,flags,args) in self.get_all().items():
if flags & (TC_WORD_WALLET | TC_XPRV_WALLET):
yield k
def check_new_main_pin(self, pin):
# user is trying to change main PIN to new value; check for issues
# - dups bad but also: delta mode pin might not work w/ longer main true pin
+ # - deciding whether TP already exists must be done via comms with SE2
+ # as checking only self.tp is not sufficient for hidden TPs or after fast wipe
# - return error msg or None
assert isinstance(pin, str)
- if pin in self.tp:
+ b, slot = self.get_by_pin(pin)
+ if slot is not None:
return 'That PIN is already in use as a Trick PIN.'
for d_pin in self.get_deltamode_pins():
@@ -319,8 +351,9 @@ def main_pin_has_changed(self, new_main_pin):
def backup_duress_wallets(self, sv):
# for backup file, yield (label, path, pairs-of-data)
done = set()
+ t_pins = self.get_all()
for pin in self.get_duress_pins():
- sn, flags, arg = self.tp[pin]
+ sn, flags, arg = t_pins[pin]
if (flags, arg) in done:
continue
@@ -330,7 +363,7 @@ def backup_duress_wallets(self, sv):
label = "Duress: BIP-85 Derived wallet"
nwords = 12 if ((arg // 1000) == 2) else 24
path = "BIP85(words=%d, index=%d)" % (nwords, arg)
- b, slot = tp.get_by_pin(pin)
+ b, slot = self.get_by_pin(pin)
words = bip39.b2a_words(slot.xdata[0:(32 if nwords==24 else 16)])
d = [ ('duress_%d_words' % arg, words) ]
@@ -369,10 +402,15 @@ def restore_backup(self, vals):
# might need to construct a BIP-85 or XPRV secret to match
path, new_secret = construct_duress_secret(flags, arg)
- b, slot = tp.update_slot(pin.encode(), new=True,
- tc_flags=flags, tc_arg=arg, secret=new_secret)
- except Exception as exc:
- sys.print_exception(exc) # not visible
+ self.update_slot(pin.encode(), new=True, tc_flags=flags,
+ tc_arg=arg, secret=new_secret)
+ except: pass
+
+ @staticmethod
+ async def err_unique_pin(pin):
+ # standardized error UX
+ return await ux_show_story(
+ "That PIN (%s) is already in use. All PIN codes must be unique." % pin)
tp = TrickPinMgmt()
@@ -401,7 +439,6 @@ def construct(self):
if bool(pa.tmp_value):
return [MenuItem('Not Available')]
- tp.reload()
tricks = tp.all_tricks()
if self.current_pin in tricks:
@@ -489,7 +526,7 @@ async def done_picking(self, item, parents):
tc_arg=tc_arg, secret=new_secret)
await ux_dramatic_pause("Saved.", 1)
except BaseException as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
await ux_show_story("Failed: %s" % exc)
self.update_contents()
@@ -518,8 +555,7 @@ async def get_new_pin(self, existing_pin=None):
have.remove(existing_pin)
if (new_pin == self.current_pin) or (new_pin in have):
- await ux_show_story("That PIN (%s) is already in use. All PIN codes must be unique." % new_pin)
- return
+ return await tp.err_unique_pin(new_pin)
# check if we "forgot" this pin, and read it back if we did.
# - important this is after the above checks so we don't reveal any trick pin used
@@ -604,6 +640,9 @@ async def add_new(self, *a):
For this mode only, trick PIN must be same length as true PIN and \
differ only in final 4 positions (ignoring dash).\
''', flags=TC_DELTA_MODE),
+ StoryMenuItem('Policy Unlock', "Adds (another?) Spending Policy unlock PIN.", flags=TC_FW_DEFINED, arg=TCA_SP_UNLOCK),
+ StoryMenuItem('Policy Unlock & Wipe' if version.has_qwerty else 'P.U. & Wipe',
+ "Pretends correct Spending Policy unlock PIN given, but silently wipes seed before asking for main PIN.", flags=TC_FW_DEFINED|TC_WIPE, arg=TCA_SP_UNLOCK),
]
m = MenuSystem(FirstMenu)
m.goto_idx(1)
@@ -632,23 +671,31 @@ async def set_any_wrong(self, *a):
# xxxxxxxxxxxxxxxx
MenuItem('[%s WRONG PIN]' % rel),
StoryMenuItem('Wipe, Stop', "Seed is wiped and a message is shown.",
- arg=num, flags=TC_WIPE),
+ arg=num, flags=TC_WIPE),
StoryMenuItem('Wipe & Reboot', "Seed is wiped and Coldcard reboots without notice.",
- arg=num, flags=TC_WIPE|TC_REBOOT),
+ arg=num, flags=TC_WIPE|TC_REBOOT),
StoryMenuItem('Silent Wipe', "Seed is silently wiped and Coldcard acts as if PIN code was just wrong.",
- arg=num, flags=TC_WIPE|TC_FAKE_OUT),
- StoryMenuItem('Brick Self', "Become a brick instantly and forever.", flags=TC_BRICK, arg=num),
- StoryMenuItem('Last Chance', "Wipe seed, then give one more try and then brick if wrong PIN.", arg=num, flags=TC_WIPE|TC_BRICK),
- StoryMenuItem('Just Reboot', "Reboot when this happens. Doesn't do anything else.", arg=num, flags=TC_REBOOT),
+ arg=num, flags=TC_WIPE|TC_FAKE_OUT),
+ StoryMenuItem('Brick Self', "Become a brick instantly and forever.",
+ arg=num, flags=TC_BRICK,),
+ StoryMenuItem('Last Chance', "Wipe seed, then give one more try and then brick if wrong PIN.",
+ arg=num, flags=TC_WIPE|TC_BRICK),
+ StoryMenuItem('Just Reboot', "Reboot when this happens. Doesn't do anything else.",
+ arg=num, flags=TC_REBOOT),
])
m.goto_idx(1)
the_ux.push(m)
async def clear_all(self, m,l,item):
+
if not await ux_confirm("Remove ALL TRICK PIN codes and special wrong-pin handling?"):
return
+ if tp.has_sp_unlock():
+ if not await ux_confirm("You will not be able to bypass spending policy anymore."):
+ return
+
if any(tp.get_duress_pins()):
if not await ux_confirm("Any funds on the duress wallet(s) have been moved already?"):
return
@@ -657,7 +704,7 @@ async def clear_all(self, m,l,item):
m.update_contents()
async def hide_pin(self, m,l, item):
- pin, slot_num, flags = item.arg
+ pin, slot_num, flags, arg = item.arg
if flags & TC_DELTA_MODE:
await ux_show_story('''Delta mode PIN will be hidden if trick PIN menu is shown \
@@ -665,12 +712,14 @@ async def hide_pin(self, m,l, item):
hiding this item.''')
return
- if pin != WRONG_PIN_CODE:
+ if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
+ msg = "It will still be possible to change or disable the spending policy if this PIN is known."
+ elif pin == WRONG_PIN_CODE:
+ msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
+ else:
msg = '''This will hide the PIN from the menus but it will still be in effect.
You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
- else:
- msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
if not await ux_confirm(msg): return
@@ -706,16 +755,20 @@ async def change_pin(self, m,l, item):
self.pop_submenu() # too lazy to get redraw right
except BaseException as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
await ux_show_story("Failed: %s" % exc)
async def delete_pin(self, m,l, item):
- pin, slot_num, flags = item.arg
+ pin, slot_num, flags, arg = item.arg
if flags & (TC_WORD_WALLET | TC_XPRV_WALLET):
if not await ux_confirm("Any funds on this duress wallet have been moved already?"):
return
+ if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
+ if not await ux_confirm("Changes to the spending policy will not be possible anymore."):
+ return
+
if pin == WRONG_PIN_CODE:
msg = "Remove special handling of wrong PINs?"
else:
@@ -743,11 +796,9 @@ async def activate_wallet(self, m, l, item):
ch = await ux_show_story('''\
This will temporarily load the secrets associated with this trick wallet \
-so you may perform transactions with it. Reboot the Coldcard to restore \
-normal operation.''')
+so you may perform transactions with it.''')
if ch != 'y': return
- from pincodes import pa, AE_SECRET_LEN
b, slot = tp.get_by_pin(pin)
assert slot
@@ -771,7 +822,7 @@ async def activate_wallet(self, m, l, item):
# switch over to new secret!
dis.fullscreen("Applying...")
- await set_ephemeral_seed(encoded, meta=name)
+ await set_ephemeral_seed(encoded, origin=name)
goto_top_menu()
async def countdown_details(self, m, l, item):
@@ -784,7 +835,7 @@ async def countdown_details(self, m, l, item):
# "arg" can be out-of-date, if they edited timer value after parent was
# rendered, where arg was captured into item.arg ... so don't use it.
- cd_val = tp.tp[pin][2]
+ cd_val = tp.get_all()[pin][2]
msg = 'Shows login countdown (%s)' % lgto_map.get(cd_val, '???').strip()
if flags & TC_WIPE:
@@ -800,16 +851,14 @@ async def countdown_details(self, m, l, item):
def adjust_countdown_chooser():
# 'disabled' choice not appropriate for this case
- ch = lgto_ch[1:]
va = lgto_va[1:]
def set_it(idx, text):
new_val = va[idx]
# save it
try:
- b, slot = tp.update_slot(pin.encode(), tc_flags=flags, tc_arg=new_val)
- except BaseException as exc:
- sys.print_exception(exc)
+ tp.update_slot(pin.encode(), tc_flags=flags, tc_arg=new_val)
+ except: pass
return va.index(cd_val), lgto_ch[1:], set_it
@@ -833,7 +882,8 @@ async def duress_details(self, m, l, item):
if ch != '6': return
b, s = tp.get_by_pin(pin)
- if s == None:
+ if s is None:
+ title = None
# could not find in SE2. Our settings vs. SE2 are not in sync.
msg = "Not found in SE2. Delete and remake."
else:
@@ -845,21 +895,22 @@ async def duress_details(self, m, l, item):
ch, pk = s.xdata[0:32], s.xdata[32:64]
node.from_chaincode_privkey(ch, pk)
- msg, *_ = render_master_secrets('xprv', None, node)
+ title, msg, *_ = render_master_secrets('xprv', None, node)
elif flags & TC_WORD_WALLET:
raw = s.xdata[0:(32 if nwords == 24 else 16)]
- msg, *_ = render_master_secrets('words', raw, None)
+ title, msg, *_ = render_master_secrets('words', raw, None)
else:
raise ValueError(hex(flags))
- await ux_show_story(msg, sensitive=True)
+ await ux_show_story(msg, title=title, sensitive=True)
async def pin_submenu(self, menu, label, item):
# drill down into a sub-menu per existing PIN
# - data display only, no editing; just clear and redo
pin = item.arg
- slot_num, flags, arg = tp.tp[pin] if (pin in tp.tp) else (-1, 0, 0)
+ t_pins = tp.get_all()
+ slot_num, flags, arg = t_pins[pin] if (pin in t_pins) else (-1, 0, 0)
rv = []
@@ -878,6 +929,8 @@ async def pin_submenu(self, menu, label, item):
rv.append(MenuItem("↳Pretends Wrong"))
elif flags & TC_DELTA_MODE:
rv.append(MenuItem("↳Delta Mode"))
+ elif (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
+ rv.append(MenuItem("↳Unlock Policy")) # width issues on Mk4
for m, msg in [
(TC_WIPE, '↳Wipes seed'),
@@ -891,8 +944,8 @@ async def pin_submenu(self, menu, label, item):
rv.append(MenuItem("Activate Wallet", f=self.activate_wallet, arg=(pin, flags, arg)))
rv.extend([
- MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags)),
- MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags)),
+ MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags, arg)),
+ MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags, arg)),
])
if pin != WRONG_PIN_CODE:
rv.append(
@@ -903,6 +956,7 @@ async def pin_submenu(self, menu, label, item):
class StoryMenuItem(MenuItem):
def __init__(self, label, story, flags=0, **kws):
+ # arg= .. handled by super
self.story = story
self.flags = flags
super().__init__(label, **kws)
diff --git a/shared/usb.py b/shared/usb.py
index 7dc1ea80f..fd4d6a413 100644
--- a/shared/usb.py
+++ b/shared/usb.py
@@ -2,16 +2,17 @@
#
# usb.py - USB related things
#
-import ckcc, pyb, callgate, sys, ux, ngu, stash, aes256ctr
+import ckcc, pyb, callgate, sys, ux, ngu, stash, aes256ctr, ujson
from uasyncio import sleep_ms, core
from uhashlib import sha256
-from public_constants import MAX_MSG_LEN, MAX_BLK_LEN, AFC_SCRIPT
+from public_constants import MAX_MSG_LEN, MAX_BLK_LEN
from public_constants import STXN_FLAGS_MASK
from ustruct import pack, unpack_from
from ckcc import watchpoint, is_simulator
from utils import problem_file_line, call_later_ms
from version import supports_hsm, is_devmode, MAX_TXN_LEN, MAX_UPLOAD_LEN
-from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled
+from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled, SpendPolicyViolation
+from pincodes import pa
# Unofficial, unpermissioned... numbers
COINKITE_VID = 0xd13e
@@ -52,8 +53,8 @@
'smsg', # limited by policy
'blkc', 'hsts', # report status values
'stok', 'smok', # completion check: sign txn or msg
- 'xpub', 'msck', # quick status checks
- 'p2sh', 'show', # limited by HSM policy
+ 'xpub', # quick status checks
+ 'show', 'msas', # limited by HSM policy
'user', # auth HSM user, other user cmds not allowed
'gslr', # read storage locker; hsm mode only, limited usage
})
@@ -68,6 +69,21 @@
"hsms",
})
+# spending policy active: blacklist some commands
+# - 'pass' may be allowed if 'okeys' is enabled
+HOBBLED_CMDS = frozenset({
+ 'enrl', # no new multisigs during policy enforcement
+ 'back', # no backups
+ 'bagi', 'dfu_', # just in case
+
+ "user", # same as HSM_DISABLE_CMDS
+ "rmur",
+ "nwur",
+ "gslr",
+ "hsts",
+ "hsms",
+})
+
# singleton instance of USBHandler()
handler = None
@@ -117,6 +133,16 @@ def is_vcp_active():
return cur and ('VCP' in cur) and en
+
+def get_miniscript_by_name(name_bytes):
+ from wallet import MiniScriptWallet
+
+ for w in MiniScriptWallet.iter_wallets():
+ if w.name == str(name_bytes, 'ascii'):
+ return True, w
+ else:
+ return False, b'err_Miniscript wallet not found'
+
class USBHandler:
def __init__(self):
self.dev = pyb.USB_HID()
@@ -169,6 +195,7 @@ async def usb_hid_recv(self):
msg_len = 0
while 1:
+ success = False
yield core._io_queue.queue_read(self.blockable)
try:
@@ -212,14 +239,14 @@ async def usb_hid_recv(self):
# this saves memory over a simple slice (confirmed)
args = memoryview(self.msg)[4:msg_len]
resp = await self.handle(self.msg[0:4], args)
- msg_len = 0
+ success = True
except CCBusyError:
# auth UX is doing something else
resp = b'busy'
- msg_len = 0
+ except SpendPolicyViolation:
+ resp = b'err_Spending policy in effect'
except HSMDenied:
resp = b'err_Not allowed in HSM mode'
- msg_len = 0
except HSMCMDDisabled:
# do NOT change below error msg as other applications depend on it
resp = b'err_HSM commands disabled'
@@ -227,16 +254,14 @@ async def usb_hid_recv(self):
except (ValueError, AssertionError) as exc:
# some limited invalid args feedback
#print("USB request caused assert: ", end='')
- #sys.print_exception(exc)
+ # sys.print_exception(exc)
msg = str(exc)
if not msg:
msg = 'Assertion ' + problem_file_line(exc)
resp = b'err_' + msg.encode()[0:80]
- msg_len = 0
except MemoryError:
# prefer to catch at higher layers, but sometimes can't
resp = b'err_Out of RAM'
- msg_len = 0
except FramingError as exc:
raise exc
except Exception as exc:
@@ -245,9 +270,15 @@ async def usb_hid_recv(self):
print("USB request caused this: ", end='')
sys.print_exception(exc)
resp = b'err_Confused ' + problem_file_line(exc)
- msg_len = 0
- # aways send a reply if they get this far
+ if not success:
+ # do not let the progress screen hang on "Receiving..."
+ from ux import restore_menu
+ restore_menu()
+
+ msg_len = 0
+
+ # always send a reply if they get this far
await self.send_response(resp)
except FramingError as exc:
@@ -342,7 +373,7 @@ async def handle(self, cmd, args):
except:
raise FramingError('decode')
- if cmd[0].isupper() and is_devmode:
+ if is_devmode and cmd[0].isupper():
# special hacky commands to support testing w/ the simulator
try:
from usb_test_commands import do_usb_command
@@ -355,7 +386,18 @@ async def handle(self, cmd, args):
if cmd not in HSM_WHITELIST:
raise HSMDenied
- if not settings.get('hsmcmd', False):
+ if pa.hobbled_mode:
+ # block some commands when we are hobbled.
+ if cmd in HOBBLED_CMDS:
+ raise SpendPolicyViolation
+
+ if cmd in {'pwok', 'pass'}:
+ from ccc import sssp_spending_policy
+ if not sssp_spending_policy('okeys'):
+ raise SpendPolicyViolation
+
+ elif not settings.get('hsmcmd', False):
+ # block these HSM-related command if not using feature
if cmd in HSM_DISABLE_CMDS:
raise HSMCMDDisabled
@@ -432,39 +474,6 @@ async def handle(self, cmd, args):
sign_msg(msg, subpath, addr_fmt)
return None
- if cmd == 'p2sh':
- # show P2SH (probably multisig) address on screen (also provides it back)
- # - must provide redeem script, and list of [xfp+path]
- from auth import start_show_p2sh_address
-
- if hsm_active and not hsm_active.approve_address_share(is_p2sh=True):
- raise HSMDenied
-
- # new multsig goodness, needs mapping from xfp->path and M values
- addr_fmt, M, N, script_len = unpack_from('= 4, 'too short key path'
+ assert (length % 4) == 0, 'corrupt key path'
+ assert (length // 4) <= MAX_PATH_DEPTH, 'too deep'
+
class DecodeStreamer:
def __init__(self):
self.runt = bytearray()
@@ -416,7 +421,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
@@ -431,7 +436,9 @@ def clean_shutdown(style=0):
# wipe SPI flash and shutdown (wiping main memory)
# - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom)
# - bootrom wipes every byte of SRAM, so no need to repeat here
- import callgate, version, uasyncio
+ # - style=2 => reboot and try login again
+ # - default is logout and (if applicable) power down.
+ import callgate
# save if anything pending
from glob import settings
@@ -454,129 +461,87 @@ def clean_shutdown(style=0):
callgate.show_logout(style)
def call_later_ms(delay, cb, *args, **kws):
- import uasyncio
-
async def doit():
await uasyncio.sleep_ms(delay)
await cb(*args, **kws)
uasyncio.create_task(doit())
-def txtlen(s):
- # width of string in chars, accounting for
- # double-wide characters which happen on Q.
- rv = len(s)
-
- if DOUBLE_WIDE:
- rv += sum(1 for ch in s if ch in DOUBLE_WIDE)
-
- return rv
def word_wrap(ln, w):
# Generate the lines needed to wrap one line into X "width"-long lines.
# - tests in testing/test_unit.py
-
- if txtlen(ln) <= w:
- yield ln
+ if ln and (ln[0] == OUT_CTRL_NOWRAP):
+ # no need to wrap this line - as requested by caller
+ yield ln[1:]
return
- while ln:
+ while True:
+ # ln_len considers DOUBLE_WIDTH chars
+ ln_len = 0
+ sp = None
+ for idx, ch in enumerate(ln):
+ if ch == ' ':
+ # split point on space if possible
+ sp = idx
+
+ ln_len += 1
+ if ch in DOUBLE_WIDE:
+ ln_len += 1
+
+ if ln_len > w:
+ # if one of .,:; is last -> allow one more character
+ # even if only half visible on Mk4
+ # on Q it's OK as (CHARS_W-1) is used as w
+ if ch in ".,:;":
+ idx += 1
+ sp = None
+
+ break
- # find a space in (width) first part of remainder
- sp = ln.rfind(' ', 0, w-1)
+ else:
+ yield ln
+ return
+
+ if sp is None:
+ 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
- if sp == -1:
# bad-break the line
- sp = min(txtlen(ln), w)
- nsp = sp
- if ln[nsp:nsp+1] == ' ':
+ sp = nsp = idx
+ if ln[sp:nsp+1] == " ":
nsp += 1
else:
# split on found space
nsp = sp+1
left = ln[0:sp]
- ln = ln[nsp:]
-
- if txtlen(left) + 1 + txtlen(ln) <= w:
- # not clear when this would happen? final bit??
- left = left + ' ' + ln
- ln = ''
-
yield left
+ ln = ln[nsp:]
+ if not ln: return
-def parse_addr_fmt_str(addr_fmt):
- # accepts strings and also integers if already parsed
- from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
-
- if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]:
- 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
- else:
- raise ValueError("Invalid address format: '%s'\n\n"
- "Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % 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
- # - returns (node, chain, addr_fmt)
- # - people are using SLIP132 so we need this
- node, chain, addr_fmt = None, None, None
- if ln is None:
- return node, chain, addr_fmt
-
- ln = ln.strip()
- if private:
- rgx = r'.prv[A-Za-z0-9]+'
- else:
- rgx = r'.pub[A-Za-z0-9]+'
-
- pat = ure.compile(rgx)
- found = pat.search(ln)
- # serialize, and note version code
- try:
- node, chain, addr_fmt, is_private = chains.slip32_deserialize(found.group(0))
- except:
- pass
-
- return node, chain, addr_fmt
-
-def chunk_writer(fd, body):
- from glob import dis
- dis.fullscreen("Saving...")
- body_len = len(body)
- chunk = body_len // 10
- for idx, i in enumerate(range(0, body_len, chunk)):
- fd.write(body[i:i + chunk])
- dis.progress_bar_show(idx / 10)
- 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"
- }[addr_fmt]
-
-
-def pad_raw_secret(raw_sec_str):
+def deserialize_secret(text_sec_str):
# Chip can hold 72-bytes as a secret
- # every secret has 0th byte as marker
- # then secret and padded to zero to AE_SECRET_LEN
+ # - has 0th byte as marker, secret and zero padding to AE_SECRET_LEN
+ # - also does hex to binary conversion
+ # - converse of: SecretStash.storage_serialize()
from pincodes import AE_SECRET_LEN
raw = bytearray(AE_SECRET_LEN)
- if len(raw_sec_str) % 2:
- raw_sec_str += '0'
- x = a2b_hex(raw_sec_str)
+ if len(text_sec_str) % 2:
+ text_sec_str += '0'
+ x = a2b_hex(text_sec_str)
raw[0:len(x)] = x
return raw
@@ -615,11 +580,6 @@ def datetime_to_str(dt, fmt="%d-%02d-%02d %02d:%02d:%02d"):
dts = fmt % (y, mo, d, h, mi, s)
return dts + " UTC"
-def censor_address(addr):
- # We don't like to show the user multisig addresses because we cannot be certain
- # they are valid and could actually be signed. And yet, dont blank too many
- # spots or else an attacker could grind out a suitable replacement.
- return addr[0:12] + '___' + addr[12+3:]
def txid_from_fname(fname):
if len(fname) >= 64:
@@ -630,7 +590,7 @@ def txid_from_fname(fname):
except: pass
return None
-def url_decode(u):
+def url_unquote(u):
# expand control chars from %XX and '+'
# - equiv to urllib.parse.unquote_plus
# - ure.sub is missing, so not being clever here.
@@ -650,29 +610,38 @@ def url_decode(u):
return u
+def url_quote(u):
+ # convert non-text chars into %hex for URL usage
+ # - urllib.parse.quote() but w/o as much thought
+ return ''.join( (ch if 33 <= ord(ch) <= 127 else '%%%02x' % ord(ch)) \
+ for ch in u)
+
def decode_bip21_text(got):
# Assume text is a BIP-21 payment address (url), with amount, description
# and url protocol prefix ... all optional except the address.
# - also will detect correctly encoded & checksummed xpubs
+ # - always verifies checksum of data it finds
proto, args, addr = None, None, None
- # remove URL protocol: if present
- if ':' in got:
- proto, got = got.split(':', 1)
-
+ # remove query params first - if any
# looks like BIP-21 payment URL
if '?' in got:
- addr, args = got.split('?', 1)
+ got, args = got.split('?', 1)
# full URL decode here, but assuming no repeated keys
parts = args.split('&')
args = dict()
for p in parts:
k, v = p.split('=', 1)
- args[k] = url_decode(v)
+ args[k] = url_unquote(v)
- # assume it's an bare address for now
+ # remove URL protocol: if present
+ if ':' in got:
+ proto, got = got.split(':', 1)
+ assert proto.lower() == "bitcoin"
+
+ # assume it's a bare address for now
if not addr:
addr = got
@@ -680,10 +649,12 @@ def decode_bip21_text(got):
try:
raw = ngu.codecs.b58_decode(addr)
- # it's valid base58
- # an address, P2PKH or xpub (xprv checked above)
+ # It's valid base58: could be
+ # an address, P2PKH or xpub/xprv
if addr[1:4] == 'pub':
return 'xpub', (addr,)
+ if addr[1:4] == 'prv':
+ return 'xprv', (addr,)
return 'addr', (proto, addr, args)
except:
@@ -701,4 +672,74 @@ def decode_bip21_text(got):
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)]
+
+def cleanup_payment_address(s):
+ # Cleanup a payment address, or raise if bad checksum
+ # - later matching is string-based, so just doing basic syntax check here
+ # - must be checksumed-base58 or bech32
+ try:
+ ngu.codecs.b58_decode(s)
+ assert len(s) < 40 # or else it's an xpub/xprv
+ return s
+ except: pass
+
+ try:
+ ngu.codecs.segwit_decode(s)
+ return s.lower()
+ except: pass
+
+ raise ValueError('bad address value: ' + s)
+
+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 wipe_if_deltamode():
+ # If in deltamode, give up and wipe self rather do
+ # a thing that might reveal true master secret...
+ from pincodes import pa
+
+ if pa.is_deltamode():
+ import callgate
+ callgate.fast_wipe()
+
+def chunk_checksum(fd, chunk=1024):
+ # reads from open file descriptor
+ md = sha256()
+ while True:
+ data = fd.read(chunk)
+ if not data:
+ break
+ md.update(data)
+
+ return md.digest()
+
+def xor(*args):
+ # bit-wise xor between all args
+ vlen = len(args[0])
+ # all have to be same length
+ assert all(len(e) == vlen for e in args)
+ rv = bytearray(vlen)
+
+ for i in range(vlen):
+ for a in args:
+ rv[i] ^= a[i]
+
+ return rv
+
# EOF
diff --git a/shared/ux.py b/shared/ux.py
index 813fa8094..36646ee67 100644
--- a/shared/ux.py
+++ b/shared/ux.py
@@ -6,8 +6,10 @@
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)
+from version import has_qwerty, num_sd_slots, has_qr
+from charcodes import (KEY_UP, KEY_DOWN, KEY_HOME, KEY_NFC, KEY_QR, 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
@@ -15,21 +17,24 @@
# See ux_mk or ux_q1 for some display functions now
if version.has_qwerty:
from lcd_display import CHARS_W, CHARS_H
- CH_PER_W = CHARS_W
+ # stories look nicer if we do not use the whole width
+ CH_PER_W = (CHARS_W - 1)
STORY_H = CHARS_H
- from ux_q1 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
- from ux_q1 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
+ from ux_q1 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin
+ from ux_q1 import ux_login_countdown, ux_dice_rolling, ux_render_words
from ux_q1 import ux_show_phish_words
OK = "ENTER"
X = "CANCEL"
else:
# How many characters can we fit on each line? How many lines?
- # (using FontSmall)
+ # (using FontSmall) .. except it's an approximation since variable-width font.
+ # - 18 can work but rightmost spot is half-width. We allow . and , in that spot.
+ # - really should look at rendered-width of text
CH_PER_W = 17
STORY_H = 5
- from ux_mk4 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
- from ux_mk4 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
+ from ux_mk4 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin
+ from ux_mk4 import ux_login_countdown, ux_dice_rolling, ux_render_words
from ux_mk4 import ux_show_phish_words
OK = "OK"
X = "X"
@@ -169,7 +174,6 @@ def ux_poll_key():
return ch
-
async def ux_show_story(msg, title=None, escape=None, sensitive=False,
strict_escape=False, hint_icons=None):
# show a big long string, and wait for XY to continue
@@ -181,8 +185,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
@@ -245,7 +249,21 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False,
if ch in { KEY_NFC, KEY_QR }:
return ch
-
+async def ux_confirm(msg, title="Are you SURE ?!?", confirm_key=None):
+ # confirmation screen, with stock title and Y=of course.
+ if not version.has_qwerty and len(title) > 12:
+ msg = title + "\n\n" + msg
+ title = None
+
+ suffix = ""
+ if confirm_key:
+ suffix = ("\n\nPress (%s) to prove you read to the end of this message"
+ " and accept all consequences.") % confirm_key
+
+ msg += suffix
+ r = await ux_show_story(msg, title=title, escape=confirm_key)
+
+ return r == (confirm_key or 'y')
async def idle_logout():
import glob
@@ -321,14 +339,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):
@@ -340,7 +358,6 @@ async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False):
return await ux_enter_number(prompt=prompt, max_value=max_value, can_cancel=can_cancel)
def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
- from version import has_qwerty, num_sd_slots, has_qr
from glob import NFC, VD
prompt, escape = None, KEY_CANCEL+"x"
@@ -376,16 +393,15 @@ def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False):
return prompt, escape
-def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None,
- force_prompt=False):
+def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None, offer_kt=False,
+ force_prompt=False, txid=None):
# Build the prompt for export
# - key0 can be for special stuff
- from version import has_qwerty, num_sd_slots, has_qr
from glob import NFC, VD
prompt, escape = None, KEY_CANCEL+"x"
- if (NFC or VD) or (num_sd_slots>1) or key0 or force_prompt:
+ if (NFC or VD) or (num_sd_slots>1) or key0 or force_prompt or offer_kt or txid or (not no_qr):
# no need to spam with another prompt, only option is SD card
prompt = "Press (1) to save %s to SD Card" % what_it_is
@@ -415,6 +431,14 @@ def export_prompt_builder(what_it_is, no_qr=False, no_nfc=False, key0=None,
prompt += ", (4) to show QR code"
escape += '4'
+ if txid:
+ prompt += ", (6) for QR Code of TXID"
+ escape += "6"
+
+ if offer_kt:
+ prompt += ", (T) to " + offer_kt
+ escape += 't'
+
if key0:
prompt += ', (0) ' + key0
escape += '0'
@@ -456,17 +480,22 @@ def import_export_prompt_decode(ch):
async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
no_nfc=False, title=None, intro='', footnotes='',
- slot_b_only=False):
+ offer_kt=False, slot_b_only=False, force_prompt=False,
+ txid=None):
+
# Show story allowing user to select source for importing/exporting
# - return either str(mode) OR dict(file_args)
# - KEY_NFC or KEY_QR for those sources
# - KEY_CANCEL for abort by user
# - dict() => do file system thing, using file_args to control vdisk vs. SD vs slot_b
+ # - 't' => key teleport, but only offered with offer_kt is set (contetxt, and Q only)
+ from glob import NFC
if is_import:
prompt, escape = _import_prompt_builder(what_it_is, no_qr, no_nfc, slot_b_only)
else:
- prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc)
+ prompt, escape = export_prompt_builder(what_it_is, no_qr, no_nfc, txid=txid,
+ force_prompt=force_prompt, offer_kt=offer_kt)
# TODO: detect if we're only asking A or B, when just one card is inserted
# - assume that's what they want to do
@@ -476,8 +505,10 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
# they don't have NFC nor VD enabled, and no second slots... so will be file.
return dict(force_vdisk=False, slot_b=None)
else:
- ch = await ux_show_story(intro+prompt+footnotes, escape=escape, title=title,
- strict_escape=True)
+ hints = ("" if no_qr else KEY_QR) + (KEY_NFC if not no_nfc and NFC else "")
+ msg_lst = [i for i in (intro, prompt, footnotes) if i]
+ ch = await ux_show_story("\n\n".join(msg_lst), escape=escape, title=title,
+ strict_escape=True, hint_icons=hints)
return import_export_prompt_decode(ch)
diff --git a/shared/ux_mk4.py b/shared/ux_mk4.py
index dac8bf515..feb37ad2d 100644
--- a/shared/ux_mk4.py
+++ b/shared/ux_mk4.py
@@ -58,17 +58,9 @@ async def wait(self):
else:
self.last_key = ch
return ch
-
-
-async def ux_confirm(msg):
- # confirmation screen, with stock title and Y=of course.
- from ux import ux_show_story
-
- resp = await ux_show_story("Are you SURE ?!?\n\n" + msg)
- return resp == 'y'
-async def ux_enter_number(prompt, max_value, can_cancel=False):
+async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
@@ -80,7 +72,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
press = PressRelease('1234567890y')
y = 26
- value = ''
+ value = str(value)
max_w = int(log(max_value, 10) + 1)
dis.clear()
@@ -122,8 +114,8 @@ 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):
- # collect a series of digits
+async def ux_input_digits(val, prompt=None, maxlen=32):
+ # collect a series of digits.
from glob import dis
from display import FontTiny
@@ -137,6 +129,11 @@ async def ux_input_numbers(val, validate_func):
dis.clear()
dis.text(None, -1, footer, FontTiny)
+
+ if prompt:
+ dis.text(0, 0, prompt)
+ y += 8
+
dis.save()
while 1:
@@ -161,7 +158,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:
@@ -170,7 +166,7 @@ async def ux_input_numbers(val, validate_func):
# quit if they press X on empty screen
return
else:
- if len(here) < 32:
+ if len(here) < maxlen:
here += ch
async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_len=0, **_kws):
@@ -287,7 +283,7 @@ def change(dx):
ch = await press.wait()
if ch == 'y':
if len(pw) < min_len:
- ch = await ux_show_story('Need %d characters at least. Press OK '
+ ch = await ux_show_story('Need %d character(s) at least. Press OK '
'to continue X to exit.' % min_len, escape="xy",
strict_escape=True)
if ch == "x": return
diff --git a/shared/ux_q1.py b/shared/ux_q1.py
index ba55f8382..1d98a841a 100644
--- a/shared/ux_q1.py
+++ b/shared/ux_q1.py
@@ -2,17 +2,18 @@
#
# 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, bip39
import uasyncio as asyncio
from uasyncio import sleep_ms
from charcodes import *
from lcd_display import CHARS_W, CHARS_H, CursorSpec, CURSOR_SOLID, CURSOR_OUTLINE
from exceptions import AbortInteraction, QRDecodeExplained
-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:
@@ -74,16 +75,8 @@ async def wait(self):
else:
self.last_key = ch
return ch
-
-async def ux_confirm(msg):
- # confirmation screen, with stock title and Y=of course.
- from ux import ux_show_story
-
- resp = await ux_show_story(msg, title="Are you SURE ?!?")
- return resp == 'y'
-
-async def ux_enter_number(prompt, max_value, can_cancel=False):
+async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
@@ -93,7 +86,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
# allow key repeat on X only?
press = PressRelease()
- value = ''
+ value = str(value)
max_w = int(log(max_value, 10) + 1)
dis.clear()
@@ -122,6 +115,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
elif ch == KEY_DELETE:
if value:
value = value[0:-1]
+ dis.text(0, 4, ' '*CHARS_W)
elif ch == KEY_CLEAR:
value = ''
dis.text(0, 4, ' '*CHARS_W)
@@ -138,11 +132,6 @@ 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):
- # collect a series of digits
- # - not wanted on Q1; just get the digits mixed in w/ the text.
- pass
-
async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
placeholder=None, funct_keys=None, force_xy=None):
@@ -159,7 +148,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
@@ -541,23 +529,22 @@ async def ux_login_countdown(sec):
dis.busy_bar(0)
-def ux_render_words(words, leading_blanks=1):
+def ux_render_words(words, leading_blanks=0):
# re-use word-list rendering code to show as a string in a story.
# - because I want them all on-screen at once, and not simple to do that
- buf = [bytearray(CHARS_W) for y in range(CHARS_H)]
-
rv = [''] * leading_blanks
num_words = len(words)
if num_words == 12:
for y in range(6):
+ # no need to use NOWRAP here, will always fit (2 word columns)
rv.append('%2d: %-8s %2d: %s' % (y+1, words[y], y+7, words[y+6]))
else:
lines = 6 if num_words == 18 else 8
for y in range(lines):
- rv.append('%d:%-8s %2d:%-8s %2d:%s' % (y+1, words[y],
- y+lines+1, words[y+lines],
- y+(lines*2)+1, words[y+(lines*2)]))
+ rv.append(OUT_CTRL_NOWRAP+'%d:%-8s %2d:%-8s %2d:%s' % (
+ y+1, words[y], y+lines+1, words[y+lines],
+ y+(lines*2)+1, words[y+(lines*2)]))
return '\n'.join(rv)
@@ -566,10 +553,21 @@ def ux_draw_words(y, num_words, words):
# Draw seed words on single screen (hard) and return x/y position of start of each
from glob import dis
+ if num_words == 2:
+ # simple version for first & last words, used only during login to spending policy
+ X = 14
+ Y = y+1
+ dis.text(X-7, Y, 'FIRST: %s' % words[0])
+ dis.text(X-4, Y+1, '⋯')
+ dis.text(X-6, Y+2, 'LAST: %s' % words[-1])
+
+ return [ (X, Y), (X, Y+2) ]
+
if num_words == 12:
cols = 2
xpos = [2, 18]
else:
+ assert num_words in (18, 24)
cols = 3
xpos = [0, 11, 23]
@@ -596,14 +594,17 @@ def ux_draw_words(y, num_words, words):
return rv
-async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
+async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None, line2=None):
# Accept a seed phrase, only
# - replaces WordNestMenu on Q1
# - max word length is 8, min is 3
# - useful: simulator.py --q1 --eff --seq 'aa ee 4i '
from glob import dis
+ from ux import ux_confirm
+
+ assert num_words and prompt
- assert num_words and prompt and done_cb
+ not24 = (num_words != 24)
def redraw_words(wrds=None):
if not wrds:
@@ -611,7 +612,15 @@ def redraw_words(wrds=None):
dis.clear()
dis.text(None, 0, prompt, invert=1)
- p = ux_draw_words(2 if num_words != 24 else 1, num_words, wrds)
+
+ Y = 2 if not24 else 1
+ if line2 and not24:
+ # add second line, if provided, but only if words length < 24
+ # currently only used to show backup filename during backup pwd entry
+ dis.text(None, 1, line2, invert=1)
+ Y += 1
+
+ p = ux_draw_words(Y, num_words, wrds)
return wrds, p
words, pos = redraw_words()
@@ -693,8 +702,7 @@ def redraw_words(wrds=None):
elif ch == KEY_CANCEL:
if word_num >= 2:
tmp = dis.save_state()
- ok = await ux_confirm("Everything you've entered will be lost.")
- if not ok:
+ if not await ux_confirm("Everything you've entered will be lost."):
dis.restore_state(tmp)
continue
return None
@@ -715,7 +723,7 @@ def redraw_words(wrds=None):
maybe = [i for i in last_words if i.startswith(value)]
if len(maybe) == 1:
value = maybe[0]
- elif len(maybe) == 0:
+ elif not maybe:
if len(last_words) == 8: # 24 words case
ll = ''.join(sorted(set([w[0] for w in last_words])))
err_msg = 'Final word starts with: ' + ll
@@ -762,7 +770,10 @@ def redraw_words(wrds=None):
else:
err_msg = 'Next key: ' + nextchars
- await done_cb(words)
+ if done_cb:
+ await done_cb(words)
+
+ return words
def ux_dice_rolling():
from glob import dis
@@ -789,7 +800,7 @@ def __init__(self):
pass
@staticmethod
- async def scan(prompt, line2=None):
+ async def scan(prompt, line2=None, enter_quits=False):
# draw animation, while waiting for them to scan something
# - CANCEL to abort
# - returns a string, BBQr object or None.
@@ -808,6 +819,8 @@ async def scan(prompt, line2=None):
task = asyncio.create_task(SCAN.scan_once())
+ escape = KEY_CANCEL + (KEY_ENTER if enter_quits else '')
+
ph = 0
while 1:
if task.done():
@@ -819,9 +832,9 @@ async def scan(prompt, line2=None):
ph = (ph + 1) % len(frames)
# wait for key or 250ms animation delay
- ch = await ux_wait_keydown(KEY_CANCEL, 250)
+ ch = await ux_wait_keydown(escape, 250)
- if ch == KEY_CANCEL:
+ if ch and (ch in escape):
data = None
break
@@ -833,14 +846,14 @@ async def scan(prompt, line2=None):
return data
- async def scan_general(self, prompt, convertor):
+ async def scan_general(self, prompt, convertor, line2=None, enter_quits=False):
# Scan stuff, and parse it .. raise QRDecodeExplained if you don't like it
# continues until something is accepted
- problem = None
+ problem = line2
while 1:
try:
- got = await self.scan(prompt, line2=problem)
+ got = await self.scan(prompt, line2=problem, enter_quits=enter_quits)
if got is None:
return None
@@ -850,7 +863,7 @@ async def scan_general(self, prompt, convertor):
problem = str(exc)
continue
except Exception as exc:
- #import sys; sys.print_exception(exc)
+ # import sys; sys.print_exception(exc)
problem = "Unable to decode QR"
continue
@@ -880,9 +893,37 @@ def convertor(got):
return await self.scan_general(prompt, convertor)
+ async def scan_for_addresses(self, prompt, line2=None):
+ # accept only payment addresses; strips BIP-21 junk that might be there
+ # - always a list result, might be size one
+ from utils import decode_bip21_text
+
+ def addr_taster(got):
+ # could be muliple-line text file via BBQR or single line
+ got = decode_qr_result(got, expect_text=True)
+
+ try:
+ rv = []
+ for ln in got.split():
+ what, args = decode_bip21_text(ln)
+ if what == 'addr':
+ rv.append(args[1])
+ if rv:
+ return rv
+ except QRDecodeExplained:
+ raise
+ except:
+ pass
+ raise QRDecodeExplained("Not a payment address?")
+
+ return await self.scan_general(prompt, addr_taster, line2=line2, enter_quits=True)
- async def scan_anything(self, expect_secret=False, tmp=False):
+
+ async def scan_anything(self, expect_secret=False, tmp=False, miniscript_wallet=None):
# start a QR scan, and act on what we find, whatever it may be.
+ from ux import ux_show_story
+ from pincodes import pa
+
problem = None
while 1:
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
@@ -895,89 +936,113 @@ async def scan_anything(self, expect_secret=False, tmp=False):
# Figure out what we got.
what, vals = decode_qr_result(got, expect_secret=expect_secret)
+ break
except QRDecodeExplained as exc:
problem = str(exc)
continue
- except Exception as exc:
- import sys; sys.print_exception(exc)
+ except Exception:
+ # import sys; sys.print_exception(exc)
problem = "Unable to decode QR"
continue
- if what == 'xprv':
- from actions import import_extended_key_as_secret
- text_xprv, = vals
- await import_extended_key_as_secret(text_xprv, tmp)
- return
+ if pa.hobbled_mode:
+ # block most imports in hobbled mode.
+ # - specific checks in place for teleport (PSBT is okay)
+ from ccc import sssp_spending_policy
+ whitelist = {'psbt', 'addr', 'vmsg', 'text', 'xpub', 'teleport' }
- if what == 'words':
- from seed import commit_new_words, set_ephemeral_seed_words # dirty API
- words, = vals
- if tmp:
- await set_ephemeral_seed_words(words, 'From QR')
- else:
- await commit_new_words(words)
+ sv_ok = sssp_spending_policy('okeys')
+ if sv_ok:
+ # seed vault, and tmp seeds are okay with user, even in hobble mode
+ whitelist.update({'xprv', 'words'})
+ if what not in whitelist:
+ await ux_show_story("Blocked when Spending Policy is in force.", title='Sorry')
return
- if what == 'psbt':
- decoder, psbt_len, got = vals
- await qr_psbt_sign(decoder, psbt_len, got)
- return
+ if what == 'xprv':
+ from actions import import_extended_key_as_secret
+ text_xprv, = vals
+ await import_extended_key_as_secret(text_xprv, tmp)
+ return
+
+ if what == 'words':
+ from seed import commit_new_words, set_ephemeral_seed_words # dirty API
+ words, = vals
+ if tmp:
+ await set_ephemeral_seed_words(words, 'From QR')
+ else:
+ await commit_new_words(words)
- if what == 'txn':
- bin_txn, = vals
- await ux_visualize_txn(bin_txn)
- return
+ return
- if what == 'addr':
- proto, addr, args = vals
- await ux_visualize_bip21(proto, addr, args)
- return
+ if what == 'psbt':
+ decoder, psbt_len, got = vals
+ await qr_psbt_sign(decoder, psbt_len, got, miniscript_wallet)
- if what == "multi":
- from auth import maybe_enroll_xpub
- from ux import ux_show_story
- ms_config, = vals
- try:
- maybe_enroll_xpub(config=ms_config)
- except Exception as e:
- await ux_show_story(
- 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
- return
+ elif what == 'txn':
+ bin_txn, = vals
+ await ux_visualize_txn(bin_txn)
- if what == "wif":
- data, = vals
- wif_str, key_pair, compressed, testnet = data
- await ux_visualize_wif(wif_str, key_pair, compressed, testnet)
- return
+ elif what == 'addr':
+ proto, addr, args = vals
+ await ux_visualize_bip21(proto, addr, args)
- if what == 'text' or what == 'xpub':
- # we couldn't really decode it.
- txt, = vals
- await ux_visualize_textqr(txt)
- return
+ elif what == "minisc":
+ from auth import maybe_enroll_xpub
+ ms_config, = vals
+ try:
+ maybe_enroll_xpub(config=ms_config)
+ except Exception as e:
+ await ux_show_story(
+ 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
+ return
+
+ elif what == "wif":
+ data, = vals
+ wif_str, key_pair, compressed, testnet = data
+ await ux_visualize_wif(wif_str, key_pair, compressed, testnet)
+
+ elif what == "vmsg":
+ data, = vals
+ from msgsign import verify_armored_signed_msg
+ await verify_armored_signed_msg(data)
+
+ elif what == "smsg":
+ data, = vals
+ from auth import approve_msg_sign
+ from msgsign import msg_signing_done
+ await approve_msg_sign(None, None, None,
+ msg_sign_request=data, kill_menu=True,
+ approved_cb=msg_signing_done)
+
+ elif what == 'text' or what == 'xpub':
+ # we couldn't really decode it.
+ txt, = vals
+ await ux_visualize_textqr(txt)
+
+ elif what == 'teleport':
+ from teleport import kt_incoming
+ await kt_incoming(*vals)
+
+ else:
+ await ux_show_story(what, title='Unhandled')
- # not reached?
- problem = 'Unhandled: ' + what
-
-async def qr_psbt_sign(decoder, psbt_len, raw):
+async def qr_psbt_sign(decoder, psbt_len, raw, miniscript_wallet=None):
# Got a PSBT coming in from QR scanner. Sign it.
# - similar to auth.sign_psbt_file()
- from auth import UserAuthorizedAction, ApproveTransaction, try_push_tx
- from utils import CapsHexWriter
- from glob import dis, PSRAM
- from ux import show_qr_code, the_ux, ux_show_story
- from ux_q1 import show_bbqr_codes
+ from auth import UserAuthorizedAction, ApproveTransaction
+ from ux import the_ux
from sffile import SFFile
- from auth import MAX_TXN_LEN, TXN_INPUT_OFFSET, TXN_OUTPUT_OFFSET
+ from auth import TXN_INPUT_OFFSET, psbt_encoding_taster
if raw != 'PSRAM': # might already be in place
-
+ # copy to PSRAM, and convert encoding at same time
if isinstance(raw, str):
raw = raw.encode()
- # copy to PSRAM, and convert encoding at same time
+ _, output_encoder, _ = psbt_encoding_taster(raw[:10], psbt_len)
total = 0
with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
if not decoder:
@@ -991,39 +1056,16 @@ async def qr_psbt_sign(decoder, psbt_len, raw):
assert total <= psbt_len
psbt_len = total
- async def done(psbt):
- dis.fullscreen("Wait...")
- txid = None
-
- with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
-
- # save transaction, as hex into PSRAM
- with CapsHexWriter(psram) as fd:
- if psbt.is_complete():
- txid = psbt.finalize(fd)
- else:
- psbt.serialize(fd)
-
- data_len, sha = psram.tell(), fd.checksum.digest()
-
- UserAuthorizedAction.cleanup()
-
- # Show the result as a QR, perhaps many BBQr's
- # - note: already HEX here!
- here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len)
- if txid and await try_push_tx(a2b_hex(here), txid, sha):
- return # success, exit
-
- try:
- await show_qr_code(here.decode(), is_alnum=True,
- msg=(txid or 'Partly Signed PSBT'))
- except (ValueError, RuntimeError):
- await show_bbqr_codes('T' if txid else 'P', here,
- (txid or 'Partly Signed PSBT'),
- already_hex=True)
+ else:
+ with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
+ taste = out.read(10)
+ _, output_encoder, _ = psbt_encoding_taster(taste, psbt_len)
UserAuthorizedAction.cleanup()
- UserAuthorizedAction.active_request = ApproveTransaction(psbt_len, approved_cb=done)
+ UserAuthorizedAction.active_request = ApproveTransaction(
+ psbt_len, input_method="qr", output_encoder=output_encoder,
+ miniscript_wallet=miniscript_wallet,
+ )
the_ux.push(UserAuthorizedAction.active_request)
async def ux_visualize_txn(bin_txn):
@@ -1056,7 +1098,7 @@ async def ux_visualize_txn(bin_txn):
msg += '\n\nTxid:\n' + b2a_hex(txid).decode()
except Exception as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
msg = "Unable to deserialize"
await ux_show_story(msg, title="Signed Transaction")
@@ -1068,7 +1110,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:
@@ -1097,9 +1139,10 @@ async def ux_visualize_bip21(proto, addr, args):
if ch == '1':
from ownership import OWNERSHIP
- await OWNERSHIP.search_ux(addr)
+ await OWNERSHIP.search_ux(addr, args)
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
+ # TODO: remove until we support signing w/ WIF keys IMHO
from ux import ux_show_story
msg = wif_str + "\n\n"
msg += "chain: %s\n\n" % ("XTN" if testnet else "BTC")
@@ -1107,15 +1150,48 @@ 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 msgsign import rfc_signature_template
+ 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(addr=address, msg=text,
+ sig=sig))
+ await show_bbqr_codes("U", armored_str, "Armored MSG")
+
+async def qr_sign_msg(txt):
+ from msgsign 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. "
+
+ 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...
@@ -1128,10 +1204,13 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
# - BUT: need zlib compress (not present) .. delayed for now
from bbqr import TYPE_LABELS, int2base36, b32encode, num_qr_needed
from glob import PSRAM, dis
- from ux import ux_wait_keyup, ux_wait_keydown
+ from ux import ux_wait_keydown
import uqr
- assert not PSRAM.is_at(data, 0) # input data would be overwritten with our work
+ # put QR shenanigans at offset 1MB after TXN_OUTPUT_OFFSET
+ TMP_OFFSET = const(3 * 1024 * 1024)
+
+ assert not PSRAM.is_at(data, TMP_OFFSET) # output data would be overwritten with our work
assert type_code in TYPE_LABELS
dis.fullscreen('Generating BBQr...', .1)
@@ -1156,20 +1235,25 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
# BBQr header
hdr = 'B$' + encoding + type_code + int2base36(num_parts) + int2base36(pkt)
- # encode the bytes
assert pos < data_len, (pkt, pos, data_len)
if already_hex:
- # not encoding, just chars->bytes
+ # not encoding, just hex string
hp = pos*2
- body = data[hp:hp+(part_size*2)].decode()
+ body = data[hp:hp+(part_size*2)]
else:
- # base32 encoding
+ # encode bytes to base32 encoding
body = b32encode(data[pos:pos+part_size])
pos += part_size
+ # first packet, want to discover a working small value for QR version
+ if pkt == 0:
+ mnv = 10 if num_parts > 1 else 1
+ else:
+ mnv = force_version
+
# do the hard work
- qr_data = uqr.make(hdr+body, min_version=(10 if pkt == 0 else force_version),
+ qr_data = uqr.make(hdr+body, min_version=mnv,
max_version=force_version, encoding=uqr.Mode_ALPHANUMERIC)
# save the rendered QR
@@ -1183,11 +1267,11 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
else:
_, _, raw = qr_data.packed()
- PSRAM.write_at(qr_size * pkt, qr_size)[0:raw_qr_size] = raw
+ PSRAM.write_at(TMP_OFFSET + (qr_size * pkt), qr_size)[0:raw_qr_size] = raw
del qr_data
- dis.progress_bar_show((pkt+1) / num_parts)
+ dis.progress_sofar((pkt+1), num_parts)
# display rate (plus time to send to display, etc)
ms_per_each = 200
@@ -1199,7 +1283,7 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
ch = None
while not ch:
for pkt in range(num_parts):
- buf = PSRAM.read_at(qr_size * pkt, raw_qr_size)
+ buf = PSRAM.read_at(TMP_OFFSET + (qr_size * pkt), raw_qr_size)
dis.draw_qr_display( (scan_w, w, buf), msg, True, None, None, False,
partial_bar=((pkt, num_parts) if num_parts else None))
diff --git a/shared/vdisk.py b/shared/vdisk.py
index 8b4fcab41..1ac66f98e 100644
--- a/shared/vdisk.py
+++ b/shared/vdisk.py
@@ -79,7 +79,7 @@ def mount(self, readonly=False):
# corrupt or unformated?
# XXX incomplete error handling here; needs work
VBLKDEV.set_inserted(True)
- sys.print_exception(exc)
+ # sys.print_exception(exc)
return None
@@ -93,7 +93,7 @@ def sample(self):
return list(sorted(('/vdisk/'+fn, sz) for (fn,ty,_,sz) in os.ilistdir('/vdisk')
if ty == 0x8000))
except BaseException as exc:
- sys.print_exception(exc)
+ # sys.print_exception(exc)
return []
finally:
@@ -111,10 +111,10 @@ def import_file(self, filename, sz):
return actual
- def new_psbt(self, filename, sz):
+ def new_psbt(self, filename):
# New incoming PSBT has been detected, start to sign it.
from auth import sign_psbt_file
- uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True))
+ uasyncio.create_task(sign_psbt_file(filename, force_vdisk=True, ux_abort=True))
def new_firmware(self, filename, sz):
# potential new firmware file detected
@@ -157,9 +157,9 @@ def host_done_handler(self):
lfn = fn.lower()
- if lfn.endswith('.psbt') and sz > 100:
+ if lfn.endswith('.psbt') and sz > 100 and ("-signed" not in lfn):
self.ignore.add(fn)
- self.new_psbt(fn, sz)
+ self.new_psbt(fn)
break
if lfn.endswith('.dfu') and sz > FW_MIN_LENGTH:
diff --git a/shared/version.py b/shared/version.py
index 1a528751a..015c38460 100644
--- a/shared/version.py
+++ b/shared/version.py
@@ -4,7 +4,8 @@
#
# REMINDER: update simulator version of this file if API changes are made.
#
-from public_constants import MAX_TXN_LEN, MAX_UPLOAD_LEN
+from public_constants import MAX_TXN_LEN_MK4 as MAX_TXN_LEN
+from public_constants import MAX_UPLOAD_LEN_MK4 as MAX_UPLOAD_LEN
def decode_firmware_header(hdr):
from sigheader import FWH_PY_FORMAT
@@ -76,14 +77,12 @@ def probe_system():
# run-once code to determine what hardware we are running on
global hw_label, has_608, is_factory_mode, is_devmode, has_psram, is_edge
global has_se2, mk_num, has_nfc, has_qr, num_sd_slots, has_qwerty, has_battery, supports_hsm
- global MAX_UPLOAD_LEN, MAX_TXN_LEN
from sigheader import RAM_BOOT_FLAGS, RBF_FACTORY_MODE
import ckcc, callgate, machine
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
@@ -122,10 +121,8 @@ def probe_system():
# what firmware signing key did we boot with? are we in dev mode?
is_devmode = get_is_devmode()
- # increase size limits for mk4
- from public_constants import MAX_TXN_LEN_MK4, MAX_UPLOAD_LEN_MK4
- MAX_UPLOAD_LEN = MAX_UPLOAD_LEN_MK4
- MAX_TXN_LEN = MAX_TXN_LEN_MK4
+ # newer, edge code in effect?
+ is_edge = (get_mpy_version()[1][-1] == 'X')
probe_system()
diff --git a/shared/wallet.py b/shared/wallet.py
index 016b8a32b..4bc9f53bc 100644
--- a/shared/wallet.py
+++ b/shared/wallet.py
@@ -2,12 +2,33 @@
#
# wallet.py - A place you find UTXO, addresses and descriptors.
#
-import chains
-from descriptor import Descriptor
-from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
-from stash import SensitiveValues
+
+import ngu, ujson, uio, chains, ure, version, stash
+from binascii import hexlify as b2a_hex
+from serializations import ser_string
+from desc_utils import bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy, Key
+from public_constants import AF_P2TR, AF_P2WSH, AF_CLASSIC, AF_P2SH, AF_P2WSH_P2SH
+from menu import MenuSystem, MenuItem, start_chooser
+from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_enter_bip32_index
+from files import CardSlot, CardMissingError, needs_microsd
+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
+from glob import settings, DESC_CACHE
+
+# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport
+KT_RXPUBKEY_DERIV = const(20250317)
+
+# PSBT Xpub trust policies
+TRUST_VERIFY = const(0)
+TRUST_OFFER = const(1)
+TRUST_PSBT = const(2)
MAX_BIP32_IDX = (2 ** 31) - 1
+MAX_NAME_LEN = 30 # use (almost) full potential of Q screen
+
+class WalletOutOfSpace(RuntimeError):
+ pass
+
class WalletABC:
# How to make this ABC useful without consuming memory/code space??
@@ -18,13 +39,13 @@ class WalletABC:
# chain
def yield_addresses(self, start_idx, count, change_idx=0):
- # TODO: returns various tuples, with at least (idx, address, ...)
+ # returns various tuples, with at least (idx, address, ...)
pass
def render_address(self, change_idx, idx):
# make one single address as text.
- tmp = list(self.yield_addresses(idx, 1, change_idx=change_idx))
+ tmp = list(self.yield_addresses(idx, 1, change_idx))
assert len(tmp) == 1
assert tmp[0][0] == idx
@@ -41,17 +62,16 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None):
# - 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_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)
+ if not version.has_qwerty:
+ # Mk4 tiny display
+ # Classic P2PKH -> P2PKH
+ # Segwit P2WPKH -> P2WPKH
+ # P2SH-Segwit -> no change (should not be used that much)
+ n = n.split(" ")[-1]
+
+ 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)
@@ -59,13 +79,13 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None):
self.chain = chains.current_chain()
if account_idx != 0:
- n += ' Account#%d' % account_idx
+ rv = " Account#%d" if version.has_qwerty else " Acct#%d"
+ n += rv % account_idx
if self.chain.ctype == 'XTN':
- n += ' (Testnet)'
+ n += ' (Testnet)' if version.has_qwerty else " XTN"
if self.chain.ctype == 'XRT':
- n += ' (Regtest)'
-
+ n += ' (Regtest)' if version.has_qwerty else " XRT"
self.name = n
self.addr_fmt = addr_fmt
@@ -82,7 +102,6 @@ def __init__(self, addr_fmt, path=None, account_idx=0, chain_name=None):
self._path = p
-
def yield_addresses(self, start_idx, count, change_idx=None):
# Render a range of addresses. Slow to start, since accesses SE in general
# - if count==1, don't derive any subkey, just do path.
@@ -91,7 +110,7 @@ def yield_addresses(self, start_idx, count, change_idx=None):
assert 0 <= change_idx <= 1
path += '/%d' % change_idx
- with SensitiveValues() as sv:
+ with stash.SensitiveValues() as sv:
node = sv.derive_path(path)
if count is None: # special case - showing single, ignoring start_idx
@@ -116,7 +135,7 @@ def yield_addresses(self, start_idx, count, change_idx=None):
def render_address(self, change_idx, idx):
# Optimized for a single address.
path = self._path + '/%d/%d' % (change_idx, idx)
- with SensitiveValues() as sv:
+ with stash.SensitiveValues() as sv:
node = sv.derive_path(path)
return self.chain.address(node, self.addr_fmt)
@@ -125,11 +144,1310 @@ def render_path(self, change_idx, idx):
return self._path + '/%d/%d' % (change_idx, idx)
def to_descriptor(self):
- from glob import settings
+ from descriptor import Descriptor, Key
xfp = settings.get('xfp')
xpub = settings.get('xpub')
- keys = (xfp, self._path, xpub)
- return Descriptor([keys], self.addr_fmt)
+ d = Descriptor(key=Key.from_cc_data(xfp, self._path, xpub), addr_fmt=self.addr_fmt)
+ return d
+
+
+class MiniScriptWallet(WalletABC):
+ skey = "miniscript"
+ # optional: user can short-circuit many checks (system wide, one power-cycle only)
+ disable_checks = False
+
+ def __init__(self, name, desc_tmplt, keys_info, af, ik_u=None,
+ desc=None, m_n=None, bip67=None, chain_type=None):
+
+ assert 1 <= len(name) <= MAX_NAME_LEN, "name len"
+
+ self.storage_idx = -1
+ self.name = name
+ self.desc_tmplt = desc_tmplt
+ self.keys_info = keys_info
+ self.desc = desc
+ self.addr_fmt = af
+ self.ik_u = ik_u # internal key unspendable (taproot only)
+
+ # below are basic multisig meta
+ # if m_n is not None, we are dealing with basic multisig
+ self.m_n = m_n
+ self.bip67 = bip67
+
+ # at this point all the keys are already validated
+ self.chain_type = chain_type or chains.current_chain().ctype
+
+ def serialize(self):
+ opts = {"af": self.addr_fmt}
+ if self.ik_u is not None:
+ opts['ik_u'] = self.ik_u
+ if self.chain_type != "BTC":
+ opts['ct'] = self.chain_type
+ if self.m_n:
+ opts['m_n'] = self.m_n
+ opts['b67'] = self.bip67
+
+ return self.name, self.desc_tmplt, self.keys_info, opts
+
+ @classmethod
+ def deserialize(cls, c, idx=-1):
+ # after deserialization - we lack loaded descriptor object
+ # we do not need it for everything
+ needs_migration = False
+ if len(c) == 4:
+ name, desc_tmplt, keys_info, opts = c
+ else:
+ # needs migration
+ name, desc_tmplt, keys_info, opts = miniscript_640_migrate(c)
+ needs_migration = True
+
+ af = opts.get("af")
+ ct = opts.get("ct", "BTC")
+ ik_u = opts.get("ik_u", False)
+ m_n = opts.get("m_n", None)
+ b67 = opts.get("b67", None)
+
+ rv = cls(name, desc_tmplt, keys_info, af, ik_u, m_n=m_n,
+ bip67=b67, chain_type=ct)
+ rv.storage_idx = idx
+ return rv, needs_migration
+
+ @property
+ def chain(self):
+ return chains.get_chain(self.chain_type)
+
+ @property
+ def key_chain(self):
+ return chains.get_chain("XTN" if self.chain_type == "XRT" else self.chain_type)
+
+ @classmethod
+ def exists(cls):
+ # are there any wallets defined?
+ return bool(settings.get(cls.skey, []))
+
+ @classmethod
+ def iter_wallets(cls, name=None, addr_fmts=None):
+ # - this is only place we should be searching this list, please!!
+ lst = settings.get(cls.skey, [])
+ for idx in range(len(lst)):
+ w, migrate = cls.deserialize(lst[idx], idx)
+ if migrate:
+ if idx == 0:
+ from glob import dis
+ dis.fullscreen("Migrating...")
+
+ lst[idx] = w.serialize()
+ settings.set(cls.skey, lst)
+ settings.save()
+
+ if w.key_chain.ctype != chains.current_key_chain().ctype:
+ continue
+ if name and name != w.name:
+ continue
+ if addr_fmts and w.addr_fmt not in addr_fmts:
+ continue
+
+ yield w
+
+ def commit(self):
+ # data to save
+ # - important that this fails immediately when nvram overflows
+ obj = self.serialize()
+
+ v = settings.get(self.skey, [])
+ orig = v.copy()
+ if not v or self.storage_idx == -1:
+ # create
+ self.storage_idx = len(v)
+ v.append(obj)
+ else:
+ # update in place
+ v[self.storage_idx] = obj
+
+ settings.set(self.skey, v)
+
+ # save now, rather than in background, so we can recover
+ # from out-of-space situation
+ try:
+ settings.save()
+ except:
+ # back out change; no longer sure of NVRAM state
+ try:
+ settings.set(self.skey, orig)
+ settings.save()
+ except: pass # give up on recovery
+
+ raise WalletOutOfSpace
+
+ def delete(self):
+ # remove saved entry
+ # - important: not expecting more than one instance of this class in memory
+ assert self.storage_idx >= 0
+ lst = settings.get(self.skey, [])
+ try:
+ del lst[self.storage_idx]
+ if lst:
+ settings.set(self.skey, lst)
+ else:
+ settings.remove_key(self.skey)
+
+ settings.save() # actual write
+ except IndexError: pass
+ self.storage_idx = -1
+
+ @classmethod
+ def get_trust_policy(cls):
+ which = settings.get('pms', None)
+ if which is None:
+ which = TRUST_VERIFY if cls.exists() else TRUST_OFFER
+
+ return which
+
+ @classmethod
+ def find_match(cls, xfp_paths, addr_fmt=None, M=None, N=None):
+ for rv in cls.iter_wallets():
+ if addr_fmt is not None:
+ if rv.addr_fmt != addr_fmt:
+ continue
+
+ if M and N:
+ if not rv.m_n:
+ continue
+
+ m, n = rv.m_n
+ if m != M or n != N:
+ continue
+
+ if rv.matching_subpaths(xfp_paths):
+ return rv
+
+ return None
+
+ def xfp_paths(self, skip_unspend_ik=False):
+ if not self.desc:
+ res = []
+ for i, k_str in enumerate(self.keys_info):
+ if not i and self.ik_u and skip_unspend_ik:
+ continue
+ k = Key.from_string(k_str)
+ res.append(k.origin.psbt_derivation())
+ return res
+
+ return self.desc.xfp_paths(skip_unspend_ik=skip_unspend_ik)
+
+ def matching_subpaths(self, xfp_paths):
+ my_xfp_paths = self.to_descriptor().xfp_paths()
+
+ if len(xfp_paths) != len(my_xfp_paths):
+ return False
+
+ for x in my_xfp_paths:
+ prefix_len = len(x)
+ for y in xfp_paths:
+ if x == y[:prefix_len]:
+ break
+ else:
+ return False
+ return True
+
+ def subderivation_indexes(self, xfp_paths):
+ # we already know that they do match
+ my_xfp_paths = self.to_descriptor().xfp_paths()
+ res = set()
+ for x in my_xfp_paths:
+ prefix_len = len(x)
+ for y in xfp_paths:
+ if x == y[:prefix_len]:
+ to_derive = tuple(y[prefix_len:])
+ res.add(to_derive)
+
+ err = "derivation indexes"
+ assert res, err
+ if len(res) == 1:
+ branch, idx = list(res)[0]
+ else:
+ branch = [i[0] for i in res]
+ indexes = set([i[1] for i in res])
+ assert len(indexes) == 1, err
+ idx = list(indexes)[0]
+
+ return branch, idx
+
+ def get_my_deriv(self):
+ # returns derivation path of the first "our" key in keys info vector
+ # used for signed exports only
+ str_xfp = xfp2str(settings.get('xfp'))
+ for ek in self.keys_info:
+ orig_end = ek.find("]")
+ if orig_end == -1:
+ continue # key without origin
+
+ orig = ek[1:orig_end]
+ fp_end = orig.find("/")
+ if fp_end == -1:
+ master_fp = orig
+ fp_end = len(orig)
+ else:
+ master_fp = orig[:fp_end]
+
+ if master_fp.upper() == str_xfp:
+ return "m" + orig[fp_end:]
+
+ # didn't find any origin info
+ # BUT we know that our key is included (verified on import)
+ # therefore our key root key
+ return "m"
+
+ def derive_desc(self, xfp_paths):
+ branch, idx = self.subderivation_indexes(xfp_paths)
+ derived_desc = self.desc.derive(branch).derive(idx)
+ return derived_desc
+
+ def validate_script_pubkey(self, script_pubkey, xfp_paths, merkle_root=None):
+ derived_desc = self.derive_desc(xfp_paths)
+ derived_spk = derived_desc.script_pubkey()
+ assert derived_spk == script_pubkey, "spk mismatch\n\ncalc:\n%s\n\npsbt:\n%s" % (
+ b2a_hex(derived_spk).decode(), b2a_hex(script_pubkey).decode()
+ )
+ if merkle_root:
+ calc = derived_desc.tapscript.merkle_root
+ assert calc == merkle_root, "merkle root mismatch\n\ncalc:\n%s\n\npsbt:\n%s" % (
+ b2a_hex(calc).decode(), b2a_hex(merkle_root).decode()
+ )
+ return derived_desc
+
+ def detail(self):
+ s = "Wallet Name:\n %s\n\n" % self.name
+ if self.m_n:
+ # basic multisig
+ M, N = self.m_n
+ s += "Policy: %d of %d\n\n" % (M, N)
+
+ if M == N == 1:
+ s += 'The one signer must approve spends.'
+ elif M == N:
+ s += 'All %d co-signers must approve spends.' % N
+ elif M == 1:
+ s += 'Any signature from %d co-signers will approve spends.' % N
+ else:
+ s += '%d signatures, from %d possible co-signers, will be required to approve spends.' % (M, N)
+
+ s += "\n\n"
+
+ s += chains.addr_fmt_label(self.addr_fmt)
+ s += "\n\n" + self.desc_tmplt
+ return s
+
+ async def show_detail(self, story="", offer_import=False):
+ story += self.detail()
+ story += "\n\nPress (1) to see extended public keys"
+
+ if offer_import:
+ story += ", OK to approve, X to cancel."
+
+ while True:
+ ch = await ux_show_story(story, escape="1")
+ if ch == "1":
+ await self.show_keys()
+
+ elif (ch == "y") and offer_import:
+ return True
+ elif ch == "x":
+ return False
+
+ async def show_keys(self):
+ msg = ""
+ for idx, k_str in enumerate(self.keys_info):
+ if idx:
+ msg += '\n---===---\n\n'
+ elif self.addr_fmt == AF_P2TR:
+ # index 0, taproot internal key
+ msg += "Taproot internal key:\n\n"
+ if self.ik_u:
+ msg += "(provably unspendable)\n\n"
+
+ msg += '@%s:\n %s\n\n' % (idx, k_str)
+
+ await ux_show_story(msg)
+
+ def to_descriptor(self):
+ if self.desc is None:
+ # actual descriptor is not loaded, but was asked for
+ # fill policy - aka storage format - to actual descriptor
+
+ if self.name in DESC_CACHE:
+ # loaded descriptor from cache
+ self.desc = DESC_CACHE[self.name]
+ else:
+ # print("loading... policy --> descriptor !!!")
+ # no need to validate already saved descriptor - was validated upon enroll
+ self.desc = self._from_bip388_wallet_policy(self.desc_tmplt, self.keys_info,
+ validate=False)
+ # cache len always 1
+ DESC_CACHE.clear()
+ DESC_CACHE[self.name] = self.desc
+
+ return self.desc
+
+ @staticmethod
+ def _from_bip388_wallet_policy(desc_template, keys_info, validate=True):
+ desc_str = bip388_wallet_policy_to_descriptor(
+ desc_template.replace("/<0;1>/*", "/**"),
+ keys_info
+ )
+ from descriptor import Descriptor
+ desc_obj = Descriptor.from_string(desc_str)
+ if validate:
+ desc_obj.validate(MiniScriptWallet.disable_checks)
+ return desc_obj
+
+ @classmethod
+ def from_bip388_wallet_policy(cls, name, desc_template, keys_info):
+ bip388_validate_policy(desc_template, keys_info)
+ desc_obj = cls._from_bip388_wallet_policy(desc_template, keys_info)
+ msc = cls.from_descriptor_obj(name, desc_obj, desc_template, keys_info)
+ return msc
+
+ @classmethod
+ def from_descriptor_obj(cls, name, desc_obj, desc_tmplt=None, keys_info=None):
+ if not desc_tmplt or not keys_info:
+ # BIP388 wasn't generated yet - generating from descriptor upon import/enroll
+ desc_tmplt, keys_info = desc_obj.bip388_wallet_policy()
+ # self-validation
+ bip388_validate_policy(desc_tmplt, keys_info)
+
+ ik_u = desc_obj.key and desc_obj.key.is_provably_unspendable
+ af = desc_obj.addr_fmt
+ m_n = None
+ bip67 = None
+ if desc_obj.is_basic_multisig:
+ m_n = desc_obj.miniscript.m_n()
+ bip67 = desc_obj.is_sortedmulti
+
+ return cls(name, desc_tmplt, keys_info, af, ik_u, desc_obj, m_n, bip67)
+
+ @classmethod
+ def from_file(cls, config, name=None, bip388=False):
+ from descriptor import Descriptor
+
+ if bip388:
+ # config is JSON wallet policy
+ wal = cls.from_bip388_wallet_policy(config["name"], config["desc_template"],
+ config["keys_info"])
+ else:
+ if name is None:
+ desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True)
+ name = cs
+ else:
+ name = to_ascii_printable(name)
+ desc_obj = Descriptor.from_string(config.strip())
+
+ desc_obj.validate(cls.disable_checks)
+
+ wal = cls.from_descriptor_obj(name, desc_obj)
+
+ return wal
+
+ @classmethod
+ def import_from_psbt(cls, addr_fmt, M, N, xpubs_list):
+ # given the raw data from PSBT global header, offer the user
+ # the details, and/or bypass that all and just trust the data.
+ # - xpubs_list is a list of (xfp+path, binary BIP-32 xpub)
+ # - already know not in our records.
+ from descriptor import Descriptor
+ from miniscript import Sortedmulti, Number
+
+ # build up an in-memory version of the wallet.
+ # - capture address format based on path used for my leg (if standards compliant)
+
+ assert N == len(xpubs_list)
+ assert 1 <= M <= N <= 20, 'M/N range'
+ my_xfp = settings.get('xfp')
+
+ has_mine = 0
+
+ keys = []
+ for ek, xfp_pth in xpubs_list:
+ k = Key.from_psbt_xpub(ek, xfp_pth)
+ has_mine += k.validate(my_xfp, cls.disable_checks)
+ keys.append(k)
+
+ assert has_mine == 1 # 'my key not included'
+
+ name = 'PSBT-%d-of-%d' % (M, N)
+ # this will always create sortedmulti multisig (BIP-67)
+ # because BIP-174 came years after wide-spread acceptance of BIP-67 policy
+ desc_obj = Descriptor(miniscript=Sortedmulti(Number(M), *keys),
+ addr_fmt=addr_fmt)
+ return cls.from_descriptor_obj(name, desc_obj)
+
+ def validate_psbt_xpubs(self, psbt_xpubs):
+ keys = set()
+ for ek, xfp_pth in psbt_xpubs:
+ key = Key.from_psbt_xpub(ek, xfp_pth)
+ key.validate(settings.get('xfp', 0), self.disable_checks)
+ keys.add(key)
+
+ if not self.disable_checks:
+ assert set(self.to_descriptor().keys) == keys
+
+ def ux_unique_name_msg(self, name=None):
+ return ("%s wallet with name '%s' already exists. All wallets MUST"
+ " have unique names.\n\n" % ("Multisig" if self.m_n else "Miniscript", name or self.name))
+
+ def find_duplicates(self):
+ for rv in self.iter_wallets():
+ assert self.name != rv.name, self.ux_unique_name_msg()
+
+ # optimization miniscript vs. multisig & different M/N multisigs
+ if self.m_n != rv.m_n:
+ # different M/N
+ continue
+
+ err = "Duplicate wallet. Wallet '%s' is the same." % rv.name
+ if self.m_n:
+ # enrolling basic multisig wallet
+ if self.addr_fmt == rv.addr_fmt and sorted(self.keys_info) == sorted(rv.keys_info):
+ if self.bip67 != rv.bip67:
+ err += " BIP-67 clash."
+ err += "\n\n"
+ assert False, err
+
+ else:
+ if self.desc_tmplt == rv.desc_tmplt and self.keys_info == rv.keys_info:
+ assert False, err + "\n\n"
+
+ async def confirm_import(self):
+ # Return T if the user approves of this new wallet
+ try:
+ allow_import = True
+ self.find_duplicates()
+ story = "Create new %s wallet?\n\n" % ('multisig' if self.m_n else 'miniscript')
+ if self.m_n and not self.bip67:
+ story += ("WARNING: BIP-67 disabled! Unsorted multisig - "
+ "order of keys in descriptor/backup is crucial\n\n")
+
+ except AssertionError as e:
+ story, allow_import = str(e), False
+
+ if not await self.show_detail(story, offer_import=allow_import):
+ # user didn't like it, stop
+ return False
+
+ # save new record
+ assert self.storage_idx == -1
+ self.commit()
+
+ # new wallet was imported, so cache its descriptor
+ assert self.desc
+ DESC_CACHE.clear()
+ DESC_CACHE[self.name] = self.desc
+
+ await ux_dramatic_pause("Saved.", 2)
+
+ return True
+
+ def yield_addresses(self, start_idx, count, change_idx=0, scripts=False):
+ ch = chains.current_chain()
+ # change_idx work as boolean here - you cannot specify random change_idx
+ # as it is defined by descriptor
+ dd = self.to_descriptor().derive(None, change=bool(change_idx))
+ idx = start_idx
+ while count:
+ if idx > MAX_BIP32_IDX:
+ break
+ # make the redeem script, convert into address
+ d = dd.derive(idx)
+ scr = d.miniscript.compile() if d.miniscript else None
+ addr = ch.render_address(d.script_pubkey(compiled_scr=scr))
+ ders = script = None
+ if scripts:
+ ders = ""
+ for k in d.keys:
+ ders += "[%s]; " % str(k.origin)
+
+ if d.tapscript:
+ # DFS ordered list of scripts
+ script = ""
+ for leaf_ver, scr, _ in d.tapscript._processed_tree:
+ script += b2a_hex(chains.tapscript_serialize(scr, leaf_ver)).decode() + "; "
+ else:
+ script = b2a_hex(ser_string(scr)).decode()
+
+ yield idx, addr, ders, script
+
+ idx += 1
+ count -= 1
+
+ def make_addresses_msg(self, msg, start, n, change=0):
+ from glob import dis
+
+ addrs = []
+
+ for idx, addr, *_ in self.yield_addresses(start, n, change):
+ msg += '.../%d =>\n' % idx # just idx, if derivations or scripts needed - export csv
+ addrs.append(addr)
+ msg += show_single_address(addr) + '\n\n'
+ dis.progress_sofar(idx - start + 1, n)
+
+ return msg, addrs
+
+ def generate_address_csv(self, start, n, change, saver=None):
+ scripts = settings.get("aemscsv", False)
+ header = ['Index', 'Payment Address']
+ if scripts:
+ header += ['Script', 'Derivations']
+
+ yield '"' + '","'.join(header) + '"\n'
+ for idx, addr, ders, script in self.yield_addresses(start, n, change, scripts=scripts):
+ if saver:
+ saver(addr, idx)
+
+ ln = '%d,"%s"' % (idx, addr)
+ if scripts:
+ ln += ',"%s"' % script
+ ln += ',"%s"' % ders
+ ln += '\n'
+ yield ln
+
+ def to_string(self, checksum=True):
+ # policy filling - not possible to specify internal/external always multipath export
+ # only supported from bitcoin-core 29.0
+ if self.desc_tmplt and self.keys_info:
+ desc = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info)
+ if checksum:
+ desc = append_checksum(desc)
+ return desc
+
+ return self.desc.to_string()
+
+ def bitcoin_core_serialize(self):
+ return [{
+ "desc": self.to_string(), # policy fill
+ "active": True,
+ "timestamp": "now",
+ "range": [0, 100],
+ }]
+
+ async def export_wallet_file(self, core=False, bip388=False, sign=True):
+ # do not load descriptor - just fill policy
+ # only with multipath format <0;1>
+ from glob import NFC, dis
+ from ux import import_export_prompt
+
+ dis.fullscreen('Wait...')
+
+ t = "Multisig" if self.m_n else "Miniscript"
+
+ if core:
+ name = "Bitcoin Core %s" % t
+ fname_pattern = 'bitcoin-core-%s.txt' % self.name
+ msg = "importdescriptors cmd"
+ core_obj = self.bitcoin_core_serialize()
+ core_str = ujson.dumps(core_obj)
+ res = "importdescriptors '%s'\n" % core_str
+ elif bip388:
+ # policy as JSON
+ msg = self.name
+ name = "BIP-388 Wallet Policy"
+ fname_pattern = 'b388-%s.json' % self.name
+ res = ujson.dumps({"name": self.name,
+ "desc_template": self.desc_tmplt,
+ "keys_info": self.keys_info})
+ else:
+ name = t
+ fname_pattern = '%s-%s.txt' % ("multi" if self.m_n else "minsc", self.name)
+ msg = self.name
+ res = self.to_string()
+
+ ch = await import_export_prompt("%s file" % name)
+ if isinstance(ch, str):
+ if ch in "3"+KEY_NFC:
+ if bip388:
+ await NFC.share_json(res)
+ else:
+ await NFC.share_text(res)
+ elif ch == KEY_QR:
+ try:
+ from ux import show_qr_code
+ await show_qr_code(res, msg=msg)
+ except:
+ if version.has_qwerty:
+ from ux_q1 import show_bbqr_codes
+ await show_bbqr_codes('U', res, msg)
+ return
+
+ try:
+ with CardSlot(**ch) as card:
+ fname, nice = card.pick_filename(fname_pattern)
+
+ # do actual write
+ with open(fname, 'w+') as fp:
+ fp.write(res)
+
+ if sign:
+ # sign with my key at the same path as first address of export
+ derive = self.get_my_deriv() + "/0/0"
+ from msgsign import write_sig_file
+ h = ngu.hash.sha256s(res.encode())
+ sig_nice = write_sig_file([(h, fname)], derive, AF_CLASSIC)
+
+ msg = '%s file written:\n\n%s' % (name, nice)
+ if sign:
+ msg += '\n\n%s signature file written:\n\n%s' % (name, sig_nice)
+ await ux_show_story(msg)
+
+ except CardMissingError:
+ await needs_microsd()
+ return
+ except Exception as e:
+ await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e)))
+ return
+
+ def xpubs_from_xfp(self, xfp):
+ # return list of XPUB's which match xfp
+ res = []
+ desc = self.to_descriptor()
+ for k in desc.keys:
+ if k.origin and k.origin.cc_fp == xfp:
+ res.append(k)
+ elif swab32(k.node.my_fp()) == xfp:
+ res.append(k)
+
+ assert res, "missing xfp %s" % xfp2str(xfp)
+ # returned is list of keys with corresponding master xfp
+ # key in list are lexicographically sorted based on their public keys
+ # lowest public key first
+ return sorted(res, key=lambda o: o.serialize())
+
+ def kt_make_rxkey(self, xfp):
+ # Derive the receiver's pubkey from preshared xpub and a special derivation
+ # - also provide the keypair we're using from our side of connection
+ # - returns 4 byte nonce which is sent un-encrypted, his_pubkey and my_keypair
+ ri = ngu.random.uniform(1<<28)
+
+ # sorted lexicographically, always use the lowest pubkey from the list at index 0
+ keys = self.xpubs_from_xfp(xfp)
+ k = keys[0]
+ k = k.derive(KT_RXPUBKEY_DERIV).derive(ri)
+ pubkey = k.node.pubkey()
+
+ kp = self.kt_my_keypair(ri)
+ return ri.to_bytes(4, 'big'), pubkey, kp
+
+ def kt_my_keypair(self, ri):
+ # Calc my keypair for sending PSBT files.
+ #
+ # sorted lexicographically, always use the lowest pubkey from the list at index 0
+ keys = self.xpubs_from_xfp(settings.get('xfp'))
+
+ subpath = "/%d/%d" % (KT_RXPUBKEY_DERIV, ri)
+ path = keys[0].origin.str_derivation() + subpath
+ with stash.SensitiveValues() as sv:
+ node = sv.derive_path(path)
+ kp = ngu.secp256k1.keypair(node.privkey())
+ return kp
+
+ @classmethod
+ def kt_search_rxkey(cls, payload):
+ # Construct the keypair for to be decryption
+ # - has to try pubkey each all the unique XFP for all co-signers in all wallets
+ # - checks checksum of ECDH unwrapped data to see if it's the right one
+ # - returns session key, decrypted first layer, and XFP of sender
+ from teleport import decode_step1
+
+ # this nonce is part of the derivation path so each txn gets new keys
+ ri = int.from_bytes(payload[0:4], 'big')
+
+ my_xfp = settings.get('xfp')
+
+ for msc in cls.iter_wallets():
+ kp = msc.kt_my_keypair(ri)
+ for k in msc.to_descriptor().keys:
+ if k.origin.cc_fp == my_xfp:
+ continue
+ kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri)
+ his_pubkey = kk.node.pubkey()
+ # if implied session key decodes the checksum, it is right
+ ses_key, body = decode_step1(kp, his_pubkey, payload[4:])
+ if ses_key:
+ return ses_key, body, kk.origin.cc_fp
+
+ return None, None, None
+
+ async def export_electrum(self):
+ # Generate and save an Electrum JSON file.
+ from export import export_contents
+
+ assert self.m_n, "not multisig"
+ M, N = self.m_n
+
+ def doit():
+ rv = dict(seed_version=17, use_encryption=False,
+ wallet_type='%dof%d' % (M, N))
+
+ ch = self.chain
+
+ # the important stuff.
+ for idx, key in enumerate(self.to_descriptor().keys):
+ # CHALLENGE: we must do slip-132 format [yz]pubs here when not p2sh mode.
+ xp = ch.serialize_public(key.node, self.addr_fmt)
+
+ rv['x%d/' % (idx + 1)] = {"hw_type":"coldcard", "type":"hardware",
+ "ckcc_xfp": key.origin.cc_fp, "xpub":xp,
+ "label":"Coldcard %s" % xfp2str(key.origin.cc_fp),
+ "derivation":key.origin.str_derivation()}
+
+ # sign export with first p2pkh key
+ return ujson.dumps(rv), self.get_my_deriv() + "/0/0", AF_CLASSIC
+
+ fname = '%s-%s.%s' % ("el", self.name.replace(" ", "_"), "json")
+ await export_contents('Electrum multisig wallet', doit,
+ fname, is_json=True)
+
+async def miniscript_delete(msc):
+ if not await ux_confirm("Delete miniscript wallet '%s'?\n\nFunds may be impacted." % msc.name):
+ await ux_dramatic_pause('Aborted.', 3)
+ return
+
+ msc.delete()
+ await ux_dramatic_pause('Deleted.', 3)
+
+async def miniscript_wallet_delete(menu, label, item):
+ msc = item.arg
+
+ await miniscript_delete(msc)
+
+ from ux import the_ux
+ # pop stack
+ the_ux.pop()
+
+ m = the_ux.top_of_stack()
+ m.update_contents()
+
+async def miniscript_wallet_rename(menu, label, item):
+ from glob import dis
+ from ux import ux_input_text, the_ux
+
+ idx, msc = item.arg
+ new_name = await ux_input_text(msc.name, confirm_exit=False,
+ min_len=1, max_len=MAX_NAME_LEN)
+
+ if not new_name:
+ return
+
+ wallets = settings.get("miniscript", [])
+ names = [i[0] for i in wallets]
+ if new_name in names:
+ await ux_show_story(msc.ux_unique_name_msg(new_name), title="FAILED")
+ return
+
+ dis.fullscreen("Saving...")
+
+ # save it
+ wal = list(wallets[idx])
+ wal[0] = new_name
+ # it will become list after JSON encode/decode anyways
+ wallets[idx] = wal
+ msc.name = new_name
+ settings.set("miniscript", wallets)
+
+ # update label in sub-menu
+ menu.items[0].label = new_name
+ # and name in parent menu too
+ parent = the_ux.parent_of(menu)
+ if parent:
+ parent.update_contents()
+
+async def miniscript_wallet_detail(menu, label, item):
+ # show details of single multisig wallet
+ msc = item.arg
+ return await msc.show_detail()
+
+async def import_miniscript(*a):
+ # pick text file from SD card, import as multisig setup file
+ from actions import file_picker
+ from ux import import_export_prompt
+
+ ch = await import_export_prompt("miniscript wallet file", is_import=True)
+ if isinstance(ch, str):
+ if ch == KEY_QR:
+ await import_miniscript_qr()
+ elif ch == KEY_NFC:
+ await import_miniscript_nfc()
+ return
+
+ def possible(filename):
+ with open(filename, 'rt') as fd:
+ for ln in fd:
+ if "sh(" in ln or "wsh(" in ln or "tr(" in ln:
+ # descriptor import
+ return True
+
+ fn = await file_picker(suffix=['.txt', '.json'], min_size=100,
+ taster=possible, **ch)
+ if not fn: return
+
+ try:
+ with CardSlot(**ch) as card:
+ with open(fn, 'rt') as fp:
+ data = fp.read()
+ except CardMissingError:
+ await needs_microsd()
+ return
+
+ from auth import maybe_enroll_xpub
+ try:
+ possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None
+ maybe_enroll_xpub(config=data, name=possible_name)
+ except BaseException as e:
+ await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e)))
+
+async def import_miniscript_nfc(*a):
+ from glob import NFC
+ try:
+ return await NFC.import_miniscript_nfc()
+ except Exception as e:
+ await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e)))
+
+async def import_miniscript_qr(*a):
+ from auth import maybe_enroll_xpub
+ from ux_q1 import QRScannerInteraction
+ data = await QRScannerInteraction().scan_text('Scan Multisig/Miniscript from a QR code')
+ if not data:
+ # press pressed CANCEL
+ return
+ try:
+ maybe_enroll_xpub(config=data)
+ except Exception as e:
+ await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e)))
+
+async def miniscript_wallet_export(menu, label, item):
+ # create a text file with the details; ready for import to next Coldcard
+ msc = item.arg[0]
+ kwargs = item.arg[1]
+ await msc.export_wallet_file(**kwargs)
+
+async def miniscript_wallet_descriptors(menu, label, item):
+ # descriptor menu
+ msc = item.arg
+ if not msc:
+ return
+
+ rv = [
+ MenuItem('Export', f=miniscript_wallet_export, arg=(msc, {"core": False})),
+ MenuItem('Bitcoin Core', f=miniscript_wallet_export, arg=(msc, {"core": True})),
+ MenuItem('BIP-388 Policy', f=miniscript_wallet_export, arg=(msc, {"bip388":True})),
+ ]
+ return rv
+
+async def miniscript_sign_psbt(a, b, item):
+ from actions import _ready2sign
+ await _ready2sign(probe=False, miniscript_wallet=item.arg)
+
+async def make_miniscript_wallet_menu(menu, label, item):
+ # details, actions on single multisig wallet
+ idx, msc = item.arg
+
+ rv = [
+ MenuItem('"%s"' % msc.name, f=miniscript_wallet_detail, arg=msc),
+ MenuItem('View Details', f=miniscript_wallet_detail, arg=msc),
+ MenuItem('Descriptors', menu=miniscript_wallet_descriptors, arg=msc),
+ MenuItem('Sign PSBT', f=miniscript_sign_psbt, arg=msc),
+ MenuItem('Rename', f=miniscript_wallet_rename, arg=(idx, msc)),
+ MenuItem('Delete', f=miniscript_wallet_delete, arg=msc),
+ ]
+ if msc.m_n and msc.bip67:
+ # basic multisig but only sortedmulti
+ rv.append(MenuItem('Electrum Wallet', f=multisig_electrum_export, arg=msc))
+
+ return rv
+
+
+class MiniscriptMenu(MenuSystem):
+ @classmethod
+ def construct(cls):
+ import version
+ from menu import ShortcutItem
+ from bsms import make_ms_wallet_bsms_menu
+ from multisig import create_ms_step1
+
+ rv = []
+ for i, msc in enumerate(MiniScriptWallet.iter_wallets()):
+ rv.append(MenuItem('%s' % msc.name, menu=make_miniscript_wallet_menu, arg=(i,msc)))
+
+ rv = rv or [MenuItem("(none setup yet)")]
+
+ from glob import NFC
+ rv.append(MenuItem('Import', f=import_miniscript))
+ rv.append(MenuItem('Export XPUB', f=export_miniscript_xpubs))
+ rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu))
+ 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(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None,
+ f=import_miniscript_nfc))
+ rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty,
+ f=import_miniscript_qr))
+ return rv
+
+ def update_contents(self):
+ # Reconstruct the list of wallets on this dynamic menu, because
+ # we added or changed them and are showing that same menu again.
+ tmp = self.construct()
+ self.replace_items(tmp)
+
+async def make_miniscript_menu(*a):
+ # list of all multisig wallets, and high-level settings/actions
+ from pincodes import pa
+
+ if pa.is_secret_blank():
+ await ux_show_story("You must have wallet seed before creating miniscript wallets.")
+ return
+
+ # 6.4.0 multisig migration is done in login_sequence
+ # this is duplicate for users that have multisig wallets stored in tmp seed settings
+ # executes upon entry to "Multisig/Miniscript" menu
+ await do_640_multisig_migration()
+
+ rv = MiniscriptMenu.construct()
+ return MiniscriptMenu(rv)
+
+
+def disable_checks_chooser():
+ ch = ['Normal', 'Skip Checks']
+
+ def xset(idx, text):
+ MiniScriptWallet.disable_checks = bool(idx)
+
+ return int(MiniScriptWallet.disable_checks), ch, xset
+
+async def disable_checks_menu(*a):
+
+ if not MiniScriptWallet.disable_checks:
+ ch = await ux_show_story('''\
+With many different wallet vendors and implementors involved, it can \
+be hard to create a PSBT consistent with the many keys involved. \
+With this setting, you can \
+disable the more stringent verification checks your Coldcard normally provides.
+
+USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \
+not be accepted by network.
+
+This settings lasts only until power down.
+
+Press (4) to confirm entering this DANGEROUS mode.
+''', escape='4')
+
+ if ch != '4': return
+
+ start_chooser(disable_checks_chooser)
+
+
+def psbt_xpubs_policy_chooser():
+ # Chooser for trust policy
+ ch = ['Verify Only', 'Offer Import', 'Trust PSBT']
+
+ def xset(idx, text):
+ settings.set('pms', idx)
+
+ return MiniScriptWallet.get_trust_policy(), ch, xset
+
+async def trust_psbt_menu(*a):
+ # show a story then go into chooser
+
+ ch = await ux_show_story('''\
+This setting controls what the Coldcard does \
+with the co-signer public keys (XPUB) that may \
+be provided inside a PSBT file. Three choices:
+
+- Verify Only. Do not import the xpubs found, but do \
+verify the correct wallet already exists on the Coldcard.
+
+- Offer Import. If it's a new multisig wallet, offer to import \
+the details and store them as a new wallet in the Coldcard.
+
+- Trust PSBT. Use the wallet data in the PSBT as a temporary,
+multisig wallet, and do not import it. This permits some \
+deniability and additional privacy.
+
+When the XPUB data is not provided in the PSBT, regardless of the above, \
+we require the appropriate multisig wallet to already exist \
+on the Coldcard. Default is to 'Offer' unless a multisig wallet already \
+exists, otherwise 'Verify'.''')
+
+ if ch == 'x': return
+ start_chooser(psbt_xpubs_policy_chooser)
+
+
+async def multisig_electrum_export(menu, label, item):
+ # create a JSON file that Electrum can use. Challenges:
+ # - file contains derivation paths for each co-signer to use
+ # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like:
+ # m/48h/1h/0h/2h
+ # - above is now called BIP-48
+ # - other signers might not be coldcards (we don't know)
+ # solution:
+ # - when building air-gap, pick address type at that point, and matching path to suit
+ # - could check path prefix and addr_fmt make sense together, but meh.
+ msc = item.arg
+ await msc.export_electrum()
+
+
+async def export_miniscript_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False):
+ # WAS: Create a single text file with lots of docs, and all possible useful xpub values.
+ # THEN: Just create the one-liner xpub export value they need/want to support BIP-45
+ # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path
+ #
+ # - consumer for this file is supposed to be ourselves, when we build on-device multisig.
+ # - however some 3rd parties are making use of it as well.
+ # - used for CCC feature now as well, but result looks just like normal export
+ #
+ xfp = xfp2str(xfp or settings.get('xfp', 0))
+ chain = chains.current_chain()
+
+ fname_pattern = 'ccxp-%s.json' % xfp
+ label = "Multisig XPUB"
+
+ if not skip_prompt:
+ msg = '''\
+This feature creates a small file containing \
+the extended public keys (XPUB) you would need to join \
+a multisig wallet.
+
+Public keys for BIP-48 conformant paths are used:
+
+P2SH-P2WSH:
+ m/48h/{coin}h/{{acct}}h/1h
+P2WSH:
+ m/48h/{coin}h/{{acct}}h/2h
+P2TR:
+ m/48h/{coin}h/{{acct}}h/3h
+
+{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X)
+
+ ch = await ux_show_story(msg)
+ if ch != "y":
+ return
+
+ acct = await ux_enter_bip32_index('Account Number:') or 0
+
+ def render(acct_num):
+ sign_der = None
+ with uio.StringIO() as fp:
+ fp.write('{\n')
+ with stash.SensitiveValues(secret=alt_secret) as sv:
+ for name, deriv, fmt in chains.MS_STD_DERIVATIONS:
+ if fmt == AF_P2SH and acct_num:
+ continue
+ dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num)
+ if fmt == AF_P2WSH:
+ sign_der = dd + "/0/0"
+ node = sv.derive_path(dd)
+ xp = chain.serialize_public(node, fmt)
+ fp.write(' "%s_deriv": "%s",\n' % (name, dd))
+ fp.write(' "%s": "%s",\n' % (name, xp))
+ xpub = chain.serialize_public(node)
+ fp.write(' "%s_key_exp": "%s",\n' % (name, "[%s/%s]%s" % (xfp, dd.replace("m/", ""), xpub)))
+
+ fp.write(' "account": "%d",\n' % acct_num)
+ fp.write(' "xfp": "%s"\n}\n' % xfp)
+ return fp.getvalue(), sign_der, AF_CLASSIC
+
+ from export import export_contents
+ await export_contents(label, lambda: render(acct), fname_pattern,
+ force_bbqr=True, is_json=True)
+
+## MIGRATION ===
+
+def miniscript_640_migrate(old_serialization):
+ from ubinascii import unhexlify as a2b_hex
+ from ucollections import OrderedDict
+ from desc_utils import PROVABLY_UNSPENDABLE
+
+ def remove_subderivation(str_key):
+ # find the end of origin derivation
+ orig_der_end = str_key.find(']')
+ if orig_der_end != -1:
+ orig_der = str_key[:orig_der_end + 1]
+ rest = str_key[orig_der_end + 1:]
+ else:
+ orig_der = ""
+ rest = str_key
+
+ rest_split = rest.split("/")
+ subder = "/%s" % "/".join(rest_split[1:])
+ return orig_der + rest_split[0], subder
+
+ # last 4 members are irrelevant
+ name, ct, af, key, keys, policy, _, _, _, _ = old_serialization
+
+ # standardize policy according to BIP-388
+ policy = policy.replace("/<0;1>/*", "/**")
+
+ # P2TR problem - policy here does not contain internal key (key)
+ # therefore numbering is wrong - needs to be x+1 to make place for internal key
+ # problem - keys can be duplicates with just subderivation different
+
+ # deduplicate keys to become origin keys
+ keys = list(OrderedDict([(remove_subderivation(k)[0], None) for k in keys]).keys())
+
+ if key:
+ # taproot internal key
+ # will always be @0
+ # need to check if this key is not already in policy somewhere
+ if "unspend(" in key:
+ # this is no longer supported - need to convert to xpub
+ end = key.find(")")
+ chain_code_str = key[8:end]
+ ik_u = True
+ ik_subder = key[end+1:]
+ n = ngu.hdnode.HDNode()
+ n.from_chaincode_pubkey(a2b_hex(chain_code_str), PROVABLY_UNSPENDABLE)
+ ik_key = chains.current_chain().serialize_public(n)
+ else:
+ ik_key, ik_subder = remove_subderivation(key)
+ ik_u = Key.from_string(ik_key).is_provably_unspendable
+
+ if ik_subder == "/<0;1>/*":
+ ik_subder = "/**"
+
+ # internal key can be used in script tree & can already be at its correct position
+ # i.e. first in the keys vector
+ ik_pos_incorrect = int(ik_key != keys[0])
+
+ keys_info = []
+ for i in range(len(keys) - 1, -1, -1):
+ ph = "@%d" % i
+ assert policy.find(ph) != -1
+
+ res_key = keys[i]
+
+ if af == AF_P2TR:
+ # to make space for internal key in policy we need to bump placeholder
+ if res_key == ik_key:
+ # this origin key is the same as internal key
+ # so it is @0
+ policy = policy.replace(ph, "@0")
+ continue # no need to insert - will do later
+ else:
+ policy = policy.replace(ph, "@%d" % (i + ik_pos_incorrect))
+
+ keys_info.insert(0, res_key)
+
+ new_opts = {"af": af}
+
+ # policy in old version lacks script type
+ if af == AF_P2TR:
+ # handle internal key
+ keys_info.insert(0, ik_key)
+ desc_tmplt = "tr(@0%s,%s)" % (ik_subder, policy)
+ new_opts["ik_u"] = ik_u
+
+ elif af == AF_P2WSH:
+ desc_tmplt = "wsh(" + policy + ")"
+ elif af == AF_P2WSH_P2SH:
+ desc_tmplt = "sh(wsh(" + policy + "))"
+ else:
+ desc_tmplt = "sh(" + policy + ")"
+
+ if ct != "BTC":
+ new_opts['ct'] = ct
+
+ # previous version had unbounded names, cut it
+ return name[:MAX_NAME_LEN], desc_tmplt, keys_info, new_opts
+
+
+async def multisig_640_migration(multisig_wallets):
+ # all MultisigWallet needs to be converted to MiniscriptWallet
+ # this function just returns new list of migrated multisig wallets without
+ # changing any persisted settings data
+ from glob import dis
+ dis.fullscreen("Migrating...")
+ total = len(multisig_wallets)
+
+ migrated_multi = []
+ # first element is always name, whether migrated or not
+ # shorten to MAX_NAME_LEN that will be done to miniscript names upon migration
+ taken_names = [tup[0][:MAX_NAME_LEN] for tup in settings.get("miniscript", [])]
+ for i, ms in enumerate(multisig_wallets):
+ bip67 = 1 # default enabled, requires 5-element serialization to disable
+ if len(ms) == 5:
+ bip67 = ms[-1]
+ ms = ms[:-1]
+
+ name, m_of_n, xpubs, opts = ms
+ ct = opts.get('ch', 'BTC')
+ af = opts.get('ft', AF_P2SH)
+
+ if len(xpubs[0]) == 2:
+ common_prefix = opts.get('pp', None)
+ if not common_prefix:
+ common_prefix = 'm'
+ common_prefix = common_prefix.replace("'", "h")
+ xpubs = [(a, common_prefix, b) for a, b in xpubs]
+ else:
+ # new format decompression
+ if 'd' in opts:
+ derivs = [p.replace("'", "h") for p in opts.get('d')]
+ xpubs = [(a, derivs[b], c) for a, b, c in xpubs]
+
+ keys_info = []
+ for mfp, der, ek in xpubs:
+ xfp = xfp2str(mfp).lower()
+ if der == "m":
+ keys_info.append("[%s]%s" % (xfp, ek))
+ else:
+ keys_info.append("[%s/%s]%s" % (xfp, der.replace("m/", ""), ek))
+
+ ms_type = "sortedmulti" if bip67 else "multi"
+ if af == AF_P2WSH:
+ desc_tmplt = "wsh(" + ms_type + "(%s))"
+ elif af == AF_P2WSH_P2SH:
+ desc_tmplt = "sh(wsh(" + ms_type + "(%s)))"
+ else:
+ desc_tmplt = "sh(" + ms_type + "(%s))"
+
+ M, N = m_of_n
+ inner = "%d,%s" % (M, ",".join(["@%d/**" % i for i in range(M)]))
+ desc_tmplt = desc_tmplt % inner
+
+ new_opts = {
+ "af": af,
+ "m_n": (M, N),
+ "b67": bip67
+ }
+ if ct != "BTC":
+ new_opts['ct'] = ct
+
+ # this should not happen as multisg names were limited to 20 chars max
+ name = name[:MAX_NAME_LEN]
+ if name in taken_names:
+ # name collision with miniscript
+ while name in taken_names:
+ suffix = str(ngu.random.uniform(100))
+ if (len(name) + len(suffix)) > MAX_NAME_LEN:
+ # issue
+ name = name[:MAX_NAME_LEN-len(suffix)]
+
+ name = name + suffix
+
+ migrated_multi.append((name, desc_tmplt, keys_info, new_opts))
+ dis.progress_sofar(i+1, total)
+
+ return migrated_multi
+async def do_640_multisig_migration():
+ if not settings.get("multi_mig", 0):
+ ms = settings.get("multisig")
+ if ms:
+ # in version 6.4.0 EDGE
+ # MultisigWallet was removed & multisigs are now part of miniscript
+ migrated = await multisig_640_migration(ms)
+ msc = settings.get("miniscript", [])
+ settings.set("miniscript", msc + migrated)
+ # settings.remove_key("multisig")
+ settings.set("multi_mig", 1)
+ settings.save()
# EOF
diff --git a/shared/web2fa.py b/shared/web2fa.py
new file mode 100644
index 000000000..b78fc1ddb
--- /dev/null
+++ b/shared/web2fa.py
@@ -0,0 +1,176 @@
+# (c) Copyright 2021 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# web2fa.py -- Bounce a shared secret off a Coinkite server to allow mobile app 2FA.
+#
+#
+import ngu, ndef, aes256ctr
+from utils import b2a_base64url, url_quote, B2A
+from version import has_qr
+from ux import show_qr_code, ux_show_story, X
+
+# Only Coldcard.com server knows private key for this pubkey. It protects
+# the privacy of the values we send to the server.
+#
+# = 0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd
+SERVER_PUBKEY = b'\x02\x31\x30\x1e\xc4\xac\xec\x08\xc1\xc7\xd0\x18\x1f\x4f\xfb\x8b\xe7\x0d\x69\x3a\xcc\xcc\x86\xcc\xcb\x8f\x00\xbf\x2e\x00\xfc\xab\xfd'
+
+def encrypt_details(qs):
+ # encryption and base64 here
+ # - pick single-use ephemeral secp256k1 keypair
+ # - do ECDH to generate a shared secret based on known pubkey of server
+ # - AES-256-CTR encryption based on that
+ # - base64url encode result
+
+ # pick a random key pair, just for this session
+ pair = ngu.secp256k1.keypair()
+ my_pubkey = pair.pubkey().to_bytes(False) # compressed format
+
+ session_key = pair.ecdh_multiply(SERVER_PUBKEY)
+ del pair
+
+ enc = aes256ctr.new(session_key).cipher
+
+ return b2a_base64url(my_pubkey + enc(qs.encode('ascii')))
+
+async def perform_web2fa(label, shared_secret):
+
+ # send them to web, prompt for valid response. Return True if it all worked.
+ expect = await nfc_share_2fa_link(label, shared_secret)
+ if not expect:
+ # aborted at NFC step
+ return False
+
+ if has_qr:
+ # Make them scan the result, for example:
+ #
+ # CCC-AUTH:E902B3DAF2D98040F3A5F556D7CCC7C22BF3D455C146C4D4C0F7CF8B7937C530
+ #
+ from ux_q1 import QRScannerInteraction
+ from exceptions import QRDecodeExplained
+
+ prefix = 'CCC-AUTH:'
+ scanner = QRScannerInteraction()
+
+ def validate(got):
+ if not got.startswith(prefix):
+ raise QRDecodeExplained("QR isn't from our site")
+ if got != prefix+expect:
+ # probably attempted replay
+ raise QRDecodeExplained("Incorrect code?")
+ return got
+
+ data = await scanner.scan_general('Scan QR shown from Web', validate)
+ if not data:
+ return False # pressed cancel
+
+ # only one legal response possible, and already validated above
+ return data == (prefix+expect)
+
+ else:
+ #
+ # Mk4 and other devices w/o QR scanner, require user to enter 8 digits
+ #
+ from ux_mk4 import ux_input_digits
+
+ while 1:
+ got = await ux_input_digits('', maxlen=8,
+ prompt="8-digits From Web")
+
+ if not got:
+ # abort if empty entry
+ return False
+
+ if got == expect:
+ # good match
+ return True
+
+ ch = await ux_show_story("You entered an incorrect code. You must"
+ " enter the digits shown after the correct"
+ " 2FA code is provided to the website."
+ " Try again or %s to stop." % X)
+ if ch == 'x':
+ return False
+
+ # not reached
+ return False
+
+
+async def web2fa_enroll(ss=None):
+ #
+ # Enroll: Pick a secret and test they have loaded it into their phone.
+ #
+
+ # must have NFC tho
+ from flow import feature_requires_nfc
+ if not await feature_requires_nfc():
+ # they don't want to proceed
+ return None
+
+ # Pick a shared secret; 10 bytes, so encodes to 16 base32 chars
+ ss = ss or ngu.codecs.b32_encode(ngu.random.bytes(10))
+
+ # show a QR that app know how to use
+ # - problem: on Mk4, not really enough space:
+ # - can only show up to 42 chars, and secret is 16, required overhead is 23 => 39 min
+ # - can't fit any metadata, like username or our serial # in there
+ # - better on Q1 where no limitations for this size of QR
+
+ nm = 'COLDCARD' if has_qr else 'CC' # must be url-safe
+ qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, nm=nm)
+
+ while 1:
+ # show QR for enroll
+ await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App",
+ force_msg=True)
+
+ # important: force them to prove they stored it correctly
+ ok = await perform_web2fa('Enroll: COLDCARD', ss)
+ if ok: break
+
+ ch = await ux_show_story("That isn't correct. Please re-import and/or "
+ "try again or %s to give up." % X)
+ if ch == 'x':
+ return None
+
+ return ss
+
+def make_web2fa_url(wallet_name, shared_secret):
+ # Build complex URL into our server w/ encrypted data
+ # - picking a nonce in the process
+ prefix = 'coldcard.com/2fa?'
+
+ # random nonce: if we get this back, then server approves of TOTP answer
+ if has_qr:
+ # data for a QR
+ nonce = B2A(ngu.random.bytes(32)).upper()
+ else:
+ # 8 digits for human entry
+ nonce = '%08d' % ngu.random.uniform(1_0000_0000)
+
+ # compose URL
+ qs = 'g=%s&ss=%s&nm=%s&q=%d' % (nonce, shared_secret, url_quote(wallet_name), has_qr)
+
+ # encrypt that
+ qs = encrypt_details(qs)
+
+ return nonce, prefix + qs
+
+async def nfc_share_2fa_link(wallet_name, shared_secret):
+ #
+ # Share complex NFC deeplink into 2fa backend; returns expected response-code.
+ # Next step is to prompt for that 8-digit code (mk4) or scan QR (Q)
+ #
+ from glob import NFC
+ assert NFC
+
+ nonce, url = make_web2fa_url(wallet_name, shared_secret)
+
+ n = ndef.ndefMaker()
+ n.add_url(url, https=True)
+
+ aborted = await NFC.share_start(n, prompt="Tap for 2FA Authentication",
+ line2="Wallet: " + wallet_name)
+
+ return None if aborted else nonce
+
+# EOF
diff --git a/shared/xor_seed.py b/shared/xor_seed.py
index deb7ab82a..0b2663c70 100644
--- a/shared/xor_seed.py
+++ b/shared/xor_seed.py
@@ -5,29 +5,16 @@
# - for secret spliting on paper
# - all combination of partial XOR seed phrases are working wallets
#
-import stash, ngu, bip39, version
+import ngu, bip39, version
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause
from ux import show_qr_code, ux_render_words, OK
-from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed
+from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed, seed_vault_iter
from glob import settings
from menu import MenuSystem, MenuItem
from actions import goto_top_menu
-from utils import encode_seed_qr, pad_raw_secret
+from utils import encode_seed_qr, deserialize_secret, xor
from charcodes import KEY_QR
-
-
-def xor(*args):
- # bit-wise xor between all args
- vlen = len(args[0])
- # all have to be same length
- assert all(len(e) == vlen for e in args)
- rv = bytearray(vlen)
-
- for i in range(vlen):
- for a in args:
- rv[i] ^= a[i]
-
- return rv
+from stash import SecretStash, blank_object, SensitiveValues, numwords_to_len, len_to_numwords
async def xor_split_start(*a):
@@ -69,7 +56,7 @@ async def xor_split_start(*a):
raw_secret = bytes(32)
try:
- with stash.SensitiveValues() as sv:
+ with SensitiveValues(enforce_delta=True) as sv:
words = None
if sv.mode == 'words':
words = bip39.b2a_words(sv.raw).split(' ')
@@ -77,7 +64,7 @@ async def xor_split_start(*a):
# checksum of target result is useful
chk_word = words[-1]
- vlen = stash.numwords_to_len(len(words))
+ vlen = numwords_to_len(len(words))
del words
@@ -101,7 +88,7 @@ async def xor_split_start(*a):
assert xor(*parts) == raw_secret # selftest
finally:
- stash.blank_object(raw_secret)
+ blank_object(raw_secret)
word_parts = [bip39.b2a_words(p).split(' ') for p in parts]
@@ -112,7 +99,8 @@ async def xor_split_start(*a):
if await ux_confirm("Stop and forget those words?"):
return
continue
- if ch == KEY_QR:
+
+ if ch in "4"+KEY_QR:
qrs = []
for wl in word_parts:
qrs.append(encode_seed_qr(wl))
@@ -133,20 +121,22 @@ async def xor_split_start(*a):
You have confirmed the details of the new split.''')
# list of seed phrases
-# stores encoded secret bytes (not word lists)
+# - stores encoded secret bytes (not word lists)
import_xor_parts = []
async def xor_all_done(data):
# So we have another part, might be done or not.
global import_xor_parts
+
chk_words = None
+
if data is None:
# special case, needs something already in import_xor_parts
- target_words = stash.len_to_numwords(len(import_xor_parts[0]))
+ target_words = len_to_numwords(len(import_xor_parts[0]))
else:
new_encoded = bip39.a2b_words(data) if isinstance(data, list) else data
import_xor_parts.append(new_encoded)
- target_words = stash.len_to_numwords(len(new_encoded))
+ target_words = len_to_numwords(len(new_encoded))
XORWordNestMenu.pop_all()
@@ -157,7 +147,7 @@ async def xor_all_done(data):
if num_parts >= 2:
chk_words = bip39.b2a_words(seed).split(' ')
chk_word = chk_words[-1]
- msg += "If you stop now, the %dth word of the XOR-combined seed phrase\nwill be:\n\n" % target_words
+ msg += "If you stop now, the %dth word of the XOR-combined seed phrase will be:\n\n" % target_words
msg += "%d: %s\n\n" % (target_words, chk_word)
if all((not x) for x in seed):
@@ -181,7 +171,7 @@ async def xor_all_done(data):
import_xor_parts.clear() # concern: we are contaminated w/ secrets
elif chk_words and ch == KEY_QR:
rv = encode_seed_qr(chk_words)
- await show_qr_code(rv, True, msg="SeedQR")
+ await show_qr_code(rv, True, msg="SeedQR", is_secret=True)
continue
elif ch == '1':
# do another list of words
@@ -198,7 +188,7 @@ async def xor_all_done(data):
from pincodes import pa
from glob import dis
- enc = stash.SecretStash.encode(seed_phrase=seed)
+ enc = SecretStash.encode(seed_phrase=seed)
if pa.is_secret_blank():
# save it since they have no other secret
@@ -212,17 +202,15 @@ async def xor_all_done(data):
# only need XFPs for UI
# xfps = [
# xfp2str(swab32(
- # stash.SecretStash.decode(stash.SecretStash.encode(seed_phrase=i))[2].my_fp()
+ # SecretStash.decode(SecretStash.encode(seed_phrase=i))[2].my_fp()
# ))
# for i in enc_parts
# ]
- await set_ephemeral_seed(
- enc,
- meta='SeedXOR(%d parts, check: "%s")' % (
- num_parts, chk_word
- )
- )
+ await set_ephemeral_seed(enc,
+ origin='SeedXOR(%d parts, check: "%s")' % (num_parts, chk_word))
+
goto_top_menu()
+
break
class XORWordNestMenu(WordNestMenu):
@@ -234,29 +222,33 @@ def tr_label(self):
async def show_n_parts(parts, chk_word):
num_parts = len(parts)
seed_len = len(parts[0])
- msg = 'Record these %d lists of %d-words each:' % (num_parts, seed_len)
+ msg = '%d lists of %d-words each:' % (num_parts, seed_len)
for n,words in enumerate(parts):
msg += '\n\nPart %s:\n' % chr(65+n)
- msg += ux_render_words(words, leading_blanks=0)
+ msg += ux_render_words(words)
msg += ('\n\nThe correctly reconstructed seed phrase will have this final word,'
' which we recommend recording:\n\n%d: %s\n\n' % (seed_len, chk_word))
msg += 'Please check and double check your notes. There will be a test! '
+ if not version.has_qwerty:
+ msg += 'Press (4) to view QR Codes. '
- return await ux_show_story(msg, sensitive=True)
+ # allow QR codes on both Mk4 & Q
+ return await ux_show_story(msg, title="Record these:", sensitive=True, escape="4",
+ hint_icons=KEY_QR)
async def xor_restore_start(*a):
# shown on import menu when no seed of any kind yet
# - or operational system
ch = await ux_show_story('''\
-To import a seed split using XOR, you must import all the parts.
-It does not matter the order (A/B/C or C/A/B) and the Coldcard
-cannot determine when you have all the parts. You may stop at
-any time and you will have a valid wallet. Combined seed parts
-have to be equal length. No way to combine seed parts of different
-length. Press %s for 24 words XOR, press (1) for 12 words XOR,
+To import a seed split using XOR, you must import all the parts. \
+It does not matter the order (A/B/C or C/A/B) and the Coldcard \
+cannot determine when you have all the parts. You may stop at \
+any time and you will have a valid wallet. Combined seed parts \
+have to be equal length.\n
+Press %s for 24 words XOR, press (1) for 12 words XOR, \
or press (2) for 18 words XOR.''' % OK, escape="12")
if ch == 'x': return
@@ -266,8 +258,6 @@ async def xor_restore_start(*a):
elif ch == "2":
desired_num_words = 18
- curr_num_words = settings.get('words', desired_num_words)
-
global import_xor_parts
import_xor_parts.clear()
@@ -279,17 +269,22 @@ async def xor_restore_start(*a):
msg = ("Since you have a seed already on this Coldcard, the reconstructed XOR seed will be "
"temporary and not saved. Wipe the seed first if you want to commit the new value "
"into the secure element.")
- if curr_num_words == desired_num_words:
+
+ curr_num_words = settings.get('words', desired_num_words)
+ if (curr_num_words == desired_num_words) and not pa.hobbled_mode:
escape += "1"
- msg += ("\nPress (1) to include this Coldcard's seed words into the XOR seed set, "
+ msg += ("\n\nPress (1) to include this Coldcard's seed words into the XOR seed set, "
"or %s to continue without." % OK)
ch = await ux_show_story(msg, escape=escape)
- if ch == 'x': return
+ if ch == 'x':
+ return
+
if ch == '1':
+ assert not pa.hobbled_mode
dis.fullscreen("Wait...")
- with stash.SensitiveValues() as sv:
+ with SensitiveValues(enforce_delta=True) as sv:
if sv.mode == 'words':
# needs copy here [:] otherwise rewritten with zeros in __exit__
import_xor_parts.append(sv.raw[:])
@@ -297,17 +292,22 @@ async def xor_restore_start(*a):
# 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", [])):
- 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))
+ for i, rec in enumerate(seed_vault_iter()):
+ raw = deserialize_secret(rec.encoded)
+
+ nw = SecretStash.is_words(raw)
+ if nw and nw == desired_num_words:
+ # it is words, and right length
+ sk = SecretStash.decode_words(raw, bin_mode=True)
+ opt.append((i, rec.xfp, sk))
+
+ blank_object(raw)
+
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 624bd5eec..be67f32d8 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/.gitignore b/stm32/.gitignore
index d1542d386..c7b33a6ae 100644
--- a/stm32/.gitignore
+++ b/stm32/.gitignore
@@ -9,7 +9,7 @@ firmware.lss
firmware-signed.*
firmware.elf
file_time.c
-*-RC1-coldcard.dfu
+*-RC1-*.dfu
RC2-*.dfu
# somewhat useful binary snapshots
diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c
index 6dde832ab..8cda6664c 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-09-12
-// version: 5.4.0
+// built: 2025-11-05
+// version: 6.4.0X
//
#include
// this overrides ports/stm32/fatfs_port.c
uint32_t get_fattime(void) {
- return 0x592c2880UL;
+ return 0x5b653080UL;
}
diff --git a/stm32/COLDCARD_MK4/psramdisk.c b/stm32/COLDCARD_MK4/psramdisk.c
index b1fc0d690..bcfa9ffc9 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 c4f85d15d..fcc45e124 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-11-05
+// version: 6.4.0QX
//
#include
// this overrides ports/stm32/fatfs_port.c
uint32_t get_fattime(void) {
- return 0x592c0860UL;
+ return 0x5b653080UL;
}
diff --git a/stm32/MK4-Makefile b/stm32/MK4-Makefile
index 02b543de9..609f34974 100644
--- a/stm32/MK4-Makefile
+++ b/stm32/MK4-Makefile
@@ -12,14 +12,14 @@ HW_MODEL = mk4
PARENT_MKFILE = MK4-Makefile
# This is release of the bootloader that will be built into the factory.dfu
-BOOTLOADER_VERSION = 3.2.0
+BOOTLOADER_VERSION = 3.2.1
BOOTLOADER_DIR = mk4-bootloader
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 = 5.4.0
+VERSION_STRING = 6.4.0X
# keep near top, because defined default target (all)
include shared.mk
diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile
index 323e8d431..23dd07857 100644
--- a/stm32/Q1-Makefile
+++ b/stm32/Q1-Makefile
@@ -10,13 +10,13 @@ HW_MODEL = q1
PARENT_MKFILE = Q1-Makefile
# This is release of the bootloader that will be built into the factory.dfu
-BOOTLOADER_VERSION = 1.0.4
+BOOTLOADER_VERSION = 1.1.0
BOOTLOADER_DIR = q1-bootloader
LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1)
# Our version for this release.
-VERSION_STRING = 1.3.0Q
+VERSION_STRING = 6.4.0QX
# Remove this closer to shipping.
#$(warning "Forcing debug build")
diff --git a/stm32/bootloader/README.md b/stm32/bootloader/README.md
index 1b629de4d..13cbb4268 100644
--- a/stm32/bootloader/README.md
+++ b/stm32/bootloader/README.md
@@ -19,7 +19,7 @@ your key storage per-system unique.
- the most helpful file here is `bootloader.lss` which is generated in build process
-- using OpenOCD is prefered for lower level code like this (not GDB)
+- using OpenOCD is preferred for lower level code like this (not GDB)
- `stm32l4x.cpu arm disassemble 0x000008 10 thumb` is very helpful
@@ -140,7 +140,7 @@ Mk4:
## Re-do Bag Number
-- cannot writes ones, and then change flash cells; have to remain unprogrammed
+- cannot write ones, and then change flash cells; have to remain unprogrammed
dfu-util -d 0483:df11 -a 0 -s 0x0801c000:8192 -U pairing.bin
diff --git a/stm32/mk4-bootloader/ae.c b/stm32/mk4-bootloader/ae.c
index a55e34ccf..df632ed14 100644
--- a/stm32/mk4-bootloader/ae.c
+++ b/stm32/mk4-bootloader/ae.c
@@ -1726,6 +1726,7 @@ ae_read_config_byte(int offset)
uint8_t tmp[4];
ae_read_config_word(offset, tmp);
+ // BUG: didnt check for failure, in which case we will return un-inited values
return tmp[offset % 4];
}
diff --git a/stm32/mk4-bootloader/pins.c b/stm32/mk4-bootloader/pins.c
index bba8272d3..25ae42380 100644
--- a/stm32/mk4-bootloader/pins.c
+++ b/stm32/mk4-bootloader/pins.c
@@ -718,7 +718,8 @@ pin_login_attempt(pinAttempt_t *args)
args->num_fails = 0;
args->attempts_left = MAX_TARGET_ATTEMPTS;
- if(check_all_zeros(slot.xdata, 32) || (slot.tc_flags & TC_WIPE)) {
+ bool wipe = (slot.tc_flags & TC_WIPE) && !(slot.tc_flags & (TC_WORD_WALLET|TC_XPRV_WALLET));
+ if(check_all_zeros(slot.xdata, 32) || wipe) {
args->state_flags |= PA_ZERO_SECRET;
}
diff --git a/stm32/mk4-bootloader/releases/3.2.1.txt b/stm32/mk4-bootloader/releases/3.2.1.txt
new file mode 100644
index 000000000..8f3fdd6b6
--- /dev/null
+++ b/stm32/mk4-bootloader/releases/3.2.1.txt
@@ -0,0 +1,4 @@
+0904b790af34c8acd8e3156cd5b4e818ae09e93611e90c673a7953fec67802d0 bootloader.dfu
+7c7acbb849d17721f9a53b613d631f8bb8ed3b49c2bf5e1a413511c7d9105775 bootloader.bin
+e71a730d2025bfcc0bf334614c60022e8df3d847c7c6a53f172aace004d69553 bootloader.lss
+3.2.1 time=20250415.090935 git=master@adcf2c8e
diff --git a/stm32/mk4-bootloader/releases/3.2.1/bootloader.bin b/stm32/mk4-bootloader/releases/3.2.1/bootloader.bin
new file mode 100644
index 000000000..964e8174b
Binary files /dev/null and b/stm32/mk4-bootloader/releases/3.2.1/bootloader.bin differ
diff --git a/stm32/mk4-bootloader/releases/3.2.1/bootloader.dfu b/stm32/mk4-bootloader/releases/3.2.1/bootloader.dfu
new file mode 100644
index 000000000..13784b589
Binary files /dev/null and b/stm32/mk4-bootloader/releases/3.2.1/bootloader.dfu differ
diff --git a/stm32/mk4-bootloader/releases/3.2.1/bootloader.lss b/stm32/mk4-bootloader/releases/3.2.1/bootloader.lss
new file mode 100644
index 000000000..a67725ce3
--- /dev/null
+++ b/stm32/mk4-bootloader/releases/3.2.1/bootloader.lss
@@ -0,0 +1,34612 @@
+
+bootloader.elf: file format elf32-littlearm
+
+Sections:
+Idx Name Size VMA LMA File off Algn
+ 0 .text 0000ea48 08000000 08000000 00010000 2**8
+ CONTENTS, ALLOC, LOAD, READONLY, CODE
+ 1 .relocate 00000150 2009e000 0800ea48 0002e000 2**2
+ CONTENTS, ALLOC, LOAD, READONLY, CODE
+ 2 .bss 000002e8 2009e150 0800eb98 0002e150 2**2
+ ALLOC
+ 3 .stack 00000800 2009e438 0800ee80 0002e150 2**0
+ ALLOC
+ 4 .debug_info 0002bc2d 00000000 00000000 0002e150 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 5 .debug_abbrev 00005f7a 00000000 00000000 00059d7d 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 6 .debug_loc 00014618 00000000 00000000 0005fcf7 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 7 .debug_aranges 000010c8 00000000 00000000 0007430f 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 8 .debug_ranges 00002148 00000000 00000000 000753d7 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 9 .debug_macro 00032533 00000000 00000000 0007751f 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 10 .debug_line 0001de5f 00000000 00000000 000a9a52 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 11 .debug_str 0011cbb0 00000000 00000000 000c78b1 2**0
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+ 12 .comment 00000049 00000000 00000000 001e4461 2**0
+ CONTENTS, READONLY
+ 13 .ARM.attributes 00000032 00000000 00000000 001e44aa 2**0
+ CONTENTS, READONLY
+ 14 .debug_frame 000036f4 00000000 00000000 001e44dc 2**2
+ CONTENTS, READONLY, DEBUGGING, OCTETS
+
+Disassembly of section .text:
+
+08000000 <_sfixed>:
+ 8000000: 200a0000 .word 0x200a0000
+ 8000004: 080000b5 .word 0x080000b5
+ 8000008: 0800001d .word 0x0800001d
+ 800000c: 0800001f .word 0x0800001f
+ 8000010: 08000021 .word 0x08000021
+ 8000014: 08000023 .word 0x08000023
+ 8000018: 08000025 .word 0x08000025
+
+0800001c :
+ 800001c: be01 bkpt 0x0001
+
+0800001e :
+ 800001e: be02 bkpt 0x0002
+
+08000020 :
+ 8000020: be03 bkpt 0x0003
+
+08000022 :
+ 8000022: be04 bkpt 0x0004
+
+08000024 :
+ 8000024: be05 bkpt 0x0005
+ 8000026: e7fe b.n 8000026
+
+08000028 :
+ ...
+ 8000040: 08000305 .word 0x08000305
+
+08000044 :
+ 8000044: 00000200 .word 0x00000200
+ ...
+ 8000060: 20296328 .word 0x20296328
+ 8000064: 79706f43 .word 0x79706f43
+ 8000068: 68676972 .word 0x68676972
+ 800006c: 30322074 .word 0x30322074
+ 8000070: 322d3831 .word 0x322d3831
+ 8000074: 20323230 .word 0x20323230
+ 8000078: 43207962 .word 0x43207962
+ 800007c: 6b6e696f .word 0x6b6e696f
+ 8000080: 20657469 .word 0x20657469
+ 8000084: 2e636e49 .word 0x2e636e49
+ 8000088: 0a200a20 .word 0x0a200a20
+ 800008c: 73696854 .word 0x73696854
+ 8000090: 61707320 .word 0x61707320
+ 8000094: 66206563 .word 0x66206563
+ 8000098: 7220726f .word 0x7220726f
+ 800009c: 21746e65 .word 0x21746e65
+ 80000a0: 73754a20 .word 0x73754a20
+ 80000a4: 42312074 .word 0x42312074
+ 80000a8: 792f4354 .word 0x792f4354
+ 80000ac: 2e726165 .word 0x2e726165
+ 80000b0: 0a200a20 .word 0x0a200a20
+
+080000b4 :
+ 80000b4: f000 f816 bl 80000e4
+ 80000b8: f04f 30ff mov.w r0, #4294967295 ; 0xffffffff
+ 80000bc: f04f 0100 mov.w r1, #0
+ 80000c0: f04f 0200 mov.w r2, #0
+ 80000c4: f04f 0300 mov.w r3, #0
+ 80000c8: f000 f91c bl 8000304
+ 80000cc: f248 0120 movw r1, #32800 ; 0x8020
+ 80000d0: ea4f 3101 mov.w r1, r1, lsl #12
+ 80000d4: 6808 ldr r0, [r1, #0]
+ 80000d6: 4685 mov sp, r0
+ 80000d8: f04f 0001 mov.w r0, #1
+ 80000dc: f8d1 e004 ldr.w lr, [r1, #4]
+ 80000e0: 4770 bx lr
+ ...
+
+080000e4 :
+ void
+firewall_setup(void)
+{
+ // This is critical: without the clock enabled to "SYSCFG" we
+ // can't tell the FW is enabled or not! Enabling it would also not work
+ __HAL_RCC_SYSCFG_CLK_ENABLE();
+ 80000e4: 4b1b ldr r3, [pc, #108] ; (8000154 )
+{
+ 80000e6: b500 push {lr}
+ __HAL_RCC_SYSCFG_CLK_ENABLE();
+ 80000e8: 6e1a ldr r2, [r3, #96] ; 0x60
+ 80000ea: f042 0201 orr.w r2, r2, #1
+ 80000ee: 661a str r2, [r3, #96] ; 0x60
+ 80000f0: 6e1b ldr r3, [r3, #96] ; 0x60
+{
+ 80000f2: b08b sub sp, #44 ; 0x2c
+ __HAL_RCC_SYSCFG_CLK_ENABLE();
+ 80000f4: f003 0301 and.w r3, r3, #1
+ 80000f8: 9300 str r3, [sp, #0]
+ 80000fa: 9b00 ldr r3, [sp, #0]
+
+ if(__HAL_FIREWALL_IS_ENABLED()) {
+ 80000fc: 4b16 ldr r3, [pc, #88] ; (8000158 )
+ 80000fe: 685b ldr r3, [r3, #4]
+ 8000100: 07db lsls r3, r3, #31
+ 8000102: d524 bpl.n 800014e
+ // REMINDERS:
+ // - cannot debug anything in boot loader w/ firewall enabled (no readback, no bkpt)
+ // - when RDP=2, this protection still important or else python can read pairing secret
+ // - in factory mode (RDP!=2), it's nice to have this disabled so we can debug still
+ // - could look at RDP level here, but it would be harder to completely reset the bag number!
+ if(check_all_ones_raw(rom_secrets->bag_number, sizeof(rom_secrets->bag_number))) {
+ 8000104: 4815 ldr r0, [pc, #84] ; (800015c )
+ 8000106: 2120 movs r1, #32
+ 8000108: f002 faae bl 8002668
+ 800010c: b9f8 cbnz r0, 800014e
+ // for debug builds, never enable firewall
+ return;
+#endif
+
+ extern int firewall_starts; // see startup.S ... aligned@256 (0x08000300)
+ uint32_t start = (uint32_t)&firewall_starts;
+ 800010e: 4b14 ldr r3, [pc, #80] ; (8000160 )
+ uint32_t len = BL_FLASH_SIZE - (start - BL_FLASH_BASE);
+ 8000110: 4a14 ldr r2, [pc, #80] ; (8000164 )
+ // but sensitive stuff is still there (which would allow bypass)
+ // - so it's important to enable option bytes to set write-protect flash of entire bootloader
+ // - to disable debug and complete protection, must enable write-protect "level 2" (RDP=2)
+ //
+
+ FIREWALL_InitTypeDef init = {
+ 8000112: 9302 str r3, [sp, #8]
+ uint32_t len = BL_FLASH_SIZE - (start - BL_FLASH_BASE);
+ 8000114: 1ad3 subs r3, r2, r3
+ FIREWALL_InitTypeDef init = {
+ 8000116: e9cd 3203 strd r3, r2, [sp, #12]
+ 800011a: f44f 4380 mov.w r3, #16384 ; 0x4000
+ 800011e: e9cd 3005 strd r3, r0, [sp, #20]
+ 8000122: e9cd 0007 strd r0, r0, [sp, #28]
+ 8000126: 9009 str r0, [sp, #36] ; 0x24
+ .VDataSegmentLength = 0,
+ .VolatileDataExecution = 0,
+ .VolatileDataShared = 0,
+ };
+
+ int rv = HAL_FIREWALL_Config((FIREWALL_InitTypeDef *)&init);
+ 8000128: a802 add r0, sp, #8
+ 800012a: f000 f821 bl 8000170
+ if(rv) {
+ 800012e: b110 cbz r0, 8000136
+ INCONSISTENT("fw");
+ 8000130: 480d ldr r0, [pc, #52] ; (8000168 )
+ 8000132: f000 fc89 bl 8000a48
+ }
+
+ __HAL_FIREWALL_PREARM_DISABLE();
+ 8000136: 4b0d ldr r3, [pc, #52] ; (800016c )
+ 8000138: 6a1a ldr r2, [r3, #32]
+ 800013a: f022 0201 bic.w r2, r2, #1
+ 800013e: 621a str r2, [r3, #32]
+ 8000140: 6a1b ldr r3, [r3, #32]
+ 8000142: f003 0301 and.w r3, r3, #1
+ 8000146: 9301 str r3, [sp, #4]
+ 8000148: 9b01 ldr r3, [sp, #4]
+ HAL_FIREWALL_EnableFirewall();
+ 800014a: f000 f88b bl 8000264
+}
+ 800014e: b00b add sp, #44 ; 0x2c
+ 8000150: f85d fb04 ldr.w pc, [sp], #4
+ 8000154: 40021000 .word 0x40021000
+ 8000158: 40010000 .word 0x40010000
+ 800015c: 0801c050 .word 0x0801c050
+ 8000160: 08000300 .word 0x08000300
+ 8000164: 0801c000 .word 0x0801c000
+ 8000168: 0800d700 .word 0x0800d700
+ 800016c: 40011c00 .word 0x40011c00
+
+08000170 :
+ * @param fw_init: Firewall initialization structure
+ * @note The API returns HAL_ERROR if the Firewall is already enabled.
+ * @retval HAL status
+ */
+HAL_StatusTypeDef HAL_FIREWALL_Config(FIREWALL_InitTypeDef * fw_init)
+{
+ 8000170: b573 push {r0, r1, r4, r5, r6, lr}
+ /* Check the Firewall initialization structure allocation */
+ if(fw_init == NULL)
+ 8000172: b910 cbnz r0, 800017a
+ {
+ return HAL_ERROR;
+ 8000174: 2001 movs r0, #1
+ /* Set Firewall Configuration Register VDE and VDS bits
+ (volatile data execution and shared configuration) */
+ MODIFY_REG(FIREWALL->CR, FW_CR_VDS|FW_CR_VDE, fw_init->VolatileDataExecution|fw_init->VolatileDataShared);
+
+ return HAL_OK;
+}
+ 8000176: b002 add sp, #8
+ 8000178: bd70 pop {r4, r5, r6, pc}
+ __HAL_RCC_FIREWALL_CLK_ENABLE();
+ 800017a: 4b19 ldr r3, [pc, #100] ; (80001e0 )
+ 800017c: 6e1a ldr r2, [r3, #96] ; 0x60
+ 800017e: f042 0280 orr.w r2, r2, #128 ; 0x80
+ 8000182: 661a str r2, [r3, #96] ; 0x60
+ 8000184: 6e1b ldr r3, [r3, #96] ; 0x60
+ 8000186: f003 0380 and.w r3, r3, #128 ; 0x80
+ 800018a: 9301 str r3, [sp, #4]
+ 800018c: 9b01 ldr r3, [sp, #4]
+ if (__HAL_FIREWALL_IS_ENABLED() != RESET)
+ 800018e: 4b15 ldr r3, [pc, #84] ; (80001e4 )
+ 8000190: 685b ldr r3, [r3, #4]
+ 8000192: 07db lsls r3, r3, #31
+ 8000194: d5ee bpl.n 8000174
+ if (fw_init->CodeSegmentLength != 0U)
+ 8000196: 6841 ldr r1, [r0, #4]
+ if (fw_init->NonVDataSegmentLength < 0x100U)
+ 8000198: 68c2 ldr r2, [r0, #12]
+ if (fw_init->CodeSegmentLength != 0U)
+ 800019a: b109 cbz r1, 80001a0
+ if (fw_init->NonVDataSegmentLength < 0x100U)
+ 800019c: 2aff cmp r2, #255 ; 0xff
+ 800019e: d9e9 bls.n 8000174
+ WRITE_REG(FIREWALL->CSSA, (FW_CSSA_ADD & fw_init->CodeSegmentStartAddress));
+ 80001a0: 6803 ldr r3, [r0, #0]
+ 80001a2: 4e11 ldr r6, [pc, #68] ; (80001e8 )
+ if (fw_init->VDataSegmentLength != 0U)
+ 80001a4: 6944 ldr r4, [r0, #20]
+ WRITE_REG(FIREWALL->CSSA, (FW_CSSA_ADD & fw_init->CodeSegmentStartAddress));
+ 80001a6: ea03 0506 and.w r5, r3, r6
+ 80001aa: 4b10 ldr r3, [pc, #64] ; (80001ec )
+ 80001ac: 601d str r5, [r3, #0]
+ WRITE_REG(FIREWALL->CSL, (FW_CSL_LENG & fw_init->CodeSegmentLength));
+ 80001ae: 4d10 ldr r5, [pc, #64] ; (80001f0 )
+ 80001b0: 4029 ands r1, r5
+ 80001b2: 6059 str r1, [r3, #4]
+ WRITE_REG(FIREWALL->NVDSSA, (FW_NVDSSA_ADD & fw_init->NonVDataSegmentStartAddress));
+ 80001b4: 6881 ldr r1, [r0, #8]
+ WRITE_REG(FIREWALL->NVDSL, (FW_NVDSL_LENG & fw_init->NonVDataSegmentLength));
+ 80001b6: 402a ands r2, r5
+ WRITE_REG(FIREWALL->NVDSSA, (FW_NVDSSA_ADD & fw_init->NonVDataSegmentStartAddress));
+ 80001b8: 4031 ands r1, r6
+ 80001ba: 6099 str r1, [r3, #8]
+ WRITE_REG(FIREWALL->NVDSL, (FW_NVDSL_LENG & fw_init->NonVDataSegmentLength));
+ 80001bc: 60da str r2, [r3, #12]
+ WRITE_REG(FIREWALL->VDSSA, (FW_VDSSA_ADD & fw_init->VDataSegmentStartAddress));
+ 80001be: 6901 ldr r1, [r0, #16]
+ 80001c0: 4a0c ldr r2, [pc, #48] ; (80001f4 )
+ 80001c2: 4011 ands r1, r2
+ WRITE_REG(FIREWALL->VDSL, (FW_VDSL_LENG & fw_init->VDataSegmentLength));
+ 80001c4: 4022 ands r2, r4
+ WRITE_REG(FIREWALL->VDSSA, (FW_VDSSA_ADD & fw_init->VDataSegmentStartAddress));
+ 80001c6: 6119 str r1, [r3, #16]
+ WRITE_REG(FIREWALL->VDSL, (FW_VDSL_LENG & fw_init->VDataSegmentLength));
+ 80001c8: 615a str r2, [r3, #20]
+ MODIFY_REG(FIREWALL->CR, FW_CR_VDS|FW_CR_VDE, fw_init->VolatileDataExecution|fw_init->VolatileDataShared);
+ 80001ca: e9d0 2006 ldrd r2, r0, [r0, #24]
+ 80001ce: 6a19 ldr r1, [r3, #32]
+ 80001d0: 4302 orrs r2, r0
+ 80001d2: f021 0106 bic.w r1, r1, #6
+ 80001d6: 430a orrs r2, r1
+ 80001d8: 621a str r2, [r3, #32]
+ return HAL_OK;
+ 80001da: 2000 movs r0, #0
+ 80001dc: e7cb b.n 8000176
+ 80001de: bf00 nop
+ 80001e0: 40021000 .word 0x40021000
+ 80001e4: 40010000 .word 0x40010000
+ 80001e8: 00ffff00 .word 0x00ffff00
+ 80001ec: 40011c00 .word 0x40011c00
+ 80001f0: 003fff00 .word 0x003fff00
+ 80001f4: 0003ffc0 .word 0x0003ffc0
+
+080001f8 :
+void HAL_FIREWALL_GetConfig(FIREWALL_InitTypeDef * fw_config)
+{
+
+ /* Enable Firewall clock, in case no Firewall configuration has been carried
+ out up to this point */
+ __HAL_RCC_FIREWALL_CLK_ENABLE();
+ 80001f8: 4b15 ldr r3, [pc, #84] ; (8000250 )
+ 80001fa: 6e1a ldr r2, [r3, #96] ; 0x60
+{
+ 80001fc: b573 push {r0, r1, r4, r5, r6, lr}
+ __HAL_RCC_FIREWALL_CLK_ENABLE();
+ 80001fe: f042 0280 orr.w r2, r2, #128 ; 0x80
+ 8000202: 661a str r2, [r3, #96] ; 0x60
+ 8000204: 6e1b ldr r3, [r3, #96] ; 0x60
+
+ /* Retrieve code segment protection setting */
+ fw_config->CodeSegmentStartAddress = (READ_REG(FIREWALL->CSSA) & FW_CSSA_ADD);
+ 8000206: 4e13 ldr r6, [pc, #76] ; (8000254 )
+ fw_config->CodeSegmentLength = (READ_REG(FIREWALL->CSL) & FW_CSL_LENG);
+ 8000208: 4d13 ldr r5, [pc, #76] ; (8000258 )
+ __HAL_RCC_FIREWALL_CLK_ENABLE();
+ 800020a: f003 0380 and.w r3, r3, #128 ; 0x80
+ 800020e: 9301 str r3, [sp, #4]
+ 8000210: 9b01 ldr r3, [sp, #4]
+ fw_config->CodeSegmentStartAddress = (READ_REG(FIREWALL->CSSA) & FW_CSSA_ADD);
+ 8000212: 4b12 ldr r3, [pc, #72] ; (800025c )
+ 8000214: 681a ldr r2, [r3, #0]
+ 8000216: 4032 ands r2, r6
+ 8000218: 6002 str r2, [r0, #0]
+ fw_config->CodeSegmentLength = (READ_REG(FIREWALL->CSL) & FW_CSL_LENG);
+ 800021a: 685c ldr r4, [r3, #4]
+ 800021c: 402c ands r4, r5
+ 800021e: 6044 str r4, [r0, #4]
+
+ /* Retrieve non volatile data segment protection setting */
+ fw_config->NonVDataSegmentStartAddress = (READ_REG(FIREWALL->NVDSSA) & FW_NVDSSA_ADD);
+ 8000220: 6899 ldr r1, [r3, #8]
+ fw_config->NonVDataSegmentLength = (READ_REG(FIREWALL->NVDSL) & FW_NVDSL_LENG);
+
+ /* Retrieve volatile data segment protection setting */
+ fw_config->VDataSegmentStartAddress = (READ_REG(FIREWALL->VDSSA) & FW_VDSSA_ADD);
+ 8000222: 4c0f ldr r4, [pc, #60] ; (8000260 )
+ fw_config->NonVDataSegmentStartAddress = (READ_REG(FIREWALL->NVDSSA) & FW_NVDSSA_ADD);
+ 8000224: 4031 ands r1, r6
+ 8000226: 6081 str r1, [r0, #8]
+ fw_config->NonVDataSegmentLength = (READ_REG(FIREWALL->NVDSL) & FW_NVDSL_LENG);
+ 8000228: 68da ldr r2, [r3, #12]
+ 800022a: 402a ands r2, r5
+ 800022c: 60c2 str r2, [r0, #12]
+ fw_config->VDataSegmentStartAddress = (READ_REG(FIREWALL->VDSSA) & FW_VDSSA_ADD);
+ 800022e: 6919 ldr r1, [r3, #16]
+ 8000230: 4021 ands r1, r4
+ 8000232: 6101 str r1, [r0, #16]
+ fw_config->VDataSegmentLength = (READ_REG(FIREWALL->VDSL) & FW_VDSL_LENG);
+ 8000234: 695a ldr r2, [r3, #20]
+ 8000236: 4022 ands r2, r4
+ 8000238: 6142 str r2, [r0, #20]
+
+ /* Retrieve volatile data execution setting */
+ fw_config->VolatileDataExecution = (READ_REG(FIREWALL->CR) & FW_CR_VDE);
+ 800023a: 6a1a ldr r2, [r3, #32]
+ 800023c: f002 0204 and.w r2, r2, #4
+ 8000240: 6182 str r2, [r0, #24]
+
+ /* Retrieve volatile data shared setting */
+ fw_config->VolatileDataShared = (READ_REG(FIREWALL->CR) & FW_CR_VDS);
+ 8000242: 6a1b ldr r3, [r3, #32]
+ 8000244: f003 0302 and.w r3, r3, #2
+ 8000248: 61c3 str r3, [r0, #28]
+
+ return;
+}
+ 800024a: b002 add sp, #8
+ 800024c: bd70 pop {r4, r5, r6, pc}
+ 800024e: bf00 nop
+ 8000250: 40021000 .word 0x40021000
+ 8000254: 00ffff00 .word 0x00ffff00
+ 8000258: 003fff00 .word 0x003fff00
+ 800025c: 40011c00 .word 0x40011c00
+ 8000260: 0003ffc0 .word 0x0003ffc0
+
+08000264 :
+ * @retval None
+ */
+void HAL_FIREWALL_EnableFirewall(void)
+{
+ /* Clears FWDIS bit of SYSCFG CFGR1 register */
+ CLEAR_BIT(SYSCFG->CFGR1, SYSCFG_CFGR1_FWDIS);
+ 8000264: 4a02 ldr r2, [pc, #8] ; (8000270 )
+ 8000266: 6853 ldr r3, [r2, #4]
+ 8000268: f023 0301 bic.w r3, r3, #1
+ 800026c: 6053 str r3, [r2, #4]
+
+}
+ 800026e: 4770 bx lr
+ 8000270: 40010000 .word 0x40010000
+
+08000274 :
+ * @retval None
+ */
+void HAL_FIREWALL_EnablePreArmFlag(void)
+{
+ /* Set FPA bit */
+ SET_BIT(FIREWALL->CR, FW_CR_FPA);
+ 8000274: 4a02 ldr r2, [pc, #8] ; (8000280 )
+ 8000276: 6a13 ldr r3, [r2, #32]
+ 8000278: f043 0301 orr.w r3, r3, #1
+ 800027c: 6213 str r3, [r2, #32]
+}
+ 800027e: 4770 bx lr
+ 8000280: 40011c00 .word 0x40011c00
+
+08000284 :
+ * @retval None
+ */
+void HAL_FIREWALL_DisablePreArmFlag(void)
+{
+ /* Clear FPA bit */
+ CLEAR_BIT(FIREWALL->CR, FW_CR_FPA);
+ 8000284: 4a02 ldr r2, [pc, #8] ; (8000290 )
+ 8000286: 6a13 ldr r3, [r2, #32]
+ 8000288: f023 0301 bic.w r3, r3, #1
+ 800028c: 6213 str r3, [r2, #32]
+}
+ 800028e: 4770 bx lr
+ 8000290: 40011c00 .word 0x40011c00
+ ...
+
+08000300 <_firewall_start>:
+ 8000300: 0f193a11 .word 0x0f193a11
+
+08000304 :
+ 8000304: f24e 0900 movw r9, #57344 ; 0xe000
+ 8000308: f2c2 0909 movt r9, #8201 ; 0x2009
+ 800030c: f44f 5a00 mov.w sl, #8192 ; 0x2000
+ 8000310: 44ca add sl, r9
+
+08000312 :
+ 8000312: f849 ab04 str.w sl, [r9], #4
+ 8000316: 45d1 cmp r9, sl
+ 8000318: d1fb bne.n 8000312
+ 800031a: 46ea mov sl, sp
+ 800031c: 46cd mov sp, r9
+ 800031e: e92d 4400 stmdb sp!, {sl, lr}
+
+08000322 :
+ 8000322: f000 f841 bl 80003a8
+ 8000326: e8bd 4400 ldmia.w sp!, {sl, lr}
+ 800032a: 46d5 mov sp, sl
+ 800032c: f24e 0900 movw r9, #57344 ; 0xe000
+ 8000330: f2c2 0909 movt r9, #8201 ; 0x2009
+ 8000334: f44f 5a00 mov.w sl, #8192 ; 0x2000
+ 8000338: 44ca add sl, r9
+
+0800033a :
+ 800033a: f849 0b04 str.w r0, [r9], #4
+ 800033e: 45d1 cmp r9, sl
+ 8000340: d1fb bne.n 800033a
+ 8000342: 4770 bx lr
+
+08000344 <__NVIC_SystemReset>:
+ \details Acts as a special kind of Data Memory Barrier.
+ It completes when all explicit memory accesses before this instruction complete.
+ */
+__STATIC_FORCEINLINE void __DSB(void)
+{
+ __ASM volatile ("dsb 0xF":::"memory");
+ 8000344: f3bf 8f4f dsb sy
+__NO_RETURN __STATIC_INLINE void __NVIC_SystemReset(void)
+{
+ __DSB(); /* Ensure all outstanding memory accesses included
+ buffered write are completed before reset */
+ SCB->AIRCR = (uint32_t)((0x5FAUL << SCB_AIRCR_VECTKEY_Pos) |
+ (SCB->AIRCR & SCB_AIRCR_PRIGROUP_Msk) |
+ 8000348: 4905 ldr r1, [pc, #20] ; (8000360 <__NVIC_SystemReset+0x1c>)
+ SCB->AIRCR = (uint32_t)((0x5FAUL << SCB_AIRCR_VECTKEY_Pos) |
+ 800034a: 4b06 ldr r3, [pc, #24] ; (8000364 <__NVIC_SystemReset+0x20>)
+ (SCB->AIRCR & SCB_AIRCR_PRIGROUP_Msk) |
+ 800034c: 68ca ldr r2, [r1, #12]
+ 800034e: f402 62e0 and.w r2, r2, #1792 ; 0x700
+ SCB->AIRCR = (uint32_t)((0x5FAUL << SCB_AIRCR_VECTKEY_Pos) |
+ 8000352: 4313 orrs r3, r2
+ 8000354: 60cb str r3, [r1, #12]
+ 8000356: f3bf 8f4f dsb sy
+ SCB_AIRCR_SYSRESETREQ_Msk ); /* Keep priority group unchanged */
+ __DSB(); /* Ensure completion of memory access */
+
+ for(;;) /* wait until reset */
+ {
+ __NOP();
+ 800035a: bf00 nop
+ for(;;) /* wait until reset */
+ 800035c: e7fd b.n 800035a <__NVIC_SystemReset+0x16>
+ 800035e: bf00 nop
+ 8000360: e000ed00 .word 0xe000ed00
+ 8000364: 05fa0004 .word 0x05fa0004
+
+08000368 :
+good_addr(const uint8_t *b, int minlen, int len, bool readonly)
+{
+ uint32_t x = (uint32_t)b;
+
+ if(minlen) {
+ if(!b) return EFAULT; // gave no buffer
+ 8000368: b198 cbz r0, 8000392
+ if(len < minlen) return ERANGE; // too small
+ 800036a: 4291 cmp r1, r2
+ 800036c: dc13 bgt.n 8000396
+ }
+
+ if((x >= SRAM1_BASE) && ((x+len) <= BL_SRAM_BASE)) {
+ 800036e: f1b0 5f00 cmp.w r0, #536870912 ; 0x20000000
+ 8000372: d303 bcc.n 800037c
+ 8000374: 490b ldr r1, [pc, #44] ; (80003a4 )
+ 8000376: 4402 add r2, r0
+ 8000378: 428a cmp r2, r1
+ 800037a: d90e bls.n 800039a
+ // ok: it's inside the SRAM areas, up to where we start
+ return 0;
+ }
+
+ if(!readonly) {
+ 800037c: b17b cbz r3, 800039e
+ return EPERM;
+ }
+
+ if((x >= FIRMWARE_START) && (x - FIRMWARE_START) < FW_MAX_LENGTH_MK4) {
+ 800037e: f100 4077 add.w r0, r0, #4143972352 ; 0xf7000000
+ 8000382: f500 007e add.w r0, r0, #16646144 ; 0xfe0000
+ // inside flash of main firmware (happens for QSTR's)
+ return 0;
+ }
+
+ return EACCES;
+ 8000386: f5b0 1ff0 cmp.w r0, #1966080 ; 0x1e0000
+ 800038a: bf34 ite cc
+ 800038c: 2000 movcc r0, #0
+ 800038e: 200d movcs r0, #13
+ 8000390: 4770 bx lr
+ if(!b) return EFAULT; // gave no buffer
+ 8000392: 200e movs r0, #14
+ 8000394: 4770 bx lr
+ if(len < minlen) return ERANGE; // too small
+ 8000396: 2022 movs r0, #34 ; 0x22
+ 8000398: 4770 bx lr
+ return 0;
+ 800039a: 2000 movs r0, #0
+ 800039c: 4770 bx lr
+ return EPERM;
+ 800039e: 2001 movs r0, #1
+}
+ 80003a0: 4770 bx lr
+ 80003a2: bf00 nop
+ 80003a4: 2009e000 .word 0x2009e000
+
+080003a8 :
+//
+ __attribute__ ((used))
+ int
+firewall_dispatch(int method_num, uint8_t *buf_io, int len_in,
+ uint32_t arg2, uint32_t incoming_sp, uint32_t incoming_lr)
+{
+ 80003a8: b570 push {r4, r5, r6, lr}
+ 80003aa: b09e sub sp, #120 ; 0x78
+ 80003ac: 460d mov r5, r1
+ 80003ae: 9c23 ldr r4, [sp, #140] ; 0x8c
+ 80003b0: 9301 str r3, [sp, #4]
+ __ASM volatile ("cpsid i" : : : "memory");
+ 80003b2: b672 cpsid i
+ // in case the caller didn't already, but would just lead to a crash anyway
+ __disable_irq();
+
+ // "1=any code executed outside the protected segment will close the Firewall"
+ // "0=.. will reset the processor"
+ __HAL_FIREWALL_PREARM_DISABLE();
+ 80003b4: 4ba5 ldr r3, [pc, #660] ; (800064c )
+ 80003b6: 6a19 ldr r1, [r3, #32]
+ 80003b8: f021 0101 bic.w r1, r1, #1
+ 80003bc: 6219 str r1, [r3, #32]
+ 80003be: 6a1b ldr r3, [r3, #32]
+ 80003c0: f003 0301 and.w r3, r3, #1
+ 80003c4: 9302 str r3, [sp, #8]
+ // using read/write in place.
+ // - use arg2 use when a simple number is needed; never a pointer!
+ // - mpy may provide a pointer to flash if we give it a qstr or small value, and if
+ // we're reading only, that's fine.
+
+ if(len_in > 1024) { // arbitrary max, increase as needed
+ 80003c6: f5b2 6f80 cmp.w r2, #1024 ; 0x400
+ __HAL_FIREWALL_PREARM_DISABLE();
+ 80003ca: 9b02 ldr r3, [sp, #8]
+ if(len_in > 1024) { // arbitrary max, increase as needed
+ 80003cc: f300 82e3 bgt.w 8000996
+
+ // Use these macros
+#define REQUIRE_IN_ONLY(x) if((rv = good_addr(buf_io, (x), len_in, true))) { goto fail; }
+#define REQUIRE_OUT(x) if((rv = good_addr(buf_io, (x), len_in, false))) { goto fail; }
+
+ switch(method_num) {
+ 80003d0: 3001 adds r0, #1
+ 80003d2: 281c cmp r0, #28
+ 80003d4: f200 81b6 bhi.w 8000744
+ 80003d8: e8df f010 tbh [pc, r0, lsl #1]
+ 80003dc: 001d02f9 .word 0x001d02f9
+ 80003e0: 00800034 .word 0x00800034
+ 80003e4: 00d100bd .word 0x00d100bd
+ 80003e8: 01f300f2 .word 0x01f300f2
+ 80003ec: 01b401b4 .word 0x01b401b4
+ 80003f0: 01b401b4 .word 0x01b401b4
+ 80003f4: 00fa01b4 .word 0x00fa01b4
+ 80003f8: 01b401b4 .word 0x01b401b4
+ 80003fc: 01240105 .word 0x01240105
+ 8000400: 01670154 .word 0x01670154
+ 8000404: 01f701ab .word 0x01f701ab
+ 8000408: 025f0206 .word 0x025f0206
+ 800040c: 02b702a2 .word 0x02b702a2
+ 8000410: 02cf02bf .word 0x02cf02bf
+ 8000414: 02eb .short 0x02eb
+ case 0: {
+ REQUIRE_OUT(64);
+ 8000416: 2300 movs r3, #0
+ 8000418: 2140 movs r1, #64 ; 0x40
+ 800041a: 4628 mov r0, r5
+ 800041c: 9200 str r2, [sp, #0]
+ 800041e: f7ff ffa3 bl 8000368
+ 8000422: 4604 mov r4, r0
+ 8000424: bb48 cbnz r0, 800047a
+
+ // Return my version string
+ memset(buf_io, 0, len_in);
+ 8000426: 4601 mov r1, r0
+ 8000428: 9a00 ldr r2, [sp, #0]
+ 800042a: 4628 mov r0, r5
+ 800042c: f00d f922 bl 800d674
+ strlcpy((char *)buf_io, version_string, len_in);
+ 8000430: 9a00 ldr r2, [sp, #0]
+ 8000432: 4987 ldr r1, [pc, #540] ; (8000650 )
+ 8000434: 4628 mov r0, r5
+ 8000436: f00d f93b bl 800d6b0
+
+ rv = strlen(version_string);
+ 800043a: 4885 ldr r0, [pc, #532] ; (8000650 )
+ 800043c: f00d f94d bl 800d6da
+ ae_setup();
+ ae_keep_alive();
+ switch(arg2) {
+ default:
+ case 0: // read state
+ rv = ae_get_gpio();
+ 8000440: 4604 mov r4, r0
+ break;
+ 8000442: e01a b.n 800047a
+ REQUIRE_OUT(32);
+ 8000444: 2300 movs r3, #0
+ 8000446: 2120 movs r1, #32
+ 8000448: 4628 mov r0, r5
+ 800044a: f7ff ff8d bl 8000368
+ 800044e: 4604 mov r4, r0
+ 8000450: b998 cbnz r0, 800047a
+ sha256_init(&ctx);
+ 8000452: a80b add r0, sp, #44 ; 0x2c
+ 8000454: f005 f81a bl 800548c
+ sha256_update(&ctx, (void *)&arg2, 4);
+ 8000458: 2204 movs r2, #4
+ 800045a: eb0d 0102 add.w r1, sp, r2
+ 800045e: a80b add r0, sp, #44 ; 0x2c
+ 8000460: f005 f822 bl 80054a8
+ sha256_update(&ctx, (void *)BL_FLASH_BASE, BL_FLASH_SIZE);
+ 8000464: f04f 6100 mov.w r1, #134217728 ; 0x8000000
+ 8000468: a80b add r0, sp, #44 ; 0x2c
+ 800046a: f44f 32e0 mov.w r2, #114688 ; 0x1c000
+ 800046e: f005 f81b bl 80054a8
+ sha256_final(&ctx, buf_io);
+ 8000472: 4629 mov r1, r5
+ 8000474: a80b add r0, sp, #44 ; 0x2c
+ 8000476: f005 f85d bl 8005534
+
+fail:
+
+ // Precaution: we don't want to leave SE1 authorized for any specific keys,
+ // perhaps due to an error path we didn't see. Always reset the chip.
+ ae_reset_chip();
+ 800047a: f002 fa95 bl 80029a8
+
+ // Unlikely it matters, but clear flash memory cache.
+ __HAL_FLASH_DATA_CACHE_DISABLE();
+ 800047e: 4b75 ldr r3, [pc, #468] ; (8000654 )
+ 8000480: 681a ldr r2, [r3, #0]
+ 8000482: f422 6280 bic.w r2, r2, #1024 ; 0x400
+ 8000486: 601a str r2, [r3, #0]
+ __HAL_FLASH_DATA_CACHE_RESET();
+ 8000488: 681a ldr r2, [r3, #0]
+ 800048a: f442 5280 orr.w r2, r2, #4096 ; 0x1000
+ 800048e: 601a str r2, [r3, #0]
+ 8000490: 681a ldr r2, [r3, #0]
+ 8000492: f422 5280 bic.w r2, r2, #4096 ; 0x1000
+ 8000496: 601a str r2, [r3, #0]
+ __HAL_FLASH_DATA_CACHE_ENABLE();
+ 8000498: 681a ldr r2, [r3, #0]
+ 800049a: f442 6280 orr.w r2, r2, #1024 ; 0x400
+ 800049e: 601a str r2, [r3, #0]
+
+ // .. and instruction memory (flash cache too?)
+ __HAL_FLASH_INSTRUCTION_CACHE_DISABLE();
+ 80004a0: 681a ldr r2, [r3, #0]
+ 80004a2: f422 7200 bic.w r2, r2, #512 ; 0x200
+ 80004a6: 601a str r2, [r3, #0]
+ __HAL_FLASH_INSTRUCTION_CACHE_RESET();
+ 80004a8: 681a ldr r2, [r3, #0]
+ 80004aa: f442 6200 orr.w r2, r2, #2048 ; 0x800
+ 80004ae: 601a str r2, [r3, #0]
+ 80004b0: 681a ldr r2, [r3, #0]
+ 80004b2: f422 6200 bic.w r2, r2, #2048 ; 0x800
+ 80004b6: 601a str r2, [r3, #0]
+ __HAL_FLASH_INSTRUCTION_CACHE_ENABLE();
+ 80004b8: 681a ldr r2, [r3, #0]
+ 80004ba: f442 7200 orr.w r2, r2, #512 ; 0x200
+ 80004be: 601a str r2, [r3, #0]
+
+ // authorize return from firewall into user's code
+ __HAL_FIREWALL_PREARM_ENABLE();
+ 80004c0: f5a3 3382 sub.w r3, r3, #66560 ; 0x10400
+
+ return rv;
+}
+ 80004c4: 4620 mov r0, r4
+ __HAL_FIREWALL_PREARM_ENABLE();
+ 80004c6: 6a1a ldr r2, [r3, #32]
+ 80004c8: f042 0201 orr.w r2, r2, #1
+ 80004cc: 621a str r2, [r3, #32]
+ 80004ce: 6a1b ldr r3, [r3, #32]
+ 80004d0: f003 0301 and.w r3, r3, #1
+ 80004d4: 930b str r3, [sp, #44] ; 0x2c
+ 80004d6: 9b0b ldr r3, [sp, #44] ; 0x2c
+}
+ 80004d8: b01e add sp, #120 ; 0x78
+ 80004da: bd70 pop {r4, r5, r6, pc}
+// Write bag number (probably a string)
+void flash_save_bag_number(const uint8_t new_number[32]);
+
+// Are we operating in level2?
+static inline bool flash_is_security_level2(void) {
+ rng_delay();
+ 80004dc: f002 f94e bl 800277c
+ return ((FLASH->OPTR & FLASH_OPTR_RDP_Msk) == 0xCC);
+ 80004e0: 4b5c ldr r3, [pc, #368] ; (8000654 )
+ 80004e2: 6a1b ldr r3, [r3, #32]
+ 80004e4: b2db uxtb r3, r3
+ 80004e6: f1a3 02cc sub.w r2, r3, #204 ; 0xcc
+ 80004ea: 4255 negs r5, r2
+ 80004ec: 4155 adcs r5, r2
+ switch(arg2) {
+ 80004ee: 9a01 ldr r2, [sp, #4]
+ 80004f0: 2a02 cmp r2, #2
+ 80004f2: d01c beq.n 800052e
+ 80004f4: 2a03 cmp r2, #3
+ 80004f6: d01f beq.n 8000538
+ 80004f8: 2a01 cmp r2, #1
+ 80004fa: d013 beq.n 8000524
+ if(secure) {
+ 80004fc: 2bcc cmp r3, #204 ; 0xcc
+ 80004fe: f000 8216 beq.w 800092e
+ puts("Die: DFU");
+ 8000502: 4855 ldr r0, [pc, #340] ; (8000658 )
+ scr = screen_upgrading; // was screen_dfu, but limited audience
+ 8000504: 4c55 ldr r4, [pc, #340] ; (800065c )
+ puts("Die: DFU");
+ 8000506: f004 fc51 bl 8004dac
+ bool secure = flash_is_security_level2();
+ 800050a: 2500 movs r5, #0
+ oled_setup();
+ 800050c: f000 fc0a bl 8000d24
+ oled_show(scr);
+ 8000510: 4620 mov r0, r4
+ 8000512: f000 fc97 bl 8000e44
+ wipe_all_sram();
+ 8000516: f000 fa77 bl 8000a08
+ psram_wipe();
+ 800051a: f004 fd6f bl 8004ffc
+ if(secure) {
+ 800051e: b18d cbz r5, 8000544
+ LOCKUP_FOREVER();
+ 8000520: bf30 wfi
+ 8000522: e7fd b.n 8000520
+ puts("Die: Downgrade");
+ 8000524: 484e ldr r0, [pc, #312] ; (8000660 )
+ scr = screen_downgrade;
+ 8000526: 4c4f ldr r4, [pc, #316] ; (8000664 )
+ puts("Die: Downgrade");
+ 8000528: f004 fc40 bl 8004dac
+ break;
+ 800052c: e7ee b.n 800050c
+ puts("Die: Blankish");
+ 800052e: 484e ldr r0, [pc, #312] ; (8000668 )
+ scr = screen_blankish;
+ 8000530: 4c4e ldr r4, [pc, #312] ; (800066c )
+ puts("Die: Blankish");
+ 8000532: f004 fc3b bl 8004dac
+ break;
+ 8000536: e7e9 b.n 800050c
+ puts("Die: Brick");
+ 8000538: 484d ldr r0, [pc, #308] ; (8000670 )
+ scr = screen_brick;
+ 800053a: 4c4e ldr r4, [pc, #312] ; (8000674 )
+ puts("Die: Brick");
+ 800053c: f004 fc36 bl 8004dac
+ secure = true; // no point going into DFU, if even possible
+ 8000540: 2501 movs r5, #1
+ break;
+ 8000542: e7e3 b.n 800050c
+ memcpy(dfu_flag->magic, REBOOT_TO_DFU, sizeof(dfu_flag->magic));
+ 8000544: 494c ldr r1, [pc, #304] ; (8000678 )
+ 8000546: 4a4d ldr r2, [pc, #308] ; (800067c )
+ 8000548: 6808 ldr r0, [r1, #0]
+ 800054a: 6849 ldr r1, [r1, #4]
+ 800054c: 4613 mov r3, r2
+ 800054e: c303 stmia r3!, {r0, r1}
+ dfu_flag->screen = scr;
+ 8000550: 6094 str r4, [r2, #8]
+ NVIC_SystemReset();
+ 8000552: f7ff fef7 bl 8000344 <__NVIC_SystemReset>
+ switch(arg2) {
+ 8000556: 9b01 ldr r3, [sp, #4]
+ 8000558: f033 0302 bics.w r3, r3, #2
+ 800055c: d102 bne.n 8000564
+ oled_show(screen_logout);
+ 800055e: 4848 ldr r0, [pc, #288] ; (8000680 )
+ 8000560: f000 fc70 bl 8000e44
+ wipe_all_sram();
+ 8000564: f000 fa50 bl 8000a08
+ psram_wipe();
+ 8000568: f004 fd48 bl 8004ffc
+ if(arg2 == 2) {
+ 800056c: 9b01 ldr r3, [sp, #4]
+ 800056e: 2b02 cmp r3, #2
+ 8000570: d103 bne.n 800057a
+ delay_ms(100);
+ 8000572: 2064 movs r0, #100 ; 0x64
+ 8000574: f003 f9c0 bl 80038f8
+ 8000578: e7eb b.n 8000552
+ LOCKUP_FOREVER();
+ 800057a: bf30 wfi
+ 800057c: e7fd b.n 800057a
+ ae_setup();
+ 800057e: f002 fa21 bl 80029c4
+ ae_keep_alive();
+ 8000582: f002 fa51 bl 8002a28
+ switch(arg2) {
+ 8000586: 9b01 ldr r3, [sp, #4]
+ 8000588: 2b02 cmp r3, #2
+ 800058a: d00a beq.n 80005a2
+ 800058c: 2b03 cmp r3, #3
+ 800058e: d00a beq.n 80005a6
+ 8000590: 2b01 cmp r3, #1
+ 8000592: d002 beq.n 800059a
+ rv = ae_get_gpio();
+ 8000594: f002 ffc6 bl 8003524
+ 8000598: e752 b.n 8000440
+ rv = ae_set_gpio(0);
+ 800059a: 2000 movs r0, #0
+ rv = ae_set_gpio(1);
+ 800059c: f002 ff94 bl 80034c8
+ 80005a0: e74e b.n 8000440
+ 80005a2: 2001 movs r0, #1
+ 80005a4: e7fa b.n 800059c
+ checksum_flash(fw_digest, world_digest, 0);
+ 80005a6: 2200 movs r2, #0
+ 80005a8: a90b add r1, sp, #44 ; 0x2c
+ 80005aa: a803 add r0, sp, #12
+ 80005ac: f001 fa44 bl 8001a38
+ rv = ae_set_gpio_secure(world_digest);
+ 80005b0: a80b add r0, sp, #44 ; 0x2c
+ 80005b2: f002 ff9f bl 80034f4
+ 80005b6: 4604 mov r4, r0
+ oled_show(screen_blankish);
+ 80005b8: 482c ldr r0, [pc, #176] ; (800066c )
+ 80005ba: f000 fc43 bl 8000e44
+ break;
+ 80005be: e75c b.n 800047a
+ ae_setup();
+ 80005c0: f002 fa00 bl 80029c4
+ rv = (ae_pair_unlock() != 0);
+ 80005c4: f002 fbf4 bl 8002db0
+ 80005c8: 1e04 subs r4, r0, #0
+ 80005ca: bf18 it ne
+ 80005cc: 2401 movne r4, #1
+ break;
+ 80005ce: e754 b.n 800047a
+ REQUIRE_OUT(1);
+ 80005d0: 2300 movs r3, #0
+ 80005d2: 2101 movs r1, #1
+ 80005d4: 4628 mov r0, r5
+ 80005d6: f7ff fec7 bl 8000368
+ 80005da: 4604 mov r4, r0
+ 80005dc: 2800 cmp r0, #0
+ 80005de: f47f af4c bne.w 800047a
+ buf_io[0] = 0; // NOT SUPPORTED on Mk4
+ 80005e2: 7028 strb r0, [r5, #0]
+ break;
+ 80005e4: e749 b.n 800047a
+ if(len_in != 4 && len_in != 32 && len_in != 72) {
+ 80005e6: 2a04 cmp r2, #4
+ 80005e8: d004 beq.n 80005f4
+ 80005ea: 2a20 cmp r2, #32
+ 80005ec: d002 beq.n 80005f4
+ 80005ee: 2a48 cmp r2, #72 ; 0x48
+ 80005f0: f040 81d1 bne.w 8000996
+ REQUIRE_OUT(4);
+ 80005f4: 2300 movs r3, #0
+ 80005f6: 2104 movs r1, #4
+ 80005f8: 4628 mov r0, r5
+ 80005fa: 9200 str r2, [sp, #0]
+ 80005fc: f7ff feb4 bl 8000368
+ 8000600: 4604 mov r4, r0
+ 8000602: 2800 cmp r0, #0
+ 8000604: f47f af39 bne.w 800047a
+ ae_setup();
+ 8000608: f002 f9dc bl 80029c4
+ if(ae_read_data_slot(arg2 & 0xf, buf_io, len_in)) {
+ 800060c: 9801 ldr r0, [sp, #4]
+ 800060e: 9a00 ldr r2, [sp, #0]
+ 8000610: 4629 mov r1, r5
+ 8000612: f000 000f and.w r0, r0, #15
+ 8000616: f002 ff11 bl 800343c
+ if(rv) {
+ 800061a: 2800 cmp r0, #0
+ 800061c: f000 80d1 beq.w 80007c2
+ rv = EIO;
+ 8000620: 2405 movs r4, #5
+ 8000622: e72a b.n 800047a
+ REQUIRE_OUT(MAX_PIN_LEN);
+ 8000624: 2300 movs r3, #0
+ 8000626: 2120 movs r1, #32
+ 8000628: 4628 mov r0, r5
+ 800062a: f7ff fe9d bl 8000368
+ 800062e: 4604 mov r4, r0
+ 8000630: 2800 cmp r0, #0
+ 8000632: f47f af22 bne.w 800047a
+ if((arg2 < 1) || (arg2 > MAX_PIN_LEN)) {
+ 8000636: 9901 ldr r1, [sp, #4]
+ 8000638: 1e4b subs r3, r1, #1
+ 800063a: 2b1f cmp r3, #31
+ 800063c: f200 81ab bhi.w 8000996
+ if(pin_prefix_words((char *)buf_io, arg2, (uint32_t *)buf_io)) {
+ 8000640: 462a mov r2, r5
+ 8000642: 4628 mov r0, r5
+ 8000644: f003 fc5c bl 8003f00
+ 8000648: e7e7 b.n 800061a
+ 800064a: bf00 nop
+ 800064c: 40011c00 .word 0x40011c00
+ 8000650: 0800e720 .word 0x0800e720
+ 8000654: 40022000 .word 0x40022000
+ 8000658: 0800d706 .word 0x0800d706
+ 800065c: 0800e18b .word 0x0800e18b
+ 8000660: 0800d70f .word 0x0800d70f
+ 8000664: 0800da7a .word 0x0800da7a
+ 8000668: 0800d71e .word 0x0800d71e
+ 800066c: 0800d7de .word 0x0800d7de
+ 8000670: 0800d72c .word 0x0800d72c
+ 8000674: 0800d80b .word 0x0800d80b
+ 8000678: 0800d737 .word 0x0800d737
+ 800067c: 20008000 .word 0x20008000
+ 8000680: 0800db96 .word 0x0800db96
+ REQUIRE_OUT(32);
+ 8000684: 2300 movs r3, #0
+ 8000686: 2120 movs r1, #32
+ 8000688: 4628 mov r0, r5
+ 800068a: f7ff fe6d bl 8000368
+ 800068e: 4604 mov r4, r0
+ 8000690: 2800 cmp r0, #0
+ 8000692: f47f aef2 bne.w 800047a
+ memset(buf_io, 0x55, 32); // to help show errors
+ 8000696: 2220 movs r2, #32
+ 8000698: 2155 movs r1, #85 ; 0x55
+ 800069a: 4628 mov r0, r5
+ 800069c: f00c ffea bl 800d674
+ rng_buffer(buf_io, 32);
+ 80006a0: 2120 movs r1, #32
+ 80006a2: 4628 mov r0, r5
+ 80006a4: f002 f854 bl 8002750
+ break;
+ 80006a8: e6e7 b.n 800047a
+ REQUIRE_OUT(PIN_ATTEMPT_SIZE_V2);
+ 80006aa: 2300 movs r3, #0
+ 80006ac: f44f 718c mov.w r1, #280 ; 0x118
+ 80006b0: 4628 mov r0, r5
+ 80006b2: 9200 str r2, [sp, #0]
+ 80006b4: f7ff fe58 bl 8000368
+ 80006b8: 4604 mov r4, r0
+ 80006ba: 2800 cmp r0, #0
+ 80006bc: f47f aedd bne.w 800047a
+ switch(arg2) {
+ 80006c0: e9dd 2300 ldrd r2, r3, [sp]
+ 80006c4: 2b08 cmp r3, #8
+ 80006c6: d83d bhi.n 8000744
+ 80006c8: e8df f003 tbb [pc, r3]
+ 80006cc: 110d0905 .word 0x110d0905
+ 80006d0: 221d1915 .word 0x221d1915
+ 80006d4: 26 .byte 0x26
+ 80006d5: 00 .byte 0x00
+ rv = pin_setup_attempt(args);
+ 80006d6: 4628 mov r0, r5
+ 80006d8: f003 fc30 bl 8003f3c
+ 80006dc: e6b0 b.n 8000440
+ rv = pin_delay(args);
+ 80006de: 4628 mov r0, r5
+ 80006e0: f003 fc9a bl 8004018
+ 80006e4: e6ac b.n 8000440
+ rv = pin_login_attempt(args);
+ 80006e6: 4628 mov r0, r5
+ 80006e8: f003 fc98 bl 800401c
+ 80006ec: e6a8 b.n 8000440
+ rv = pin_change(args);
+ 80006ee: 4628 mov r0, r5
+ 80006f0: f003 fda2 bl 8004238
+ 80006f4: e6a4 b.n 8000440