improve taptree parser
remove msas (always allowed) remove unsort_ms (always allowed) rework fake_txn # Conflicts: # cli/signit.py # releases/ChangeLog.md # releases/History-Mk4.md # releases/Next-ChangeLog.md # releases/signatures.txt # shared/actions.py # shared/address_explorer.py # shared/auth.py # shared/backups.py # shared/chains.py # shared/decoders.py # shared/descriptor.py # shared/display.py # shared/export.py # shared/flow.py # shared/lcd_display.py # shared/multisig.py # shared/nfc.py # shared/notes.py # shared/nvstore.py # shared/ownership.py # shared/paper.py # shared/psbt.py # shared/qrs.py # shared/seed.py # shared/serializations.py # shared/utils.py # shared/ux.py # shared/ux_mk4.py # shared/ux_q1.py # shared/version.py # shared/wallet.py # shared/xor_seed.py # stm32/COLDCARD_MK4/file_time.c # stm32/COLDCARD_Q1/file_time.c # stm32/MK4-Makefile # stm32/Q1-Makefile # testing/conftest.py # testing/helpers.py # testing/test_address_explorer.py # testing/test_backup.py # testing/test_bbqr.py # testing/test_export.py # testing/test_msg.py # testing/test_multisig.py # testing/test_notes.py # testing/test_ownership.py # testing/test_sign.py # testing/test_unit.py # testing/txn.py
This commit is contained in:
parent
c10aff8a02
commit
e209980630
@ -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_MK4, 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, \
|
||||
|
||||
224
docs/key-teleport.md
Normal file
224
docs/key-teleport.md
Normal file
@ -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.
|
||||
@ -55,8 +55,11 @@
|
||||
|
||||
- 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.
|
||||
@ -199,3 +202,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
|
||||
|
||||
# CCC Feature (ColdCard Cosigning)
|
||||
|
||||
- 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 prevent CCC cosign.
|
||||
|
||||
|
||||
@ -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,7 @@
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
Seed XOR
|
||||
Migrate Coldcard
|
||||
Help
|
||||
Advanced/Tools
|
||||
View Identity
|
||||
@ -54,17 +54,22 @@
|
||||
From VirtDisk [IF VIRTDISK ENABLED]
|
||||
File Management
|
||||
Verify Backup
|
||||
Teleport Multisig PSBT [IF QR AND SECRET]
|
||||
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,6 +160,9 @@
|
||||
Menu Wrapping
|
||||
Default Off
|
||||
Enable
|
||||
Home Menu XFP [IF SECRET AND NOT TMP SEED]
|
||||
Only Tmp
|
||||
Always Show
|
||||
---
|
||||
|
||||
[NORMAL OPERATION]
|
||||
@ -223,8 +237,13 @@
|
||||
Clone Coldcard
|
||||
Export Wallet
|
||||
Bitcoin Core
|
||||
Fully Noded
|
||||
Sparrow Wallet
|
||||
Nunchuk
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Wasabi Wallet
|
||||
Unchained
|
||||
Lily Wallet
|
||||
@ -235,7 +254,7 @@
|
||||
Export XPUB
|
||||
Segwit (BIP-84)
|
||||
Classic (BIP-44)
|
||||
P2WPKH/P2SH (49)
|
||||
P2WPKH/P2SH (BIP-49)
|
||||
Master XPUB
|
||||
Current XFP
|
||||
Dump Summary
|
||||
@ -248,8 +267,13 @@
|
||||
Backup System
|
||||
Export Wallet
|
||||
Bitcoin Core
|
||||
Fully Noded
|
||||
Sparrow Wallet
|
||||
Nunchuk
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Wasabi Wallet
|
||||
Unchained
|
||||
Lily Wallet
|
||||
@ -260,15 +284,18 @@
|
||||
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 [IF QR AND SECRET]
|
||||
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]
|
||||
@ -314,18 +341,23 @@
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
Coldcard Backup
|
||||
Key Teleport (start)
|
||||
Paper Wallets
|
||||
Enable HSM [IF HSM AND SECRET]
|
||||
Default Off
|
||||
Enable
|
||||
Coldcard Co-Signing [IF NOT TMP SEED]
|
||||
User Management [IF HSM AND SECRET]
|
||||
(no users yet)
|
||||
NFC Tools [IF NFC ENABLED]
|
||||
Sign PSBT
|
||||
Show Address
|
||||
Sign Message
|
||||
Verify Sig File
|
||||
Verify Address
|
||||
File Share
|
||||
Import Multisig
|
||||
Push Transaction [IF ENBALED]
|
||||
Danger Zone
|
||||
Debug Functions
|
||||
Seed Functions
|
||||
@ -334,13 +366,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,11 +387,14 @@
|
||||
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
|
||||
@ -427,11 +464,21 @@
|
||||
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 +513,9 @@
|
||||
50%
|
||||
60%
|
||||
70%
|
||||
80% (default)
|
||||
80%
|
||||
90%
|
||||
95% (default)
|
||||
100%
|
||||
Delete PSBTs
|
||||
Default Keep
|
||||
@ -475,22 +523,27 @@
|
||||
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 ENBALED]
|
||||
---
|
||||
|
||||
[FACTORY MODE]
|
||||
Version: 5.x.x
|
||||
Bag Me Now
|
||||
Version: 5.x.x
|
||||
DFU Upgrade
|
||||
Ship W/O Bag
|
||||
Debug Functions
|
||||
|
||||
@ -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/<app_deriv>/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/<app_deriv>h/<coin_type>'h/<account>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'/<coin_type>'/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/<coin_type>h/<account>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/<coin_type>h/<account>h/2h/0/0`
|
||||
* Multisig address explorer export: Signed by own key at the same derivation as first (0th) row on exported list. `my_key/<change>/<start_index>`
|
||||
|
||||
### What is NOT signed
|
||||
|
||||
|
||||
91
docs/web2fa.md
Normal file
91
docs/web2fa.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
|
||||
For now, Web2FA is only being used as part of CCC spending policy (opt-in),
|
||||
but we may find other uses for it.
|
||||
|
||||
## 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 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.
|
||||
|
||||
@ -4,65 +4,30 @@ This lists the changes in the most recent firmware, for each hardware platform.
|
||||
|
||||
# Shared Improvements - Both Mk4 and Q
|
||||
|
||||
- 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":"<required msg>","subpath":"<optional sp>","addr_fmt": "<optional af>"}`
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
|
||||
# Mk4 Specific Changes
|
||||
|
||||
## 5.4.1 - 2024-02-13
|
||||
## 5.4.3 - 2025-05-14
|
||||
|
||||
- Enhancement: Export single sig descriptor with simple QR.
|
||||
- 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.
|
||||
|
||||
|
||||
# Q Specific Changes
|
||||
|
||||
## 1.3.1Q - 2024-02-13
|
||||
|
||||
- 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.3Q - 2025-05-14
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
# Release History
|
||||
|
||||
@ -1,5 +1,82 @@
|
||||
*See ChangeLog.md for more recent changes, these are historic versions*
|
||||
|
||||
## 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":"<required msg>","subpath":"<optional sp>","addr_fmt": "<optional af>"}`
|
||||
- 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
|
||||
|
||||
@ -1,5 +1,139 @@
|
||||
*See ChangeLog.md for more recent changes, these are historic versions*
|
||||
|
||||
## 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":"<required msg>","subpath":"<optional sp>","addr_fmt": "<optional af>"}`
|
||||
- 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":"<descriptor>"}` 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
|
||||
|
||||
|
||||
@ -4,18 +4,17 @@ This lists the new changes that have not yet been published in a normal release.
|
||||
|
||||
# Shared Improvements - Both Mk4 and Q
|
||||
|
||||
- tbd
|
||||
|
||||
|
||||
# Mk4 Specific Changes
|
||||
|
||||
## 5.4.2 - 2024-03-??
|
||||
## 5.4.? - 2025-06-
|
||||
|
||||
- tbd
|
||||
- Bugfix: Part of extended keys in stories were not always visible.
|
||||
|
||||
|
||||
# Q Specific Changes
|
||||
|
||||
## 1.3.2Q - 2024-03-??
|
||||
## 1.3.?Q - 2025-06
|
||||
|
||||
|
||||
- tbd
|
||||
|
||||
@ -2,41 +2,119 @@
|
||||
Hash: SHA256
|
||||
|
||||
95eff9e044cdb6b3d00961ae72d450684d5441c6a3661ab550a3c3aa0882e754 README.md
|
||||
8f027f7bf0b571f75acac01b6d7bb25ece6873ee2297c9138f40de553613a30e Next-ChangeLog.md
|
||||
b6015f2f807bc78b6063ed6c12a12a47579a81a68f954cfc2e542e7ac6c02c0e History-Q.md
|
||||
05228d2c59135c3fe251d877b519bec65f929ecf0aac8b727622359014236568 History-Mk4.md
|
||||
3ba92e73d5260656641828e962e8eae4590f59774150d14276818a5229daf734 Next-ChangeLog.md
|
||||
0173cade759704320e7a43810dabd5f18cf2034b447c6c7996f447c8d3ad21de History-Q.md
|
||||
e6192bd7c2b27df7c9d8e58ae9a41bda4ef0615991c3159fb05ff60dc3cfedd1 History-Mk4.md
|
||||
c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md
|
||||
7fcd753917adbdfeb7f736c2f2269bcc310839d29309eb36543e9826c865cb5d History-Edge.md
|
||||
0eaa18b39e2c12343584a4dce99b61f29a2305ce3a71ff59dff5ab3e54e5c1c9 EdgeChangeLog.md
|
||||
45cd0478996bb9da77075846122b8ba732b9b34dbbae0d12cb85ad0d931d40fc ChangeLog.md
|
||||
6e8b95855e05dc7889b1476acfb1854107b4e8df6f12cdf4a643a9776e60c798 ChangeLog.md
|
||||
be166b3bb3ec2259991db998c20c3d44e88eeaa73c2b8114f31cb14cab5e66e6 2025-05-14T1344-v5.4.3-mk4-coldcard.dfu
|
||||
876932d4ea7634d268145d5bf45577c7198c9d60e8a271b5079faba4d4c91acd 2025-05-14T1344-v5.4.3-mk4-coldcard-factory.dfu
|
||||
aaed0b90be5de310c8ac9f2d0cb3a7eea58923a53d349eb4b9ac8a902e5cba4e 2025-05-14T1343-v1.3.3Q-q1-coldcard.dfu
|
||||
9daa2b48abdfa2303a43ee1d0ba3d0d905e7f6286018f44a0dda3c755c46039e 2025-05-14T1343-v1.3.3Q-q1-coldcard-factory.dfu
|
||||
c1202ba30db68a12b882176997f08844da4ec31087ac2f507ea1d4281d2faa9a 2025-04-16T1907-v5.4.2-mk4-coldcard.dfu
|
||||
a82fff91f8da35c122b09d54b01b3d3f3b8825fbc7cddee8e686fd8d57a69285 2025-04-16T1907-v5.4.2-mk4-coldcard-factory.dfu
|
||||
4393c67f8dcbb8890950678f5856d2cca81042b9a447ce149fe624ddfe336a2a 2025-04-16T1906-v1.3.2Q-q1-coldcard.dfu
|
||||
6f2d77f99d61cad9328cc617754aadb3b828150386def41c946d90db8cfb2277 2025-04-16T1906-v1.3.2Q-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
|
||||
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
|
||||
a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu
|
||||
cc93209e800bc05386b5613969e62c27b9acd4388e3a922686525da90a505778 2024-01-18T1507-v6.2.2X-mk4-coldcard-factory.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
|
||||
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
|
||||
66c83c3f95fd3d0796b1e452d2e8ed8ac6a4abead53faf5ae793eceb6f7bbdb5 2023-06-20T1506-v6.1.0X-mk4-coldcard-factory.dfu
|
||||
233398cc8f6b9e894072448eb8b8a82a4f546219ce461dd821f0ed0a38b61900 2023-06-19T1627-v4.1.8-coldcard.dfu
|
||||
2e8ed970f518a476d0b34752ecbad75bab246669aa65de8f43801364c6f5753e 2023-05-12T1316-v6.0.0X-mk4-coldcard.dfu
|
||||
8dd5ff029bb2b08c857604f0c9b5773931f6683ee331ecbc35d9ab4c460b745f 2023-05-12T1316-v6.0.0X-mk4-coldcard-factory.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
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAme2M9sACgkQo6MbrVoq
|
||||
WxDroQf/eewxSI7773hBITAqWFxy9ISBrtOWmRqTp+uNCB4fz9vKPPrgb9WYFipH
|
||||
qpsthUZCgL3tF5GuOzXwGrkO6nGzLqEiTHof71CjskVs9f+dwVOAFIFHYBQcZv1s
|
||||
DeLiFuCENoXrMW36tyywSU9x3kjSmf37+NgVoJrr/dsi/PMfVPgBr8vMvum4COrM
|
||||
W6opBDWLB3CgWPIAC7QqhJPBZ9KWBR6msFghyMsJm2YMgeg1WnsFKRlxpHUTb0bD
|
||||
BhUnayt81I/Rmoeb8mpoM9tFohwf/WbPIEMLNYF8BnNQ/iwnvRd29jyDcUkhV8F9
|
||||
6s2209+xZLoXC78R9iUkJGM9ksflEg==
|
||||
=C67v
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmgknkYACgkQo6MbrVoq
|
||||
WxBkuggAqTFP4YJdkzdNPbPDxtnCL4ZFJ+Rtnybp9JigTazbMvA/pjR+uODPFI3M
|
||||
Pm8I6kNPY8lMOPptEiFpNHn8EL8i2jOdH4NcmSP9OYInCRWyknm8fbmboSkOueAp
|
||||
SG3irwVXf/XWMMpBdXvALPPvttPzlVOLYowYnervDPiINiQDkd5jRP+Kd0AStVEt
|
||||
/QNq3ocmYHj4AUhJ5YSkyyVnnmGrZzKpcJ1q0XxXFCMJnyBrkjkJ60SgDx+ucy7c
|
||||
vTVk+W8QyLfqFkbhv4OT7YBITNGHEwk8sZ6V3N98r2/8Hx5PI42QOKEARYtOTpip
|
||||
oj0LNnPFnAIkOTwZVazuc+vtG/GgSA==
|
||||
=IRUs
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
@ -9,14 +9,14 @@ 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, 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, ux_render_words
|
||||
from export import make_json_wallet, make_summary_file, make_descriptor_wallet_export
|
||||
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 files import CardSlot, CardMissingError, needs_microsd
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR, 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 version import MAX_TXN_LEN
|
||||
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
|
||||
@ -526,6 +524,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 +561,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
|
||||
@ -581,17 +580,20 @@ async def clear_seed(*a):
|
||||
'All funds will be lost. '
|
||||
'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.''', 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)
|
||||
@ -616,7 +618,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:
|
||||
@ -626,28 +633,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)
|
||||
|
||||
@ -657,33 +666,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
|
||||
|
||||
@ -706,12 +717,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)
|
||||
|
||||
@ -723,7 +729,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)
|
||||
|
||||
@ -827,7 +833,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
|
||||
|
||||
@ -848,9 +854,7 @@ async def start_login_sequence():
|
||||
try:
|
||||
from pwsave import MicroSD2FA
|
||||
MicroSD2FA.enforce_policy()
|
||||
except BaseException as exc:
|
||||
# robustness: keep going!
|
||||
sys.print_exception(exc)
|
||||
except: pass # robustness: keep going!
|
||||
|
||||
# implement idle timeout now that we are logged-in
|
||||
IMPT.start_task('idle', idle_logout())
|
||||
@ -994,7 +998,7 @@ SENSITIVE_NOT_SECRET = '''
|
||||
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):
|
||||
@ -1218,9 +1222,9 @@ without ever connecting this Coldcard to a computer.\
|
||||
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
|
||||
@ -1232,7 +1236,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
|
||||
@ -1267,7 +1272,8 @@ You can then open that file in Wasabi without ever connecting this Coldcard to 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
|
||||
@ -1284,9 +1290,8 @@ This saves multisig XPUB information required to setup on the Unchained platform
|
||||
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):
|
||||
@ -1308,11 +1313,11 @@ 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:
|
||||
@ -1376,50 +1381,29 @@ async def import_xprv(_1, _2, item):
|
||||
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):
|
||||
|
||||
if not pa.is_secret_blank():
|
||||
await ux_show_story(EMPTY_RESTORE_MSG)
|
||||
return
|
||||
|
||||
# restore everything, using a password, from single encrypted 7z file
|
||||
fn = await file_picker(suffix='.7z')
|
||||
await backups.restore_complete(fn, item.arg, True)
|
||||
|
||||
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)
|
||||
|
||||
async def restore_everything_cleartext(*A):
|
||||
# Asssume no password on backup file; devs and crazy people only
|
||||
|
||||
if not pa.is_secret_blank():
|
||||
await ux_show_story(EMPTY_RESTORE_MSG)
|
||||
return
|
||||
|
||||
# restore everything, using NO password, from single text file, like would be wrapped in 7z
|
||||
fn = await file_picker(suffix='.txt')
|
||||
|
||||
if fn:
|
||||
import backups
|
||||
prob = await backups.restore_complete_doit(fn, [])
|
||||
if prob:
|
||||
await ux_show_story(prob, title='FAILED')
|
||||
await backups.restore_complete(fn, not pa.is_secret_blank(), words)
|
||||
|
||||
async def bkpw_override(*A):
|
||||
# allows user to:
|
||||
@ -1433,9 +1417,7 @@ async def bkpw_override(*A):
|
||||
if pa.is_secret_blank():
|
||||
return
|
||||
|
||||
if pa.is_deltamode():
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
wipe_if_deltamode()
|
||||
|
||||
while True:
|
||||
pwd = settings.get("bkpw", None)
|
||||
@ -1518,47 +1500,52 @@ async def qr_share_file(_1, _2, item):
|
||||
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, force_bbqr=force_bbqr)
|
||||
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.
|
||||
@ -1687,7 +1674,7 @@ async def list_files(*A):
|
||||
card.securely_blank_file(fn)
|
||||
break
|
||||
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)
|
||||
@ -1696,13 +1683,14 @@ async def list_files(*A):
|
||||
|
||||
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)
|
||||
|
||||
if choices is None:
|
||||
choices = []
|
||||
@ -1746,7 +1734,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
|
||||
|
||||
@ -1768,7 +1756,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 %r. ' % suffix
|
||||
|
||||
msg += '\n\nMaybe insert (another) SD card and try again?'
|
||||
|
||||
@ -1783,10 +1771,10 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None,
|
||||
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)
|
||||
@ -1900,9 +1888,9 @@ async def ready2sign(*a):
|
||||
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'''
|
||||
from your desktop wallet software or command line tools.'''
|
||||
|
||||
footnotes = ("\n\nYou will always be prompted to confirm the details "
|
||||
footnotes = ("You 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
|
||||
@ -1934,7 +1922,7 @@ from your desktop wallet software or command line tools.\n\n'''
|
||||
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
|
||||
|
||||
@ -1984,7 +1972,7 @@ async def verify_sig_file(*a):
|
||||
return
|
||||
|
||||
# start the process
|
||||
from auth import verify_txt_sig_file
|
||||
from msgsign import verify_txt_sig_file
|
||||
await verify_txt_sig_file(fn)
|
||||
|
||||
|
||||
@ -2031,7 +2019,7 @@ Write it down.'''
|
||||
while 1:
|
||||
lll.reset()
|
||||
lll.subtitle = "New " + title
|
||||
pin = await lll.get_new_pin(title, allow_clear=False)
|
||||
pin = await lll.get_new_pin(title)
|
||||
|
||||
if pin is None:
|
||||
return await ux_aborted()
|
||||
@ -2337,6 +2325,21 @@ PUSHTX_SUPPLIERS = [
|
||||
('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.
|
||||
@ -2355,12 +2358,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
|
||||
|
||||
@ -8,27 +8,26 @@ 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_P2WPKH, AF_P2TR
|
||||
from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH, AF_P2TR, AF_CLASSIC
|
||||
from multisig import MultisigWallet
|
||||
from miniscript import MiniScriptWallet
|
||||
from uasyncio import sleep_ms
|
||||
from uhashlib import sha256
|
||||
from glob import settings
|
||||
from auth import write_sig_file
|
||||
from 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
|
||||
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:]
|
||||
def censor_address(addr):
|
||||
# We don't like to show the user full multisig addresses because we cannot be certain
|
||||
# they could actually be signed. And yet, don't blank too many
|
||||
# spots or else an attacker could grind out a suitable replacement.
|
||||
# 3 chars in the middle hidden by default
|
||||
# censoring can be disabled by msas setting
|
||||
if settings.get("msas", 0):
|
||||
return addr
|
||||
return addr[0:12] + '___' + addr[12+3:]
|
||||
|
||||
class KeypathMenu(MenuSystem):
|
||||
def __init__(self, path=None, nl=0):
|
||||
@ -312,8 +311,10 @@ Press (3) if you really understand and accept these risks.
|
||||
|
||||
# export options
|
||||
k0 = 'to show change addresses' if allow_change and change == 0 else None
|
||||
export_msg, escape = export_prompt_builder('address summary file',
|
||||
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+KEY_QR
|
||||
else:
|
||||
@ -359,6 +360,7 @@ Press (3) if you really understand and accept these risks.
|
||||
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, is_addrs=True)
|
||||
|
||||
continue
|
||||
|
||||
elif NFC and (choice == KEY_NFC):
|
||||
@ -376,7 +378,7 @@ Press (3) if you really understand and accept these risks.
|
||||
else:
|
||||
# only custom path sets allow_change to False
|
||||
# msg sign
|
||||
from auth import sign_with_own_address
|
||||
from msgsign import sign_with_own_address
|
||||
await sign_with_own_address(path, addr_fmt)
|
||||
|
||||
elif n is None:
|
||||
@ -445,7 +447,7 @@ 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.
|
||||
@ -457,7 +459,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:
|
||||
@ -468,27 +469,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 and addr_fmt != AF_P2TR:
|
||||
derive = path.format(account=account_num, change=change, idx=start) # first addr
|
||||
if addr_fmt != AF_P2TR:
|
||||
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(settings.get('xfp'))
|
||||
derive += "/%d/%d" % (change, start)
|
||||
else:
|
||||
derive = path.format(account=account_num, change=change, idx=start) # first addr
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
1139
shared/auth.py
1139
shared/auth.py
File diff suppressed because it is too large
Load Diff
@ -5,8 +5,8 @@
|
||||
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
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text
|
||||
import version, ujson
|
||||
from uio import StringIO
|
||||
import seed
|
||||
@ -44,12 +44,7 @@ def render_backup_contents(bypass_tmp=False):
|
||||
|
||||
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))
|
||||
|
||||
@ -104,6 +99,8 @@ def render_backup_contents(bypass_tmp=False):
|
||||
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 == 'seedvault' and not v: continue
|
||||
if k == 'seeds' and not v: continue
|
||||
ADD('setting.' + k, v)
|
||||
@ -131,7 +128,7 @@ def extract_raw_secret(chain, vals):
|
||||
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)
|
||||
@ -149,9 +146,10 @@ def extract_long_secret(vals):
|
||||
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):
|
||||
@ -189,9 +187,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)
|
||||
|
||||
@ -215,13 +211,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
|
||||
@ -262,6 +262,25 @@ def restore_from_dict_ll(vals):
|
||||
|
||||
return None, need_ftux
|
||||
|
||||
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):
|
||||
from glob import dis
|
||||
|
||||
@ -276,14 +295,14 @@ async def restore_tmp_from_dict_ll(vals):
|
||||
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", "miniscript"]:
|
||||
# whitelist
|
||||
settings.set(k, v)
|
||||
settings.set(key, v)
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
@ -362,15 +381,17 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
|
||||
ckcc.rng_bytes(b)
|
||||
pwd = bip39.b2a_words(b).rsplit(' ', num_pw_words)[0]
|
||||
|
||||
ch = await seed.show_words(prompt="Record this (%d word) backup file password:\n",
|
||||
words=pwd.split(" "), 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
|
||||
@ -465,11 +486,9 @@ async def write_complete_backup(pwd, 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
|
||||
|
||||
@ -535,12 +554,13 @@ 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):
|
||||
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)
|
||||
@ -548,14 +568,24 @@ async def restore_complete(fname_or_fd, temporary=False):
|
||||
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
|
||||
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)
|
||||
|
||||
the_ux.push(m)
|
||||
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)
|
||||
pwd.append(ipw)
|
||||
|
||||
await done(pwd)
|
||||
|
||||
async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False):
|
||||
# Open file, read it, maybe decrypt it; return string if any error
|
||||
@ -566,7 +596,6 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary
|
||||
|
||||
# build password
|
||||
password = ' '.join(words)
|
||||
|
||||
prob = None
|
||||
|
||||
try:
|
||||
@ -613,19 +642,7 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary
|
||||
await needs_microsd()
|
||||
return
|
||||
|
||||
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!
|
||||
vals = text_bk_parser(contents)
|
||||
|
||||
# this leads to reboot if it works, else errors shown, etc.
|
||||
if temporary:
|
||||
|
||||
@ -6,12 +6,14 @@ import utime, uzlib, ngu
|
||||
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 @@ class BBQrState:
|
||||
# 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 @@ class BBQrState:
|
||||
# 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 @@ class BBQrPsramStorage(BBQrStorage):
|
||||
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 @@ class BBQrPsramStorage(BBQrStorage):
|
||||
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 @@ class BBQrPsramStorage(BBQrStorage):
|
||||
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")
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ 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_numbers, ux_input_text
|
||||
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
|
||||
@ -820,7 +820,8 @@ async def bsms_signer_round1(*a):
|
||||
if version.has_qwerty:
|
||||
token_int = await ux_input_text("", scan_ok=True, prompt="Decimal Token")
|
||||
else:
|
||||
token_int = await ux_input_numbers("", lambda: True)
|
||||
from ux_mk4 import ux_input_digits
|
||||
token_int = await ux_input_digits("", prompt="Decimal Token")
|
||||
token_hex = hex(int(token_int))
|
||||
else:
|
||||
return
|
||||
|
||||
@ -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
|
||||
#
|
||||
@ -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))
|
||||
|
||||
@ -59,8 +62,8 @@ Example Commands:
|
||||
if ln == 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:
|
||||
# try login
|
||||
@ -86,10 +89,8 @@ Example Commands:
|
||||
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))
|
||||
|
||||
889
shared/ccc.py
Normal file
889
shared/ccc.py
Normal file
@ -0,0 +1,889 @@
|
||||
# (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.
|
||||
#
|
||||
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 CCCPolicyViolationError
|
||||
|
||||
|
||||
# limit to number of addresses in list
|
||||
MAX_WHITELIST = const(25)
|
||||
|
||||
class CCCFeature:
|
||||
|
||||
# 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
|
||||
last_fail_reason = ""
|
||||
|
||||
@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 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 dict(settings.get('ccc', dict(pol={})).get('pol'))
|
||||
|
||||
@classmethod
|
||||
def update_policy(cls, pol):
|
||||
# serialize the spending policy, save it
|
||||
v = dict(settings.get('ccc', {}))
|
||||
v['pol'] = dict(pol)
|
||||
settings.set('ccc', v)
|
||||
return v['pol']
|
||||
|
||||
@classmethod
|
||||
def update_policy_key(cls, **kws):
|
||||
# update a few elements of the spending policy
|
||||
# - all settings "saved" as they are changed.
|
||||
# - return updated policy
|
||||
p = cls.get_policy()
|
||||
p.update(kws)
|
||||
return cls.update_policy(p)
|
||||
|
||||
@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 meets_policy(cls, psbt):
|
||||
# Does policy allow signing this? Else raise why
|
||||
pol = cls.get_policy()
|
||||
|
||||
# not safe to sign any txn w/ warnings: might be complaining about
|
||||
# massive miner fees, or weird OP_RETURN stuff
|
||||
if psbt.warnings:
|
||||
raise CCCPolicyViolationError("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 CCCPolicyViolationError("magnitude")
|
||||
|
||||
# Velocity: if zero => no velocity checks
|
||||
velocity = pol.get("vel", None)
|
||||
if velocity:
|
||||
if not psbt.lock_time:
|
||||
raise CCCPolicyViolationError("no nLockTime")
|
||||
|
||||
if psbt.lock_time >= NLOCK_IS_TIME:
|
||||
# this is unix timestamp - not allowed - fail
|
||||
raise CCCPolicyViolationError("nLockTime not height")
|
||||
|
||||
block_h = pol.get("block_h", chains.current_chain().ccc_min_block)
|
||||
if psbt.lock_time <= block_h:
|
||||
raise CCCPolicyViolationError("rewound (%d)" % psbt.lock_time)
|
||||
|
||||
# we won't sign txn unless old height + velocity >= new height
|
||||
if psbt.lock_time < (block_h + velocity):
|
||||
raise CCCPolicyViolationError("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 CCCPolicyViolationError("whitelist")
|
||||
|
||||
# Web 2FA
|
||||
# - slow, requires UX, and they might not acheive it...
|
||||
# - wait until about to do signature
|
||||
if pol.get('web2fa', False):
|
||||
psbt.warnings.append(('CCC', 'Web 2FA required.'))
|
||||
return True
|
||||
|
||||
|
||||
@classmethod
|
||||
def could_sign(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_multisig
|
||||
if not ms:
|
||||
# single-sig CCC not supported
|
||||
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 ms.xfp_paths:
|
||||
# does not involve us
|
||||
return False, False
|
||||
|
||||
try:
|
||||
# check policy
|
||||
needs_2fa = cls.meets_policy(psbt)
|
||||
except CCCPolicyViolationError as e:
|
||||
cls.last_fail_reason = str(e)
|
||||
psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
|
||||
return False, False
|
||||
|
||||
return True, 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.
|
||||
pol = cls.get_policy()
|
||||
|
||||
ok = await web2fa.perform_web2fa('Approve CCC Transaction', pol.get('web2fa'))
|
||||
if not ok:
|
||||
cls.last_fail_reason = '2FA Fail'
|
||||
raise CCCPolicyViolationError
|
||||
|
||||
@classmethod
|
||||
def sign_psbt(cls, psbt):
|
||||
# do the math
|
||||
psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp())
|
||||
cls.last_fail_reason = ""
|
||||
|
||||
old_h = cls.get_policy().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
|
||||
cls.update_policy_key(block_h=psbt.lock_time)
|
||||
settings.save()
|
||||
|
||||
|
||||
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 multisig import MultisigWallet, make_ms_wallet_menu
|
||||
|
||||
my_xfp = CCCFeature.get_xfp()
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('CCC [%s]' % xfp2str(my_xfp), f=self.show_ident),
|
||||
MenuItem('Spending Policy', menu=CCCPolicyMenu.be_a_submenu),
|
||||
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 ms in MultisigWallet.get_all():
|
||||
if my_xfp in ms.xfp_paths:
|
||||
items.append(MenuItem('↳ %d/%d: %s' % (ms.M, ms.N, ms.name),
|
||||
menu=make_ms_wallet_menu, arg=ms.storage_idx))
|
||||
count += 1
|
||||
|
||||
items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
|
||||
|
||||
if CCCFeature.last_fail_reason:
|
||||
# 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
|
||||
|
||||
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
|
||||
% CCCFeature.last_fail_reason
|
||||
ch = await ux_show_story(msg, escape='4')
|
||||
|
||||
if ch == '4':
|
||||
CCCFeature.last_fail_reason = ''
|
||||
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 multisig import export_multisig_xpubs
|
||||
await export_multisig_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 PolCheckedMenuItem(MenuItem):
|
||||
# Show a checkmark if **policy** setting is defined and not the default
|
||||
# - only works inside CCCPolicyMenu
|
||||
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, CCCPolicyMenu)
|
||||
return bool(m.policy.get(self.polkey, False))
|
||||
|
||||
|
||||
class CCCAddrWhitelist(MenuSystem):
|
||||
# simulator arg: --seq tcENTERENTERsENTERwENTER
|
||||
def __init__(self):
|
||||
items = self.construct()
|
||||
super().__init__(items)
|
||||
|
||||
def update_contents(self):
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
@classmethod
|
||||
async def be_a_submenu(cls, *a):
|
||||
return cls()
|
||||
|
||||
def construct(self):
|
||||
# list of addresses
|
||||
addrs = CCCFeature.get_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 = CCCFeature.get_policy().get('addrs', [])
|
||||
addrs.remove(addr)
|
||||
CCCFeature.update_policy_key(addrs=addrs)
|
||||
self.update_contents()
|
||||
|
||||
async def clear_all(self, *a):
|
||||
if await ux_confirm("Irreversibly remove all addresses from the whitelist?",
|
||||
confirm_key='4'):
|
||||
CCCFeature.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 = CCCFeature.get_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()
|
||||
|
||||
CCCFeature.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 CCCPolicyMenu(MenuSystem):
|
||||
# Build menu stack that allows edit of all features of the spending
|
||||
# policy. 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):
|
||||
self.policy = CCCFeature.get_policy()
|
||||
items = self.construct()
|
||||
super().__init__(items)
|
||||
|
||||
def update_contents(self):
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
@classmethod
|
||||
async def be_a_submenu(cls, *a):
|
||||
return cls()
|
||||
|
||||
def construct(self):
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
PolCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude),
|
||||
PolCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity),
|
||||
PolCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''),
|
||||
'addrs', menu=CCCAddrWhitelist.be_a_submenu),
|
||||
PolCheckedMenuItem('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('CCC Test', 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
|
||||
ss = self.policy.get('web2fa')
|
||||
assert ss
|
||||
await web2fa.web2fa_enroll('CCC', 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 = CCCFeature.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 = CCCFeature.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
|
||||
|
||||
# reminder: dont forget the poor Mk4 users
|
||||
# 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 = CCCFeature.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 = CCCFeature.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('CCC')
|
||||
if not ss:
|
||||
return
|
||||
|
||||
# update state
|
||||
self.policy = CCCFeature.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)
|
||||
|
||||
# EOF
|
||||
@ -30,6 +30,10 @@ Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint'))
|
||||
# - from <https://github.com/Bit-Wasp/bitcoin-php/issues/576>
|
||||
# - 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.
|
||||
@ -54,6 +58,8 @@ def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT):
|
||||
class ChainsBase:
|
||||
|
||||
curve = 'secp256k1'
|
||||
menu_name = None # use 'name' if this isn't defined
|
||||
ccc_min_block = 0
|
||||
|
||||
# b44_cointype comes from
|
||||
# <https://github.com/satoshilabs/slips/blob/master/slip-0044.md>
|
||||
@ -272,37 +278,46 @@ class ChainsBase:
|
||||
|
||||
@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", ""
|
||||
if OP_RETURN not in script_type:
|
||||
return
|
||||
|
||||
try:
|
||||
data = next(gen)[0]
|
||||
if data is None: raise RuntimeError
|
||||
except (RuntimeError, StopIteration):
|
||||
return "null-data", ""
|
||||
|
||||
data_ascii = None
|
||||
if len(data) > 200:
|
||||
# completely arbitrary limit, prevents huge stories
|
||||
data_hex = b2a_hex(data[:100]).decode() + "\n ⋯\n" + b2a_hex(data[-100:]).decode()
|
||||
else:
|
||||
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
|
||||
except: pass
|
||||
return data_hex, data_ascii
|
||||
|
||||
@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:
|
||||
@ -321,6 +336,7 @@ class BitcoinMain(ChainsBase):
|
||||
# see <https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp#L140>
|
||||
ctype = 'BTC'
|
||||
name = 'Bitcoin Mainnet'
|
||||
ccc_min_block = 892714 # Apr 16/2025
|
||||
|
||||
slip132 = {
|
||||
AF_CLASSIC: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
|
||||
@ -339,7 +355,7 @@ 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 4'
|
||||
@ -362,7 +378,7 @@ class BitcoinTestnet(BitcoinMain):
|
||||
b44_cointype = 1
|
||||
|
||||
|
||||
class BitcoinRegtest(BitcoinMain):
|
||||
class BitcoinRegtest(ChainsBase):
|
||||
ctype = 'XRT'
|
||||
name = 'Bitcoin Regtest'
|
||||
|
||||
@ -450,15 +466,37 @@ STD_DERIVATIONS = {
|
||||
"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_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()
|
||||
|
||||
@ -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:
|
||||
@ -136,6 +136,13 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
|
||||
what = "smsg"
|
||||
|
||||
return what, (got,)
|
||||
|
||||
elif ty in 'RSE':
|
||||
# key-teleport related
|
||||
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)
|
||||
|
||||
@ -519,12 +519,9 @@ def taproot_tree_helper(scripts):
|
||||
assert isinstance(script, bytes)
|
||||
h = ngu.secp256k1.tagged_sha256(b"TapLeaf", chains.tapscript_serialize(script))
|
||||
return [(chains.TAPROOT_LEAF_TAPSCRIPT, script, bytes())], h
|
||||
if len(scripts) == 1:
|
||||
return taproot_tree_helper(scripts[0])
|
||||
|
||||
split_pos = len(scripts) // 2
|
||||
left, left_h = taproot_tree_helper(scripts[0:split_pos])
|
||||
right, right_h = taproot_tree_helper(scripts[split_pos:])
|
||||
left, left_h = taproot_tree_helper(scripts[0].tree)
|
||||
right, right_h = taproot_tree_helper(scripts[1].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:
|
||||
|
||||
@ -10,7 +10,7 @@ from utils import cleanup_deriv_path, check_xpub, xfp2str, swab32
|
||||
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_SIGNERS, MAX_TR_SIGNERS
|
||||
from desc_utils import parse_desc_str, append_checksum, descriptor_checksum, Key
|
||||
from desc_utils import taproot_tree_helper, fill_policy, Unspend
|
||||
from desc_utils import taproot_tree_helper, fill_policy
|
||||
from miniscript import Miniscript
|
||||
|
||||
|
||||
@ -24,19 +24,17 @@ class WrongCheckSumError(Exception):
|
||||
|
||||
class Tapscript:
|
||||
def __init__(self, tree=None, keys=None, policy=None):
|
||||
self.tree = tree
|
||||
self.tree = tree # miniscript or (tapscript, tapscript)
|
||||
self.keys = keys
|
||||
self.policy = policy
|
||||
self._merkle_root = None
|
||||
|
||||
@staticmethod
|
||||
def iter_leaves(tree):
|
||||
if isinstance(tree, Miniscript):
|
||||
yield tree
|
||||
def iter_leaves(self):
|
||||
if isinstance(self.tree, Miniscript):
|
||||
yield self.tree
|
||||
else:
|
||||
assert isinstance(tree, list)
|
||||
for lv in tree:
|
||||
yield from Tapscript.iter_leaves(lv)
|
||||
for ts in self.tree:
|
||||
yield from ts.iter_leaves()
|
||||
|
||||
@property
|
||||
def merkle_root(self):
|
||||
@ -44,23 +42,24 @@ class Tapscript:
|
||||
self.process_tree()
|
||||
return self._merkle_root
|
||||
|
||||
@staticmethod
|
||||
def _derive(tree, idx, key_map, change=False):
|
||||
if isinstance(tree, Miniscript):
|
||||
return tree.derive(idx, key_map, change=change)
|
||||
def _derive(self, idx, key_map, change=False):
|
||||
if isinstance(self.tree, Miniscript):
|
||||
tree = self.tree.derive(idx, key_map, change=change)
|
||||
else:
|
||||
if len(tree) == 1 and isinstance(tree[0], Miniscript):
|
||||
return tree[0].derive(idx, key_map, change=change)
|
||||
l, r = tree
|
||||
return [Tapscript._derive(l, idx, key_map, change=change),
|
||||
Tapscript._derive(r, idx, key_map, change=change)]
|
||||
l, r = self.tree
|
||||
tree = (l._derive(idx, key_map, change=change),
|
||||
r._derive(idx, key_map, change=change))
|
||||
|
||||
return type(self)(tree)
|
||||
|
||||
def derive(self, idx=None, change=False):
|
||||
derived_keys = OrderedDict()
|
||||
for k in self.keys:
|
||||
derived_keys[k] = k.derive(idx, change=change)
|
||||
tree = Tapscript._derive(self.tree, idx, derived_keys, change=change)
|
||||
return type(self)(tree, policy=self.policy, keys=list(derived_keys.values()))
|
||||
ts = self._derive(idx, derived_keys, change=change)
|
||||
ts.policy = self.policy
|
||||
ts.keys = list(derived_keys.values())
|
||||
return ts
|
||||
|
||||
def process_tree(self):
|
||||
info, mr = taproot_tree_helper(self.tree)
|
||||
@ -69,76 +68,31 @@ class Tapscript:
|
||||
|
||||
@classmethod
|
||||
def read_from(cls, s):
|
||||
num_leafs = 0
|
||||
depth = 0
|
||||
tapscript = []
|
||||
p0 = s.read(1)
|
||||
if p0 != b"{":
|
||||
# depth zero
|
||||
s.seek(-1, 1)
|
||||
alone = Miniscript.read_from(s, taproot=True)
|
||||
alone.is_sane(taproot=True)
|
||||
alone.verify()
|
||||
tapscript.append(alone)
|
||||
num_leafs += 1
|
||||
else:
|
||||
assert p0 == b"{"
|
||||
depth += 1
|
||||
itmp = None
|
||||
itmp_p = None
|
||||
while True:
|
||||
p1 = s.read(1)
|
||||
if p1 == b'':
|
||||
break
|
||||
elif p1 == b")":
|
||||
s.seek(-1, 1)
|
||||
break
|
||||
elif p1 == b",":
|
||||
continue
|
||||
elif p1 == b"{":
|
||||
if itmp is None:
|
||||
itmp = []
|
||||
else:
|
||||
if itmp_p:
|
||||
itmp[itmp_p].append([])
|
||||
else:
|
||||
itmp.append(([]))
|
||||
itmp_p = -1
|
||||
c = s.read(1)
|
||||
if len(c) == 0:
|
||||
return cls()
|
||||
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 ','")
|
||||
|
||||
depth += 1
|
||||
continue
|
||||
elif p1 == b"}":
|
||||
depth -= 1
|
||||
if depth == 1:
|
||||
tapscript.append(itmp)
|
||||
itmp = None
|
||||
right = cls.read_from(s)
|
||||
if s.read(1) != b"}":
|
||||
raise ValueError("Invalid tapscript: expected '}'")
|
||||
|
||||
if depth <= 2:
|
||||
itmp_p = None
|
||||
continue
|
||||
return cls((left, right))
|
||||
|
||||
s.seek(-1, 1)
|
||||
item = Miniscript.read_from(s, taproot=True)
|
||||
item.is_sane(taproot=True)
|
||||
item.verify()
|
||||
num_leafs += 1
|
||||
if itmp is None:
|
||||
tapscript.append(item)
|
||||
else:
|
||||
if itmp_p and depth == 4:
|
||||
itmp[itmp_p][itmp_p].append(item)
|
||||
elif itmp_p:
|
||||
itmp[itmp_p].append(item)
|
||||
else:
|
||||
itmp.append(item)
|
||||
|
||||
assert num_leafs <= 8, "num_leafs > 8"
|
||||
ts = cls(tapscript)
|
||||
ts.parse_policy()
|
||||
return ts
|
||||
s.seek(-1, 1)
|
||||
ms = Miniscript.read_from(s, taproot=True)
|
||||
ms.is_sane(taproot=True)
|
||||
ms.verify()
|
||||
return cls(ms)
|
||||
|
||||
def parse_policy(self):
|
||||
self.policy, self.keys = self._parse_policy(self.tree, [])
|
||||
self.policy, self.keys = self._parse_policy([])
|
||||
orig_keys = OrderedDict()
|
||||
for k in self.keys:
|
||||
if k.origin not in orig_keys:
|
||||
@ -148,43 +102,26 @@ class Tapscript:
|
||||
# always keep subderivation in policy string
|
||||
self.policy = self.policy.replace(k_lst[0].to_string(subderiv=False), chr(64) + str(i))
|
||||
|
||||
@staticmethod
|
||||
def _parse_policy(tree, all_keys):
|
||||
if isinstance(tree, Miniscript):
|
||||
keys, leaf_str = tree.keys, tree.to_string()
|
||||
def _parse_policy(self, all_keys):
|
||||
if isinstance(self.tree, Miniscript):
|
||||
keys, leaf_str = self.tree.keys, self.tree.to_string()
|
||||
for k in keys:
|
||||
if k not in all_keys:
|
||||
all_keys.append(k)
|
||||
|
||||
return leaf_str, all_keys
|
||||
else:
|
||||
assert isinstance(tree, list)
|
||||
if len(tree) == 1 and isinstance(tree[0], Miniscript):
|
||||
keys, leaf_str = tree[0].keys, tree[0].to_string()
|
||||
for k in keys:
|
||||
if k not in all_keys:
|
||||
all_keys.append(k)
|
||||
l, r = self.tree
|
||||
ll, all_keys = l._parse_policy(all_keys)
|
||||
rr, all_keys = r._parse_policy(all_keys)
|
||||
return "{" + ll + "," + rr + "}", all_keys
|
||||
|
||||
return leaf_str, all_keys
|
||||
else:
|
||||
l, r = tree
|
||||
ll, all_keys = Tapscript._parse_policy(l, all_keys)
|
||||
rr, all_keys = Tapscript._parse_policy(r, all_keys)
|
||||
return "{" + ll + "," + rr + "}", all_keys
|
||||
|
||||
@staticmethod
|
||||
def script_tree(tree):
|
||||
if isinstance(tree, Miniscript):
|
||||
return b2a_hex(chains.tapscript_serialize(tree.compile())).decode()
|
||||
def script_tree(self):
|
||||
if isinstance(self.tree, Miniscript):
|
||||
return b2a_hex(chains.tapscript_serialize(self.tree.compile())).decode()
|
||||
else:
|
||||
assert isinstance(tree, list)
|
||||
if len(tree) == 1 and isinstance(tree[0], Miniscript):
|
||||
return b2a_hex(chains.tapscript_serialize(tree[0].compile())).decode()
|
||||
else:
|
||||
l, r = tree
|
||||
ll = Tapscript.script_tree(l)
|
||||
rr = Tapscript.script_tree(r)
|
||||
return "{" + ll + "," + rr + "}"
|
||||
l, r = self.tree
|
||||
return "{" + l.script_tree() + "," +r.script_tree() + "}"
|
||||
|
||||
def to_string(self, external=True, internal=True):
|
||||
return fill_policy(self.policy, self.keys, external, internal)
|
||||
@ -520,6 +457,7 @@ class Descriptor:
|
||||
else:
|
||||
assert sep == b","
|
||||
tapscript = Tapscript.read_from(s)
|
||||
tapscript.parse_policy()
|
||||
elif start.startswith(b"sh(wsh("):
|
||||
sh = True
|
||||
wsh = True
|
||||
|
||||
@ -334,7 +334,8 @@ class Display:
|
||||
# no status bar on Mk4
|
||||
return
|
||||
|
||||
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, is_addr=False):
|
||||
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert,
|
||||
is_addr=False, force_msg=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
|
||||
# - ignores "is_addr" because exactly zero space to do anything special
|
||||
|
||||
@ -11,8 +11,8 @@ from ux import ux_show_story, ux_enter_bip32_index, the_ux, ux_confirm, ux_drama
|
||||
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
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -51,4 +51,8 @@ class QRDecodeExplained(ValueError):
|
||||
class UnknownAddressExplained(ValueError):
|
||||
pass
|
||||
|
||||
# We're not going to co-sign using CCC feature
|
||||
class CCCPolicyViolationError(RuntimeError):
|
||||
pass
|
||||
|
||||
# EOF
|
||||
|
||||
199
shared/export.py
199
shared/export.py
@ -5,10 +5,10 @@
|
||||
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 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
|
||||
@ -18,9 +18,7 @@ async def export_by_qr(body, label, type_code, force_bbqr=False):
|
||||
from ux import show_qr_code
|
||||
|
||||
try:
|
||||
# ignore label/title - provides no useful info
|
||||
# makes qr smaller and harder to read
|
||||
if force_bbqr:
|
||||
if force_bbqr or len(body) > 2000:
|
||||
raise ValueError
|
||||
|
||||
await show_qr_code(body)
|
||||
@ -34,6 +32,75 @@ async def export_by_qr(body, label, type_code, force_bbqr=False):
|
||||
|
||||
return
|
||||
|
||||
|
||||
async def export_contents(title, contents, fname_pattern, derive=None, addr_fmt=None,
|
||||
is_json=False, force_bbqr=False, force_prompt=False):
|
||||
# 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
|
||||
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)
|
||||
|
||||
while True:
|
||||
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)
|
||||
continue
|
||||
elif ch == KEY_NFC:
|
||||
if is_json:
|
||||
await NFC.share_json(contents)
|
||||
else:
|
||||
await NFC.share_text(contents)
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
def generate_public_contents():
|
||||
# Generate public details about wallet.
|
||||
#
|
||||
@ -130,50 +197,6 @@ be needed for different systems.
|
||||
yield fp.getvalue()
|
||||
del fp
|
||||
|
||||
async def write_text_file(fname_pattern, body, title, derive, addr_fmt,
|
||||
force_prompt=False):
|
||||
# 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,
|
||||
force_prompt=force_prompt) # QR offered also on Mk4
|
||||
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)
|
||||
|
||||
sig_nice = None
|
||||
if addr_fmt != AF_P2TR:
|
||||
h = ngu.hash.sha256s(body.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' % (title, nice)
|
||||
if sig_nice:
|
||||
msg += '\n\n%s signature file written:\n\n%s' % (title, sig_nice)
|
||||
await ux_show_story(msg)
|
||||
|
||||
async def make_summary_file(fname_pattern='public.txt'):
|
||||
from glob import dis
|
||||
@ -184,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)
|
||||
|
||||
@ -246,7 +269,7 @@ importmulti '{imp_multi}'
|
||||
|
||||
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
|
||||
@ -347,20 +370,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)
|
||||
|
||||
@ -369,9 +388,7 @@ 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)
|
||||
@ -475,60 +492,6 @@ 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"):
|
||||
@ -571,7 +534,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
|
||||
)
|
||||
|
||||
dis.progress_bar_show(1)
|
||||
await write_text_file(fname_pattern, body, "Descriptor", derive + "/0/0",
|
||||
await export_contents("Descriptor", body, fname_pattern, derive + "/0/0",
|
||||
addr_type, force_prompt=True)
|
||||
|
||||
# EOF
|
||||
|
||||
@ -264,7 +264,7 @@ class CardSlot:
|
||||
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
|
||||
|
||||
@ -20,9 +20,11 @@ from countdowns import countdown_chooser
|
||||
from paper import make_paper_wallet
|
||||
from trick_pins import TrickPinMenu
|
||||
from tapsigner import import_tapsigner_backup_file
|
||||
from ccc import toggle_ccc_feature
|
||||
|
||||
# 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
|
||||
@ -39,12 +41,14 @@ if version.has_battery:
|
||||
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"
|
||||
@ -70,6 +74,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)
|
||||
@ -140,7 +146,7 @@ SettingsMenu = [
|
||||
NonDefaultMenuItem('Multisig Wallets', 'multisig',
|
||||
menu=make_multisig_menu, predicate=has_secrets),
|
||||
NonDefaultMenuItem('Miniscript', 'miniscript',
|
||||
menu=make_miniscript_menu, predicate=has_secrets),
|
||||
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),
|
||||
@ -195,6 +201,7 @@ WalletExportMenu = [
|
||||
arg=(True, [AF_P2WPKH, AF_P2WPKH_P2SH], "Zeus Wallet", "zeus-export.txt")),
|
||||
MenuItem("Electrum Wallet", f=electrum_skeleton),
|
||||
MenuItem("Theya", f=named_generic_skeleton, arg="Theya"),
|
||||
MenuItem("Bitcoin Safe", f=named_generic_skeleton, arg="Bitcoin Safe"),
|
||||
MenuItem("Wasabi Wallet", f=wasabi_skeleton),
|
||||
MenuItem("Unchained", f=unchained_capital_export),
|
||||
MenuItem("Lily Wallet", f=named_generic_skeleton, arg="Lily"),
|
||||
@ -215,6 +222,7 @@ FileMgmtMenu = [
|
||||
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 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),
|
||||
@ -236,8 +244,9 @@ DevelopersMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Serial REPL", f=dev_enable_repl),
|
||||
MenuItem('Warm Reset', f=reset_self),
|
||||
MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
|
||||
MenuItem("BKPW Override", menu=bkpw_override),
|
||||
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)
|
||||
@ -254,6 +263,7 @@ AdvancedPinnedVirginMenu = [ # Has PIN but no secrets yet
|
||||
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),
|
||||
@ -329,7 +339,6 @@ correctly- crafted transactions signed on Testnet could be broadcast on Mainnet.
|
||||
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
|
||||
]
|
||||
|
||||
@ -337,7 +346,7 @@ BackupStuffMenu = [
|
||||
# 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),
|
||||
]
|
||||
|
||||
@ -364,11 +373,13 @@ AdvancedNormalMenu = [
|
||||
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('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),
|
||||
NonDefaultMenuItem('Coldcard Co-Signing', 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
|
||||
MenuItem('User Management', menu=make_users_menu,
|
||||
predicate=hsm_available),
|
||||
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
|
||||
@ -379,7 +390,7 @@ AdvancedNormalMenu = [
|
||||
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),
|
||||
]
|
||||
@ -390,7 +401,7 @@ ImportWallet = [
|
||||
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),
|
||||
@ -415,8 +426,9 @@ EmptyWallet = [
|
||||
MenuItem('Import Existing', menu=ImportWallet),
|
||||
MenuItem("Migrate Coldcard", menu=clone_start),
|
||||
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.
|
||||
@ -443,8 +455,8 @@ NormalSystem = [
|
||||
|
||||
# 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'),
|
||||
|
||||
@ -18,7 +18,7 @@ from glob import settings
|
||||
# - 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 @@ class OutptValueCache:
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
#
|
||||
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
|
||||
@ -69,9 +70,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):
|
||||
@ -148,22 +149,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
|
||||
@ -215,7 +200,7 @@ class ApprovalRule:
|
||||
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')
|
||||
@ -960,7 +945,7 @@ class HSMPolicy:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ Press %s to save policy and enable HSM mode.''' % (self.policy.hash(), confirm_c
|
||||
|
||||
except BaseException as exc:
|
||||
self.failed = "Exception"
|
||||
sys.print_exception(exc)
|
||||
# sys.print_exception(exc)
|
||||
self.refused = True
|
||||
|
||||
self.ux_done = True
|
||||
@ -354,7 +354,7 @@ class hsmUxInteraction:
|
||||
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
|
||||
|
||||
@ -58,7 +58,7 @@ class ImportantTask:
|
||||
else:
|
||||
# uncaught exception in an unnamed (and unimportant) task
|
||||
print("UNNAMED: " + context["message"])
|
||||
sys.print_exception(context["exception"])
|
||||
# sys.print_exception(context["exception"])
|
||||
print("... future: %r" % context.get("future", '?'))
|
||||
|
||||
def start_task(self, name, awaitable):
|
||||
|
||||
@ -622,7 +622,8 @@ class Display:
|
||||
# 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:
|
||||
@ -641,10 +642,10 @@ class Display:
|
||||
|
||||
def _draw_addr(self, y, addr, prev_x=None):
|
||||
# Draw a single-line of an address
|
||||
# - use prev_x=0 to start centered
|
||||
# - use prev_x=0 to start centered
|
||||
if prev_x is None:
|
||||
# left justify (for stories)
|
||||
prev_x = x = 1
|
||||
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)
|
||||
@ -655,7 +656,8 @@ class Display:
|
||||
|
||||
return prev_x
|
||||
|
||||
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None, is_addr=False):
|
||||
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,
|
||||
is_addr=False, force_msg=False):
|
||||
# Show a QR code on screen w/ some text under it
|
||||
# - invert not supported on Q1
|
||||
# - sidebar not supported here (see users.py)
|
||||
@ -705,25 +707,21 @@ class Display:
|
||||
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
|
||||
@ -809,8 +807,12 @@ class Display:
|
||||
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)
|
||||
|
||||
@ -270,7 +270,7 @@ suffix break point is correct.\n\n'''
|
||||
return await self.interact()
|
||||
|
||||
|
||||
async def get_new_pin(self, title, story=None, allow_clear=False):
|
||||
async def get_new_pin(self, title, story=None):
|
||||
# Do UX flow to get new (or change) PIN. Always does the double-entry thing
|
||||
self.is_setting = True
|
||||
|
||||
@ -283,10 +283,6 @@ suffix break point is correct.\n\n'''
|
||||
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:
|
||||
|
||||
@ -61,9 +61,7 @@ try:
|
||||
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:
|
||||
|
||||
@ -5,6 +5,7 @@ freeze_as_mpy('', [
|
||||
'actions.py',
|
||||
'address_explorer.py',
|
||||
'auth.py',
|
||||
'msgsign.py',
|
||||
'backups.py',
|
||||
'bsms.py',
|
||||
'callgate.py',
|
||||
@ -54,6 +55,8 @@ freeze_as_mpy('', [
|
||||
'tapsigner.py',
|
||||
'wallet.py',
|
||||
'ownership.py',
|
||||
'ccc.py',
|
||||
'web2fa.py',
|
||||
], opt=0)
|
||||
|
||||
# Optimize data-like files, since no need to debug them.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# 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',
|
||||
@ -18,6 +18,7 @@ freeze_as_mpy('', [
|
||||
'battery.py',
|
||||
'notes.py',
|
||||
'calc.py',
|
||||
'teleport.py',
|
||||
], opt=0)
|
||||
|
||||
# Optimize data-like files, since no need to debug them.
|
||||
|
||||
@ -382,7 +382,7 @@ class MenuSystem:
|
||||
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)
|
||||
@ -393,7 +393,7 @@ class MenuSystem:
|
||||
#
|
||||
if picked is None:
|
||||
# "go back" or cancel or something
|
||||
self.on_cancel()
|
||||
await self.on_cancel()
|
||||
else:
|
||||
await picked.activate(self, self.cursor)
|
||||
|
||||
@ -406,7 +406,7 @@ class MenuSystem:
|
||||
gc.collect()
|
||||
if self.multi_selected is not None:
|
||||
# multichoice
|
||||
self.on_cancel()
|
||||
await self.on_cancel()
|
||||
return ch
|
||||
|
||||
await self.activate(ch)
|
||||
|
||||
@ -13,7 +13,7 @@ from wallet import BaseStorageWallet
|
||||
from menu import MenuSystem, MenuItem
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_single_address
|
||||
from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_single_address, keypath_to_str
|
||||
from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER
|
||||
|
||||
|
||||
@ -106,6 +106,7 @@ class MiniScriptWallet(BaseStorageWallet):
|
||||
if self._taproot and self._policy:
|
||||
# tapscript
|
||||
ts = Tapscript.read_from(uio.BytesIO(filled_policy))
|
||||
ts.parse_policy()
|
||||
elif self._policy:
|
||||
# miniscript
|
||||
ms = Miniscript.read_from(uio.BytesIO(filled_policy))
|
||||
@ -213,6 +214,13 @@ class MiniScriptWallet(BaseStorageWallet):
|
||||
|
||||
return branch, idx
|
||||
|
||||
def get_my_deriv(self, my_xfp):
|
||||
# TODO we can have more our keys in descriptor
|
||||
# maybe lowest account/change index should be chosen
|
||||
for e in self.xfp_paths():
|
||||
if e[0] == my_xfp:
|
||||
return keypath_to_str(e)
|
||||
|
||||
def derive_desc(self, xfp_paths):
|
||||
branch, idx = self.subderivation_indexes(xfp_paths)
|
||||
derived_desc = self.desc.derive(branch).derive(idx)
|
||||
@ -272,24 +280,23 @@ class MiniScriptWallet(BaseStorageWallet):
|
||||
return True
|
||||
|
||||
def taproot_internal_key_detail(self, short=False):
|
||||
if self.taproot:
|
||||
key = Key.from_string(self.key)
|
||||
s = "Taproot internal key:\n\n"
|
||||
if key.is_provably_unspendable:
|
||||
note = "provably unspendable"
|
||||
if short:
|
||||
s += note
|
||||
else:
|
||||
s += self.key
|
||||
if type(key) is Key:
|
||||
# it is unspendable, BUT not unspend(
|
||||
s += "\n (%s)" % note
|
||||
s += "\n\n"
|
||||
key = Key.from_string(self.key)
|
||||
s = "Taproot internal key:\n\n"
|
||||
if key.is_provably_unspendable:
|
||||
note = "provably unspendable"
|
||||
if short:
|
||||
s += note
|
||||
else:
|
||||
xfp, deriv, xpub = key.to_cc_data()
|
||||
s += '%s:\n %s\n\n%s/%s\n\n' % (xfp2str(xfp), deriv, xpub,
|
||||
key.derivation.to_string())
|
||||
return s
|
||||
s += self.key
|
||||
if type(key) is Key:
|
||||
# it is unspendable, BUT not unspend(
|
||||
s += "\n (%s)" % note
|
||||
s += "\n\n"
|
||||
else:
|
||||
xfp, deriv, xpub = key.to_cc_data()
|
||||
s += '%s:\n %s\n\n%s/%s\n\n' % (xfp2str(xfp), deriv, xpub,
|
||||
key.derivation.to_string())
|
||||
return s
|
||||
|
||||
async def show_keys(self):
|
||||
msg = ""
|
||||
@ -323,7 +330,7 @@ class MiniScriptWallet(BaseStorageWallet):
|
||||
else:
|
||||
name = to_ascii_printable(name)
|
||||
desc_obj = Descriptor.from_string(config.strip())
|
||||
assert not desc_obj.is_basic_multisig, "Use Settings -> Multisig Wallets"
|
||||
|
||||
wal = cls(desc_obj, name=name, chain_type=desc_obj.keys[0].chain_type)
|
||||
return wal
|
||||
|
||||
@ -376,7 +383,7 @@ class MiniScriptWallet(BaseStorageWallet):
|
||||
script = ""
|
||||
if scripts:
|
||||
if d.tapscript:
|
||||
script = d.tapscript.script_tree(d.tapscript.tree)
|
||||
script = d.tapscript.script_tree()
|
||||
else:
|
||||
script = b2a_hex(ser_string(d.miniscript.compile())).decode()
|
||||
|
||||
@ -630,7 +637,6 @@ async def miniscript_wallet_detail(menu, label, item):
|
||||
async def import_miniscript(*a):
|
||||
# pick text file from SD card, import as multisig setup file
|
||||
from actions import file_picker
|
||||
from glob import dis
|
||||
from ux import import_export_prompt
|
||||
|
||||
ch = await import_export_prompt("miniscript wallet file", is_import=True)
|
||||
@ -665,14 +671,14 @@ async def import_miniscript(*a):
|
||||
possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None
|
||||
maybe_enroll_xpub(config=data, name=possible_name, miniscript=True)
|
||||
except BaseException as e:
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(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(title="ERROR", msg="Failed to import miniscript. %s" % str(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
|
||||
@ -681,7 +687,6 @@ async def import_miniscript_qr(*a):
|
||||
if not data:
|
||||
# press pressed CANCEL
|
||||
return
|
||||
|
||||
try:
|
||||
maybe_enroll_xpub(config=data, miniscript=True)
|
||||
except Exception as e:
|
||||
@ -868,15 +873,12 @@ class Miniscript:
|
||||
|
||||
def is_sane(self, taproot=False):
|
||||
err = "multi mixin"
|
||||
# cannot have same keys in single miniscript
|
||||
forbiden = (Sortedmulti_a, Multi_a)
|
||||
keys = self.keys
|
||||
# cannot have same keys in single miniscript
|
||||
# provably unspendable taproot internal key is not covered here
|
||||
# all other keys (miniscript,tapscript) require key origin info
|
||||
assert len(keys) == len(set(keys)), "Insane"
|
||||
if taproot:
|
||||
forbiden = (Sortedmulti, Multi)
|
||||
|
||||
forbiden = (Sortedmulti, Multi) if taproot else (Sortedmulti_a, Multi_a)
|
||||
assert type(self) not in forbiden, err
|
||||
|
||||
for arg in self.args:
|
||||
|
||||
@ -57,8 +57,7 @@ def init0():
|
||||
|
||||
try:
|
||||
make_psram_fs()
|
||||
except BaseException as exc:
|
||||
sys.print_exception(exc)
|
||||
except: pass
|
||||
|
||||
if version.is_devmode:
|
||||
try:
|
||||
@ -70,10 +69,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)
|
||||
@ -82,15 +84,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
|
||||
|
||||
512
shared/msgsign.py
Normal file
512
shared/msgsign.py
Normal file
@ -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 <https://www.ietf.org/rfc/rfc2440.txt> 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
|
||||
@ -4,20 +4,20 @@
|
||||
#
|
||||
import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable
|
||||
from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable, extract_cosigner
|
||||
from utils import str_to_keypath, problem_file_line, check_xpub, get_filesize, show_single_address
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys
|
||||
from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code, ux_enter_number, OK, X
|
||||
from ux import ux_enter_bip32_index, ux_enter_number, OK, X
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
from descriptor import Descriptor
|
||||
from miniscript import Key, Sortedmulti, Number, Multi
|
||||
from desc_utils import multisig_descriptor_template
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR
|
||||
from menu import MenuSystem, MenuItem, NonDefaultMenuItem, start_chooser, ToggleMenuItem
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR, AF_CLASSIC
|
||||
from menu import MenuSystem, MenuItem, NonDefaultMenuItem, start_chooser
|
||||
from opcodes import OP_CHECKMULTISIG
|
||||
from exceptions import FatalPSBTIssue
|
||||
from glob import settings
|
||||
from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
|
||||
from charcodes import KEY_NFC, KEY_QR
|
||||
from serializations import disassemble
|
||||
from wallet import BaseStorageWallet, MAX_BIP32_IDX
|
||||
|
||||
@ -26,6 +26,8 @@ TRUST_VERIFY = const(0)
|
||||
TRUST_OFFER = const(1)
|
||||
TRUST_PSBT = const(2)
|
||||
|
||||
# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport
|
||||
KT_RXPUBKEY_DERIV = const(20250317)
|
||||
|
||||
def disassemble_multisig_mn(redeem_script):
|
||||
# pull out just M and N from script. Simple, faster, no memory.
|
||||
@ -163,6 +165,11 @@ class MultisigWallet(BaseStorageWallet):
|
||||
deriv = derivs[0]
|
||||
return deriv + '/%d/%d' % (change_idx, idx)
|
||||
|
||||
def get_my_deriv(self, my_xfp):
|
||||
for tup in self.xpubs:
|
||||
if tup[0] == my_xfp:
|
||||
return tup[1]
|
||||
|
||||
@classmethod
|
||||
def get_trust_policy(cls):
|
||||
|
||||
@ -238,13 +245,14 @@ class MultisigWallet(BaseStorageWallet):
|
||||
def is_correct_chain(cls, o, curr_chain):
|
||||
# for newer versions, last element can be bip67 marker
|
||||
d = o[-1] if isinstance(o[-1], dict) else o[-2]
|
||||
|
||||
if "ch" not in d:
|
||||
# mainnet
|
||||
ch = "BTC"
|
||||
else:
|
||||
ch = d["ch"]
|
||||
|
||||
if ch == "XRT":
|
||||
ch = "XTN"
|
||||
if ch == curr_chain.ctype:
|
||||
return True
|
||||
return False
|
||||
@ -255,7 +263,6 @@ class MultisigWallet(BaseStorageWallet):
|
||||
# - this is only place we should be searching this list, please!!
|
||||
lst = settings.get(cls.key_name, [])
|
||||
c = chains.current_key_chain()
|
||||
|
||||
for idx, rec in enumerate(lst):
|
||||
if not cls.is_correct_chain(rec, c):
|
||||
continue
|
||||
@ -429,6 +436,10 @@ class MultisigWallet(BaseStorageWallet):
|
||||
return set(xp_idx for xp_idx, (wxfp, _, _) in enumerate(self.xpubs)
|
||||
if wxfp == xfp)
|
||||
|
||||
def xpubs_from_xfp(self, xfp):
|
||||
# return list of XPUB's which match xfp; typically one.
|
||||
return [xpub for (wxfp, _, xpub) in 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.
|
||||
@ -444,7 +455,7 @@ class MultisigWallet(BaseStorageWallet):
|
||||
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)
|
||||
path = "[%s%s/%d/{idx}]" % (xfp2str(xfp), deriv.replace("m", ""), change_idx)
|
||||
nodes.append(node)
|
||||
paths.append(path)
|
||||
|
||||
@ -682,7 +693,7 @@ class MultisigWallet(BaseStorageWallet):
|
||||
|
||||
@classmethod
|
||||
def from_descriptor(cls, descriptor: str):
|
||||
# excpect descriptor here if only one line, normal multisig file requires more lines
|
||||
# expect descriptor here if only one line, normal multisig file requires more lines
|
||||
has_mine = 0
|
||||
my_xfp = settings.get('xfp')
|
||||
xpubs = []
|
||||
@ -741,9 +752,6 @@ class MultisigWallet(BaseStorageWallet):
|
||||
# assume descriptor, classic config should not contain sertedmulti( and check for checksum separator
|
||||
# ignore name
|
||||
_, 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
|
||||
@ -788,7 +796,7 @@ class MultisigWallet(BaseStorageWallet):
|
||||
|
||||
async def export_electrum(self):
|
||||
# Generate and save an Electrum JSON file.
|
||||
from export import make_json_wallet
|
||||
from export import export_contents
|
||||
|
||||
def doit():
|
||||
rv = dict(seed_version=17, use_encryption=False,
|
||||
@ -814,80 +822,47 @@ class MultisigWallet(BaseStorageWallet):
|
||||
derivation=deriv, xpub=xp)
|
||||
|
||||
# sign export with first p2pkh key
|
||||
return ujson.dumps(rv), False, False
|
||||
return ujson.dumps(rv), self.get_my_deriv(settings.get('xfp'))+"/0/0", AF_CLASSIC
|
||||
|
||||
await make_json_wallet('Electrum multisig wallet', doit,
|
||||
fname_pattern=self.make_fname('el', 'json'))
|
||||
await export_contents('Electrum multisig wallet', doit,
|
||||
self.make_fname('el', 'json'), is_json=True)
|
||||
|
||||
async def export_wallet_file(self, mode="exported from", extra_msg=None, descriptor=False,
|
||||
async def export_wallet_file(self, mode="exported from", 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'))
|
||||
my_xfp = settings.get('xfp')
|
||||
# both core and CC export contains newlines, not supported with simple QR
|
||||
force_bbqr = True
|
||||
if core:
|
||||
name = "Bitcoin Core"
|
||||
fname_pattern = self.make_fname('bitcoin-core')
|
||||
elif descriptor:
|
||||
# classic descriptor is one-liner, can be exported as simple QR if size allows
|
||||
# pretty desc has newlines - needs BBQr
|
||||
force_bbqr = desc_pretty
|
||||
name = "Descriptor"
|
||||
fname_pattern = self.make_fname('desc')
|
||||
else:
|
||||
name = "Coldcard"
|
||||
fname_pattern = self.make_fname('export')
|
||||
|
||||
hdr = '%s %s' % (mode, my_xfp)
|
||||
hdr = '%s %s' % (mode, xfp2str(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
|
||||
with uio.StringIO() as fp:
|
||||
self.render_export(fp, hdr_comment=hdr, descriptor=descriptor,
|
||||
core=core, desc_pretty=desc_pretty)
|
||||
body = fp.getvalue()
|
||||
|
||||
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
|
||||
# create airgapped, where own key is not included in the ms setup, no key to sign with
|
||||
af = None
|
||||
der = self.get_my_deriv(my_xfp)
|
||||
if der:
|
||||
der = der + "/0/0"
|
||||
af = AF_CLASSIC
|
||||
|
||||
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%s\n%s' % (e, problem_file_line(e)))
|
||||
return
|
||||
from export import export_contents
|
||||
await export_contents(label, body, fname_pattern, der, af, force_bbqr=force_bbqr)
|
||||
|
||||
def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True):
|
||||
if descriptor:
|
||||
@ -1159,6 +1134,98 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=
|
||||
|
||||
return await ux_show_story(msg, title=self.name)
|
||||
|
||||
# Key Teleport support, where a co-signers pubkeys are used for ECDH
|
||||
|
||||
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)
|
||||
try:
|
||||
xpub, = self.xpubs_from_xfp(xfp)
|
||||
except ValueError:
|
||||
raise RuntimeError("dup or missing xfp")
|
||||
|
||||
node = self.chain.deserialize_node(xpub, AF_P2SH)
|
||||
node.derive(KT_RXPUBKEY_DERIV, False)
|
||||
node.derive(ri, False)
|
||||
pubkey = node.pubkey()
|
||||
|
||||
kp = self.kt_my_keypair(ri)
|
||||
|
||||
#print("psbt sender: ri=%d toward xfp: %s ... %s" % (ri, xfp2str(xfp), B2A(pubkey)))
|
||||
|
||||
return ri.to_bytes(4, 'big'), pubkey, kp
|
||||
|
||||
def kt_my_keypair(self, ri):
|
||||
# Calc my keypair for sending PSBT files.
|
||||
#
|
||||
|
||||
my_xfp = settings.get('xfp')
|
||||
|
||||
# Find the derivation path used by my leg of this multisig
|
||||
deriv = list(self.xfp_paths[my_xfp])
|
||||
deriv.append(KT_RXPUBKEY_DERIV)
|
||||
deriv.append(ri)
|
||||
|
||||
path = keypath_to_str(deriv)
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
node = sv.derive_path(path)
|
||||
|
||||
kp = ngu.secp256k1.keypair(node.privkey())
|
||||
|
||||
#print("my keypair: ri=%d my_xfp=%s ... %s" % (
|
||||
# ri, xfp2str(my_xfp), B2A(kp.pubkey().to_bytes())))
|
||||
|
||||
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')
|
||||
|
||||
kp = None
|
||||
for ms in cls.iter_wallets():
|
||||
if my_xfp not in ms.xfp_paths:
|
||||
# we aren't a party to this MS wallet? not supposed to happen, but
|
||||
# easy to handle
|
||||
continue
|
||||
|
||||
if (not kp) or (kp_deriv != ms.xfp_paths[my_xfp]):
|
||||
# my keypair is cachable if my derivation path is the
|
||||
# same in subsequent MS wallet
|
||||
kp = ms.kt_my_keypair(ri)
|
||||
kp_deriv = ms.xfp_paths[my_xfp]
|
||||
|
||||
for xfp, deriv, xpub in ms.xpubs:
|
||||
if xfp == my_xfp: continue
|
||||
|
||||
node = ms.chain.deserialize_node(xpub, AF_P2SH)
|
||||
node.derive(KT_RXPUBKEY_DERIV, False)
|
||||
node.derive(ri, False)
|
||||
|
||||
his_pubkey = node.pubkey()
|
||||
|
||||
#print("try decode: ri=%d toward xfp: %s ... from %s <= to %s" % (
|
||||
# ri, xfp2str(xfp), B2A(his_pubkey), B2A(kp.pubkey().to_bytes())), end=' ... ')
|
||||
|
||||
# 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, xfp
|
||||
|
||||
return None, None, None
|
||||
|
||||
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.")
|
||||
@ -1228,50 +1295,13 @@ exists, otherwise 'Verify'.''')
|
||||
if ch == 'x': return
|
||||
start_chooser(psbt_xpubs_policy_chooser)
|
||||
|
||||
def unsort_ms_chooser():
|
||||
def xset(idx, text):
|
||||
if idx:
|
||||
settings.set('unsort_ms', idx)
|
||||
else:
|
||||
settings.remove_key('unsort_ms')
|
||||
|
||||
return settings.get('unsort_ms', 0), ['Do Not Allow', 'Allow'], xset
|
||||
|
||||
async def unsorted_ms_menu(*a):
|
||||
|
||||
if not settings.get("unsort_ms", None):
|
||||
ch = await ux_show_story(
|
||||
'Enable this to allow import and operation with'
|
||||
' "multi(...)" unsorted multisig wallets that DO NOT follow BIP-67.'
|
||||
' It is of CRUCIAL importance to backup multisig descriptor for unsorted wallets'
|
||||
' in order to preserve key ordering.'
|
||||
' Many popular wallets like Sparrow and Electrum do NOT support "multi(...)".'
|
||||
'\n\nUSE AT YOUR OWN RISK. Disabling BIP-67 is discouraged!'
|
||||
'\n\nPress (4) to confirm allowing "multi(...)"', escape='4')
|
||||
|
||||
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(unsort_ms_chooser)
|
||||
|
||||
class MultisigMenu(MenuSystem):
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# Dynamic menu with user-defined names of wallets shown
|
||||
from glob import NFC
|
||||
|
||||
from bsms import make_ms_wallet_bsms_menu
|
||||
|
||||
@ -1283,7 +1313,7 @@ class MultisigMenu(MenuSystem):
|
||||
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))
|
||||
@ -1294,9 +1324,6 @@ class MultisigMenu(MenuSystem):
|
||||
rv.append(MenuItem('Create Airgapped', f=create_ms_step1))
|
||||
rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu))
|
||||
rv.append(MenuItem('Skip Checks?', f=disable_checks_menu))
|
||||
rv.append(NonDefaultMenuItem(
|
||||
'Unsorted Multisig?' if version.has_qwerty else 'Unsorted Multi?',
|
||||
'unsort_ms', f=unsorted_ms_menu))
|
||||
|
||||
return rv
|
||||
|
||||
@ -1422,24 +1449,23 @@ async def ms_wallet_detail(menu, label, item):
|
||||
return await ms.show_detail()
|
||||
|
||||
|
||||
async def export_multisig_xpubs(*a):
|
||||
async def export_multisig_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.
|
||||
# - 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
|
||||
#
|
||||
from glob import NFC, dis
|
||||
from ux import import_export_prompt
|
||||
|
||||
xfp = xfp2str(settings.get('xfp', 0))
|
||||
xfp = xfp2str(xfp or settings.get('xfp', 0))
|
||||
chain = chains.current_chain()
|
||||
|
||||
fname_pattern = 'ccxp-%s.json' % xfp
|
||||
label = "Multisig XPUB"
|
||||
|
||||
msg = '''\
|
||||
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.
|
||||
@ -1455,80 +1481,40 @@ P2TR:
|
||||
|
||||
{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
|
||||
ch = await ux_show_story(msg)
|
||||
if ch != "y":
|
||||
return
|
||||
|
||||
acct_num = await ux_enter_bip32_index('Account Number:') or 0
|
||||
acct = 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),
|
||||
("m/48h/{coin}h/{acct_num}h/3h", 'p2tr', AF_P2TR),
|
||||
]
|
||||
|
||||
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):
|
||||
def render(acct_num):
|
||||
sign_der = None
|
||||
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
|
||||
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)
|
||||
descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt)
|
||||
if descriptor_template is None:
|
||||
continue
|
||||
fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template))
|
||||
|
||||
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)])
|
||||
fp.write(' "account": "%d",\n' % acct_num)
|
||||
fp.write(' "xfp": "%s"\n}\n' % xfp)
|
||||
return fp.getvalue(), sign_der, AF_CLASSIC
|
||||
|
||||
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)
|
||||
from export import export_contents
|
||||
await export_contents(label, lambda: render(acct), fname_pattern,
|
||||
force_bbqr=True, is_json=True)
|
||||
|
||||
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
|
||||
@ -1548,7 +1534,30 @@ async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs):
|
||||
async def ms_coordinator_qr(af_str, my_xfp, chain):
|
||||
# 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:
|
||||
import json
|
||||
return json.loads(data)
|
||||
except:
|
||||
raise QRDecodeExplained('Unable to decode JSON data')
|
||||
else:
|
||||
for line in data.split("\n"):
|
||||
if len(line) > 112:
|
||||
l_data = extract_cosigner(line, af_str)
|
||||
if l_data:
|
||||
return l_data
|
||||
|
||||
num_mine = 0
|
||||
num_files = 0
|
||||
@ -1556,10 +1565,9 @@ async def ms_coordinator_qr(af_str, my_xfp, chain):
|
||||
|
||||
msg = 'Scan Exported XPUB from Coldcard'
|
||||
while True:
|
||||
vals = await QRScannerInteraction().scan_json(msg)
|
||||
vals = await QRScannerInteraction().scan_general(msg, convertor, enter_quits=True)
|
||||
if vals is None:
|
||||
break
|
||||
|
||||
try:
|
||||
is_mine = await validate_xpub_for_ms(vals, af_str, chain, my_xfp, xpubs)
|
||||
except KeyError as e:
|
||||
@ -1592,7 +1600,8 @@ 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
|
||||
|
||||
@ -1608,7 +1617,16 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
|
||||
|
||||
try:
|
||||
with open(full_fname, 'rt') as fp:
|
||||
vals = ujson.load(fp)
|
||||
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():
|
||||
vals = extract_cosigner(line, af_str)
|
||||
if vals:
|
||||
break
|
||||
|
||||
is_mine = await validate_xpub_for_ms(vals, af_str, chain,
|
||||
my_xfp, xpubs)
|
||||
@ -1622,7 +1640,7 @@ 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:
|
||||
@ -1631,7 +1649,18 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
|
||||
|
||||
return xpubs, 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 = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num,
|
||||
2 if addr_fmt == AF_P2WSH else 1)
|
||||
|
||||
with stash.SensitiveValues(secret=secret) as sv:
|
||||
node = sv.derive_path(deriv)
|
||||
the_xfp = sv.get_xfp()
|
||||
return (the_xfp, deriv, chain.serialize_public(node, AF_P2SH))
|
||||
|
||||
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
|
||||
@ -1666,19 +1695,39 @@ 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[0], c[0]]
|
||||
xpubs = [x for x in xpubs if x[0] not in got_xfps]
|
||||
|
||||
if not xpubs:
|
||||
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
|
||||
xpubs = [a, c] + xpubs
|
||||
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)))
|
||||
xpubs.append(add_own_xpub(chain, acct, addr_fmt))
|
||||
num_mine += 1
|
||||
|
||||
N = len(xpubs)
|
||||
@ -1687,18 +1736,28 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
|
||||
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)
|
||||
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)
|
||||
|
||||
ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt)
|
||||
|
||||
if num_mine:
|
||||
@ -1715,21 +1774,22 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
|
||||
await ms.export_wallet_file(descriptor=True, desc_pretty=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 multisg 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('''\
|
||||
@ -1747,7 +1807,7 @@ Default is P2WSH addresses (segwit) or press (1) for P2SH-P2WSH.''', escape='1')
|
||||
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:
|
||||
await ux_show_story('Failed to create multisig.\n\n%s\n%s' % (e, problem_file_line(e)),
|
||||
title="ERROR")
|
||||
|
||||
123
shared/nfc.py
123
shared/nfc.py
@ -224,6 +224,11 @@ class NFCHandler:
|
||||
|
||||
self.set_rf_disable(1)
|
||||
|
||||
async def share_loop(self, n, **kws):
|
||||
while 1:
|
||||
done = await self.share_start(n, **kws)
|
||||
if done: 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 +236,20 @@ class NFCHandler:
|
||||
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 +279,9 @@ class NFCHandler:
|
||||
n.add_url(url, https=is_https)
|
||||
|
||||
if line2 is None:
|
||||
line2 = "Signed TXID: %s⋯%s" % (txid[0:8], txid[-8:])
|
||||
line2 = self.txid_line2(txid)
|
||||
|
||||
while 1:
|
||||
done = await self.share_start(n, prompt="Tap to broadcast, CANCEL when done",
|
||||
line2=line2)
|
||||
|
||||
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 +351,19 @@ class NFCHandler:
|
||||
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 +371,7 @@ class NFCHandler:
|
||||
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,7 +397,8 @@ class NFCHandler:
|
||||
self.write_dyn(GPO_CTRL_Dyn, 0x01) # GPO_EN
|
||||
self.read_dyn(IT_STS_Dyn) # clear interrupt
|
||||
|
||||
async def ux_animation(self, write_mode, allow_enter=True, prompt=None, line2=None):
|
||||
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
|
||||
@ -468,7 +472,8 @@ class NFCHandler:
|
||||
|
||||
self.set_rf_disable(1)
|
||||
if not write_mode:
|
||||
await self.wipe(False)
|
||||
# function argument secret decides whether to do full wipe after writing to chip
|
||||
await self.wipe(is_secret)
|
||||
|
||||
return aborted
|
||||
|
||||
@ -514,7 +519,6 @@ class NFCHandler:
|
||||
await self.wipe(False)
|
||||
return rv
|
||||
|
||||
|
||||
async def start_psbt_rx(self):
|
||||
from auth import psbt_encoding_taster, TXN_INPUT_OFFSET
|
||||
from auth import UserAuthorizedAction, ApproveTransaction
|
||||
@ -540,10 +544,7 @@ class NFCHandler:
|
||||
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 +565,13 @@ class NFCHandler:
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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,7 +580,10 @@ class NFCHandler:
|
||||
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):
|
||||
@ -692,21 +665,11 @@ class NFCHandler:
|
||||
if winner:
|
||||
try:
|
||||
from seed import set_ephemeral_seed_words
|
||||
await set_ephemeral_seed_words(winner, meta='NFC Import')
|
||||
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 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
|
||||
|
||||
async def address_show_and_share(self):
|
||||
from auth import show_address
|
||||
|
||||
@ -752,16 +715,15 @@ class NFCHandler:
|
||||
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
|
||||
|
||||
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.')
|
||||
@ -769,8 +731,8 @@ class NFCHandler:
|
||||
if winner:
|
||||
await verify_armored_signed_msg(winner, digest_check=False)
|
||||
|
||||
async def verify_address_nfc(self):
|
||||
# Get an address or complete bip-21 url even and search it... slow.
|
||||
async def read_address(self):
|
||||
# Read an address or BIP-21 url and parse out addr (just one)
|
||||
from utils import decode_bip21_text
|
||||
|
||||
def f(m):
|
||||
@ -781,6 +743,11 @@ class NFCHandler:
|
||||
|
||||
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.
|
||||
winner = await self.read_address()
|
||||
if winner:
|
||||
from ownership import OWNERSHIP
|
||||
await OWNERSHIP.search_ux(winner)
|
||||
|
||||
@ -13,7 +13,7 @@ from files import CardMissingError, needs_microsd, CardSlot
|
||||
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)
|
||||
@ -22,11 +22,6 @@ ONE_LINE = CHARS_W-2
|
||||
|
||||
async def make_notes_menu(*a):
|
||||
|
||||
from pincodes import pa
|
||||
if pa.is_deltamode():
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
|
||||
if not settings.get('secnap', False):
|
||||
# Explain feature, and then enable if interested. Drop them into menu.
|
||||
ch = await ux_show_story('''\
|
||||
@ -122,6 +117,8 @@ class NotesMenu(MenuSystem):
|
||||
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))
|
||||
@ -165,7 +162,7 @@ class NotesMenu(MenuSystem):
|
||||
|
||||
if got.startswith('otpauth://totp/'):
|
||||
# see <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
|
||||
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 <https://github.com/qistoph/otp_export>
|
||||
tmp.title = 'Google Auth'
|
||||
@ -179,7 +176,6 @@ class NotesMenu(MenuSystem):
|
||||
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.
|
||||
@ -278,7 +274,7 @@ class NoteContentBase:
|
||||
|
||||
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
|
||||
|
||||
@ -288,6 +284,19 @@ class NoteContentBase:
|
||||
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()
|
||||
|
||||
@ -322,7 +331,7 @@ class NoteContentBase:
|
||||
await start_export([self])
|
||||
|
||||
async def sign_txt_msg(self, a, b, item):
|
||||
from auth import ux_sign_msg, msg_signing_done
|
||||
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)
|
||||
|
||||
@ -350,8 +359,8 @@ class PasswordContent(NoteContentBase):
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Change Password', f=self.change_pw),
|
||||
self.sign_misc_menu_item(),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr),
|
||||
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='password'),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label),
|
||||
ShortcutItem(KEY_NFC, f=self.share_nfc, arg=self.type_label),
|
||||
]
|
||||
|
||||
async def view(self, *a):
|
||||
@ -392,7 +401,7 @@ class PasswordContent(NoteContentBase):
|
||||
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
|
||||
@ -404,10 +413,6 @@ class PasswordContent(NoteContentBase):
|
||||
"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
|
||||
|
||||
@ -480,7 +485,7 @@ class NoteContent(NoteContentBase):
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Export', f=self.export),
|
||||
self.sign_misc_menu_item(),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
|
||||
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
|
||||
]
|
||||
|
||||
@ -488,17 +493,7 @@ class NoteContent(NoteContentBase):
|
||||
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
|
||||
@ -541,16 +536,16 @@ class NoteContent(NoteContentBase):
|
||||
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
|
||||
|
||||
@ -608,14 +603,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
|
||||
@ -624,7 +616,13 @@ async def import_from_other(menu, *a):
|
||||
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'
|
||||
|
||||
@ -634,14 +632,11 @@ async def import_from_other(menu, *a):
|
||||
|
||||
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
|
||||
|
||||
@ -64,7 +64,8 @@ from utils import call_later_ms
|
||||
# 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
|
||||
|
||||
# Stored w/ key=00 for access before login
|
||||
# _skip_pin = hard code a PIN value (dangerous, only for debug)
|
||||
@ -278,6 +279,7 @@ class SettingsObject:
|
||||
|
||||
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
|
||||
|
||||
@ -414,7 +416,7 @@ class SettingsObject:
|
||||
|
||||
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
|
||||
|
||||
@ -82,7 +82,7 @@ class AddressCacheFile:
|
||||
except OSError:
|
||||
return
|
||||
except Exception as exc:
|
||||
sys.print_exception(exc)
|
||||
# sys.print_exception(exc)
|
||||
self.count = 0
|
||||
self.hdr = None
|
||||
return
|
||||
@ -326,6 +326,8 @@ class OwnershipCache:
|
||||
sp = None
|
||||
msg = show_single_address(addr)
|
||||
msg += '\n\nFound in wallet:\n ' + wallet.name
|
||||
|
||||
|
||||
if hasattr(wallet, "render_path"):
|
||||
sp = wallet.render_path(*subpath)
|
||||
msg += '\nDerivation path:\n ' + sp
|
||||
@ -354,7 +356,7 @@ class OwnershipCache:
|
||||
msg=addr, is_addrs=True
|
||||
)
|
||||
elif not is_complex and (ch == "0"): # only singlesig
|
||||
from auth import sign_with_own_address
|
||||
from msgsign import sign_with_own_address
|
||||
await sign_with_own_address(sp, wallet.addr_fmt)
|
||||
else:
|
||||
break
|
||||
|
||||
@ -5,13 +5,12 @@
|
||||
#
|
||||
import ujson, ngu, chains
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from utils import imported
|
||||
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
|
||||
from menu import MenuSystem, MenuItem
|
||||
from stash import blank_object
|
||||
|
||||
background_msg = '''\
|
||||
Coldcard will pick a random private key (which has no relation to your seed words), \
|
||||
@ -85,6 +84,12 @@ class PaperWalletMaker:
|
||||
from glob import dis, VD
|
||||
|
||||
try:
|
||||
import ngu
|
||||
from msgsign import write_sig_file
|
||||
from chains import current_chain
|
||||
from serializations import hash160
|
||||
from stash import blank_object
|
||||
|
||||
if not have_key:
|
||||
# get some random bytes
|
||||
await ux_dramatic_pause("Picking key...", 2)
|
||||
@ -167,7 +172,6 @@ class PaperWalletMaker:
|
||||
|
||||
nice_sig = None
|
||||
if af != AF_P2TR:
|
||||
from auth import write_sig_file
|
||||
nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename,
|
||||
addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC)
|
||||
|
||||
@ -182,8 +186,7 @@ class PaperWalletMaker:
|
||||
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
|
||||
|
||||
475
shared/psbt.py
475
shared/psbt.py
@ -2,11 +2,12 @@
|
||||
#
|
||||
# psbt.py - understand PSBT file format: verify and generate them
|
||||
#
|
||||
import stash, gc, history, sys, ngu, ckcc, chains
|
||||
from ustruct import unpack_from, unpack, pack
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length
|
||||
from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, problem_file_line
|
||||
import stash, gc, history, sys, ngu, ckcc, chains
|
||||
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
|
||||
from chains import NLOCK_IS_TIME
|
||||
from uhashlib import sha256
|
||||
from uio import BytesIO
|
||||
from sffile import SizerFile
|
||||
@ -19,6 +20,7 @@ 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, SIGHASH_DEFAULT
|
||||
from opcodes import OP_CHECKMULTISIG, OP_RETURN
|
||||
from glob import settings
|
||||
|
||||
from public_constants import (
|
||||
@ -211,7 +213,7 @@ class psbtProxy:
|
||||
|
||||
key = fd.read(ks)
|
||||
vs = deser_compact_size(fd)
|
||||
assert vs != None, 'eof'
|
||||
assert vs is not None, 'eof'
|
||||
|
||||
kt = key[0]
|
||||
|
||||
@ -353,7 +355,7 @@ class psbtProxy:
|
||||
# - creates dictionary: pubkey => [xfp, *path] (self.subpaths)
|
||||
# - creates dictionary: pubkey => [leaf_hash_list, xfp, *path] (self.taproot_subpaths)
|
||||
# - will be single entry for non-p2sh ins and outs
|
||||
if self.num_our_keys != None:
|
||||
if self.num_our_keys is not None:
|
||||
# already been here once
|
||||
return self.num_our_keys
|
||||
|
||||
@ -496,13 +498,18 @@ class psbtOutputProxy(psbtProxy):
|
||||
|
||||
num_ours = self.parse_subpaths(my_xfp, parent.warnings)
|
||||
|
||||
if num_ours == 0:
|
||||
# - must match expected address for this output, coming from unsigned txn
|
||||
af, addr_or_pubkey, is_segwit = txo.get_address()
|
||||
|
||||
if (num_ours == 0) 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.
|
||||
return
|
||||
|
||||
# - must match expected address for this output, coming from unsigned txn
|
||||
addr_type, addr_or_pubkey, is_segwit = txo.get_address()
|
||||
# OP_RETURN
|
||||
# - nothing we can do with anchor outputs
|
||||
# UNKNOWN
|
||||
# - scripts that we do not understand
|
||||
return af
|
||||
|
||||
if self.subpaths and len(self.subpaths) == 1 and not active_miniscript: # miniscript can have one key only
|
||||
# p2pk, p2pkh, p2wpkh cases
|
||||
@ -513,7 +520,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
# p2wsh/p2sh cases need full set of pubkeys, and therefore redeem script
|
||||
expect_pubkey = None
|
||||
|
||||
if addr_type == 'p2pk':
|
||||
if af == 'p2pk':
|
||||
# output is public key (not a hash, much less common)
|
||||
assert len(addr_or_pubkey) == 33
|
||||
|
||||
@ -521,12 +528,12 @@ class psbtOutputProxy(psbtProxy):
|
||||
raise FraudulentChangeOutput(out_idx, "P2PK change output is fraudulent")
|
||||
|
||||
self.is_change = True
|
||||
return
|
||||
return af
|
||||
|
||||
# Figure out what the hashed addr should be
|
||||
pkh = addr_or_pubkey
|
||||
|
||||
if addr_type == 'p2sh':
|
||||
if af == 'p2sh':
|
||||
# P2SH or Multisig output
|
||||
|
||||
# Can be both, or either one depending on address type
|
||||
@ -537,7 +544,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
# 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.
|
||||
# But definitely 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])
|
||||
@ -566,7 +573,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
active_miniscript.validate_script_pubkey(txo.scriptPubKey,
|
||||
list(self.subpaths.values()))
|
||||
self.is_change = True
|
||||
return
|
||||
return af
|
||||
except Exception as e:
|
||||
raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e)
|
||||
else:
|
||||
@ -579,7 +586,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
# - 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
|
||||
return af
|
||||
|
||||
if active_multisig:
|
||||
# Multisig change output, for wallet we're supposed to be a part of.
|
||||
@ -592,7 +599,8 @@ class psbtOutputProxy(psbtProxy):
|
||||
# Without validation, we have to assume all outputs
|
||||
# will be taken from us, and are not really change.
|
||||
self.is_change = False
|
||||
return
|
||||
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)
|
||||
@ -623,7 +631,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
raise FraudulentChangeOutput(out_idx, "P2WSH witness script has wrong hash")
|
||||
|
||||
self.is_change = True
|
||||
return
|
||||
return af
|
||||
|
||||
if witness_script:
|
||||
# p2sh-p2wsh case (because it had witness script)
|
||||
@ -640,11 +648,11 @@ class psbtOutputProxy(psbtProxy):
|
||||
# old BIP-16 style; looks like payment addr
|
||||
expect_pkh = hash160(redeem_script)
|
||||
|
||||
elif addr_type == 'p2pkh':
|
||||
elif af == 'p2pkh':
|
||||
# input is hash160 of a single public key
|
||||
assert len(addr_or_pubkey) == 20
|
||||
expect_pkh = hash160(expect_pubkey)
|
||||
elif addr_type == "p2tr":
|
||||
elif af == "p2tr":
|
||||
if expect_pubkey is None and len(self.taproot_subpaths) > 1:
|
||||
if active_miniscript:
|
||||
try:
|
||||
@ -653,7 +661,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
[v[1:] for v in self.taproot_subpaths.values() if len(v[1:]) > 1]
|
||||
)
|
||||
self.is_change = True
|
||||
return
|
||||
return af
|
||||
except Exception as e:
|
||||
raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e)
|
||||
expect_pkh = None
|
||||
@ -661,13 +669,14 @@ class psbtOutputProxy(psbtProxy):
|
||||
expect_pkh = taptweak(expect_pubkey)
|
||||
else:
|
||||
# we don't know how to "solve" this type of input
|
||||
return
|
||||
return af
|
||||
|
||||
if pkh != expect_pkh:
|
||||
raise FraudulentChangeOutput(out_idx, "Change output is fraudulent")
|
||||
|
||||
# We will check pubkey value at the last second, during signing.
|
||||
self.is_change = True
|
||||
return af
|
||||
|
||||
|
||||
# Track details of each input of PSBT
|
||||
@ -689,7 +698,7 @@ class psbtInputProxy(psbtProxy):
|
||||
'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid',
|
||||
'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'taproot_key_sig',
|
||||
'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "use_keypath", "subpaths",
|
||||
"taproot_subpaths", "taproot_internal_key", "part_sig"
|
||||
"taproot_subpaths", "taproot_internal_key", "is_miniscript",
|
||||
)
|
||||
|
||||
def __init__(self, fd, idx):
|
||||
@ -697,7 +706,7 @@ class psbtInputProxy(psbtProxy):
|
||||
|
||||
#self.utxo = None
|
||||
#self.witness_utxo = None
|
||||
# self.part_sig = {}
|
||||
self.part_sigs = {}
|
||||
#self.sighash = None
|
||||
# self.subpaths = {} # will be empty if taproot
|
||||
#self.redeem_script = None
|
||||
@ -809,13 +818,13 @@ class psbtInputProxy(psbtProxy):
|
||||
# rework the pubkey => subpath mapping
|
||||
self.parse_subpaths(my_xfp, parent.warnings)
|
||||
|
||||
if self.part_sig:
|
||||
if self.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
|
||||
self.fully_signed = len(self.part_sig) >= len(self.subpaths)
|
||||
self.fully_signed = (len(self.part_sigs) >= len(self.subpaths))
|
||||
else:
|
||||
# No signatures at all yet for this input (typical non multisig)
|
||||
self.fully_signed = False
|
||||
@ -901,7 +910,7 @@ class psbtInputProxy(psbtProxy):
|
||||
|
||||
return utxo
|
||||
|
||||
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
|
||||
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt, cosign_xfp=None):
|
||||
# See what it takes to sign this particular input
|
||||
# - type of script
|
||||
# - which pubkey needed
|
||||
@ -923,6 +932,15 @@ class psbtInputProxy(psbtProxy):
|
||||
which_key = None
|
||||
|
||||
addr_type, addr_or_pubkey, addr_is_segwit = utxo.get_address()
|
||||
if addr_type == "op_return":
|
||||
self.required_key = None
|
||||
return
|
||||
|
||||
if addr_type 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 addr_is_segwit and not self.is_segwit:
|
||||
self.is_segwit = True
|
||||
|
||||
@ -944,18 +962,18 @@ class psbtInputProxy(psbtProxy):
|
||||
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
|
||||
# - limitation: we cannot be two legs of a multisig (only if CCC feature used)
|
||||
# - but if partial sig already in place, ignore that one
|
||||
if not which_key:
|
||||
which_key = set()
|
||||
|
||||
for pubkey, path in self.subpaths.items():
|
||||
if self.part_sig and (pubkey in self.part_sig):
|
||||
if self.part_sigs and (pubkey in self.part_sigs):
|
||||
# pubkey has already signed, so ignore
|
||||
continue
|
||||
|
||||
if path[0] == my_xfp:
|
||||
if path[0] in (my_xfp, cosign_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 \
|
||||
@ -1043,10 +1061,11 @@ class psbtInputProxy(psbtProxy):
|
||||
# we don't know how to "solve" this type of input
|
||||
pass
|
||||
|
||||
if self.is_multisig and which_key:
|
||||
if self.is_multisig:
|
||||
# We will be signing this input, so
|
||||
# - find which wallet it is or
|
||||
# - check it's the right M/N to match redeem script
|
||||
# - which_key can be empty set, meaning all is already signed
|
||||
|
||||
#print("redeem: %s" % b2a_hex(redeem_script))
|
||||
xfp_paths = list(self.subpaths.values())
|
||||
@ -1067,10 +1086,10 @@ class psbtInputProxy(psbtProxy):
|
||||
try:
|
||||
psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths)
|
||||
except BaseException as exc:
|
||||
sys.print_exception(exc)
|
||||
# sys.print_exception(exc)
|
||||
raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc))
|
||||
|
||||
if self.is_miniscript and which_key:
|
||||
if self.is_miniscript:
|
||||
try:
|
||||
xfp_paths = [item[1:] for item in self.taproot_subpaths.values() if len(item[1:]) > 1]
|
||||
except AttributeError:
|
||||
@ -1089,6 +1108,7 @@ class psbtInputProxy(psbtProxy):
|
||||
psbt.active_miniscript.validate_script_pubkey(utxo.scriptPubKey,
|
||||
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))
|
||||
|
||||
if not which_key and DEBUG:
|
||||
@ -1130,9 +1150,7 @@ class psbtInputProxy(psbtProxy):
|
||||
elif kt == PSBT_IN_WITNESS_UTXO:
|
||||
self.witness_utxo = val
|
||||
elif kt == PSBT_IN_PARTIAL_SIG:
|
||||
if self.part_sig is None:
|
||||
self.part_sig = {}
|
||||
self.part_sig[key[1:]] = val
|
||||
self.part_sigs[key[1:]] = self.get(val)
|
||||
elif kt == PSBT_IN_BIP32_DERIVATION:
|
||||
if self.subpaths is None:
|
||||
self.subpaths = {}
|
||||
@ -1188,9 +1206,9 @@ class psbtInputProxy(psbtProxy):
|
||||
if self.witness_utxo:
|
||||
wr(PSBT_IN_WITNESS_UTXO, self.witness_utxo)
|
||||
|
||||
if self.part_sig:
|
||||
for pk in self.part_sig:
|
||||
wr(PSBT_IN_PARTIAL_SIG, self.part_sig[pk], pk)
|
||||
if self.part_sigs:
|
||||
for pk, sig in self.part_sigs.items():
|
||||
wr(PSBT_IN_PARTIAL_SIG, sig, pk)
|
||||
|
||||
if self.taproot_key_sig:
|
||||
wr(PSBT_IN_TAP_KEY_SIG, self.taproot_key_sig)
|
||||
@ -1485,7 +1503,6 @@ class psbtObject(psbtProxy):
|
||||
# Peek at the inputs to see if we can guess M/N value. Just takes
|
||||
# first one it finds.
|
||||
#
|
||||
from opcodes import OP_CHECKMULTISIG
|
||||
for i in self.inputs:
|
||||
ks = i.witness_script or i.redeem_script
|
||||
if not ks: continue
|
||||
@ -1606,11 +1623,11 @@ class psbtObject(psbtProxy):
|
||||
# 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:
|
||||
@ -1628,11 +1645,11 @@ class psbtObject(psbtProxy):
|
||||
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:
|
||||
@ -1670,7 +1687,7 @@ class psbtObject(psbtProxy):
|
||||
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"
|
||||
|
||||
@ -1698,9 +1715,9 @@ class psbtObject(psbtProxy):
|
||||
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
|
||||
assert inp.req_time_locktime >= NLOCK_IS_TIME
|
||||
if inp.req_height_locktime is not None:
|
||||
assert 0 < inp.req_height_locktime < 500000000
|
||||
assert 0 < inp.req_height_locktime < NLOCK_IS_TIME
|
||||
else:
|
||||
# v0 requires exclusion
|
||||
assert inp.prevout_idx is None
|
||||
@ -1729,7 +1746,7 @@ class psbtObject(psbtProxy):
|
||||
))
|
||||
else:
|
||||
msg = "This tx can only be spent after "
|
||||
if self.lock_time < 500000000:
|
||||
if self.lock_time < NLOCK_IS_TIME:
|
||||
msg += "block height of %d" % self.lock_time
|
||||
else:
|
||||
try:
|
||||
@ -1766,17 +1783,35 @@ class psbtObject(psbtProxy):
|
||||
# - 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
|
||||
|
||||
for idx, txo in self.output_iter():
|
||||
output = self.outputs[idx]
|
||||
# perform output validation
|
||||
output.validate(idx, txo, self.my_xfp, self.active_multisig, self.active_miniscript, self)
|
||||
af = output.validate(idx, txo, self.my_xfp, self.active_multisig, self.active_miniscript, 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 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:
|
||||
@ -1790,16 +1825,17 @@ class psbtObject(psbtProxy):
|
||||
'%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)
|
||||
|
||||
@ -1810,6 +1846,28 @@ class psbtObject(psbtProxy):
|
||||
self.warnings.append(('Big Fee', 'Network fee is more than '
|
||||
'5%% of total value (%.1f%%).' % per_fee))
|
||||
|
||||
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 num_unknown_scripts:
|
||||
self.warnings.append(
|
||||
('Output?',
|
||||
'Sending to %d not well understood script(s).' % num_unknown_scripts)
|
||||
)
|
||||
|
||||
if zero_val_outs:
|
||||
self.warnings.append(
|
||||
('Zero Value',
|
||||
'Non-standard zero value outputs: %d' % zero_val_outs)
|
||||
)
|
||||
|
||||
self.consolidation_tx = (self.num_change_outputs == self.num_outputs)
|
||||
|
||||
# Enforce policy related to change outputs
|
||||
@ -1955,7 +2013,7 @@ class psbtObject(psbtProxy):
|
||||
for p in probs:
|
||||
self.warnings.append(('Troublesome Change Outs', p))
|
||||
|
||||
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
|
||||
@ -1979,14 +2037,14 @@ class psbtObject(psbtProxy):
|
||||
# pull out just the CTXOut object (expensive)
|
||||
utxo = inp.get_utxo(txi.prevout.n)
|
||||
|
||||
assert utxo.nValue > 0
|
||||
assert utxo.nValue >= 0, "negative input value: i%d" % i
|
||||
total_in += utxo.nValue
|
||||
|
||||
# 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)
|
||||
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
|
||||
|
||||
# iff to UTXO is segwit, then check it's value, and also
|
||||
# capture that value, since it's supposed to be immutable
|
||||
@ -1999,7 +2057,7 @@ class psbtObject(psbtProxy):
|
||||
|
||||
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
|
||||
@ -2010,15 +2068,18 @@ class psbtObject(psbtProxy):
|
||||
)
|
||||
|
||||
if len(self.presigned_inputs) == self.num_inputs:
|
||||
# Maybe wrong for multisig cases? Maybe they want to add their
|
||||
# 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 (inp.required_key is None) and (not inp.fully_signed)
|
||||
)
|
||||
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
|
||||
@ -2146,7 +2207,51 @@ class psbtObject(psbtProxy):
|
||||
outp.serialize(out_fd, self.is_v2)
|
||||
out_fd.write(b'\0')
|
||||
|
||||
def sign_it(self):
|
||||
@staticmethod
|
||||
def check_pubkey_at_path(sv, subpath, target_pk, is_xonly=False):
|
||||
# derive actual pubkey from private
|
||||
skp = keypath_to_str(subpath)
|
||||
node = sv.derive_path(skp)
|
||||
|
||||
# check the pubkey of this BIP-32 node
|
||||
our_pk = node.pubkey()
|
||||
if is_xonly:
|
||||
our_pk = our_pk[1:]
|
||||
if target_pk == our_pk:
|
||||
return node
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def ecdsa_grind_sign(sk, digest, sighash):
|
||||
# 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(sk, 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
|
||||
|
||||
# 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, sighash)
|
||||
return der_sig
|
||||
|
||||
def sign_it(self, alternate_secret=None, my_xfp=None):
|
||||
# txn is approved. sign all inputs we can sign. add signatures
|
||||
# - hash the txn first
|
||||
# - sign all inputs we have the key for
|
||||
@ -2156,10 +2261,13 @@ class psbtObject(psbtProxy):
|
||||
from glob import dis
|
||||
from ownership import OWNERSHIP
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
# Double check the change outputs are right. This is slow, but critical because
|
||||
if my_xfp is None:
|
||||
my_xfp = self.my_xfp
|
||||
|
||||
with stash.SensitiveValues(secret=alternate_secret) as sv:
|
||||
# Double-check the change outputs are right. This is slow, but critical because
|
||||
# it detects bad actors, not bugs or mistakes.
|
||||
# - equivilent check already done for p2sh outputs when we re-built the redeem script
|
||||
# - equivalent check already done for p2sh outputs when we re-built the redeem script
|
||||
change_outs = [n for n,o in enumerate(self.outputs) if o.is_change]
|
||||
if change_outs:
|
||||
dis.fullscreen('Change Check...')
|
||||
@ -2173,36 +2281,25 @@ class psbtObject(psbtProxy):
|
||||
good = 0
|
||||
if oup.subpaths:
|
||||
for pubkey, subpath in oup.subpaths.items():
|
||||
if subpath[0] != self.my_xfp:
|
||||
# for multisig, will be N paths, and exactly one will
|
||||
# be our key. For single-signer, should always be my XFP
|
||||
continue
|
||||
|
||||
# derive actual pubkey from private
|
||||
skp = keypath_to_str(subpath)
|
||||
node = sv.derive_path(skp)
|
||||
|
||||
# check the pubkey of this BIP-32 node
|
||||
if pubkey == node.pubkey():
|
||||
good += 1
|
||||
OWNERSHIP.note_subpath_used(subpath)
|
||||
# for multisig, will be N paths, and exactly one will
|
||||
# be our key. For single-signer, should always be my XFP
|
||||
if subpath[0] == my_xfp:
|
||||
# derive actual pubkey from private
|
||||
res = self.check_pubkey_at_path(sv, subpath, pubkey)
|
||||
if res:
|
||||
good += 1
|
||||
# TODO is this needed if output is multisig?
|
||||
OWNERSHIP.note_subpath_used(subpath)
|
||||
|
||||
if oup.taproot_subpaths:
|
||||
for xonly_pk, val in oup.taproot_subpaths.items():
|
||||
leaf_hashes, subpath = val[0], val[1:]
|
||||
if subpath[0] != self.my_xfp:
|
||||
# for multisig, will be N paths, and exactly one will
|
||||
# be our key. For single-signer, should always be my XFP
|
||||
continue
|
||||
|
||||
# derive actual pubkey from private
|
||||
skp = keypath_to_str(subpath)
|
||||
node = sv.derive_path(skp)
|
||||
|
||||
# check the pubkey of this BIP-32 node
|
||||
if xonly_pk == node.pubkey()[1:]:
|
||||
good += 1
|
||||
OWNERSHIP.note_subpath_used(subpath)
|
||||
if subpath[0] == self.my_xfp:
|
||||
res = self.check_pubkey_at_path(sv, subpath, xonly_pk, is_xonly=True)
|
||||
if res:
|
||||
good += 1
|
||||
# TODO is this needed if output is miniscript?
|
||||
OWNERSHIP.note_subpath_used(subpath)
|
||||
|
||||
if not good:
|
||||
raise FraudulentChangeOutput(out_idx,
|
||||
@ -2214,7 +2311,6 @@ class psbtObject(psbtProxy):
|
||||
# randomize secp context before each signing session
|
||||
ngu.secp256k1.ctx_rnd()
|
||||
# Sign individual inputs
|
||||
success = set()
|
||||
for in_idx, txi in self.input_iter():
|
||||
dis.progress_sofar(in_idx, self.num_inputs)
|
||||
|
||||
@ -2242,26 +2338,28 @@ class psbtObject(psbtProxy):
|
||||
# need to consider a set of possible keys, since xfp may not be unique
|
||||
for which_key in inp.required_key:
|
||||
# get node required
|
||||
if inp.taproot_subpaths: # this can be set to False even if we haev script ready, but can send keypath
|
||||
is_xonly = False
|
||||
if inp.taproot_subpaths: # this can be set to False even if we have script ready, but can send keypath
|
||||
# tapscript
|
||||
schnorrsig = True
|
||||
# previously internal keys would be filtered here with if item[0]
|
||||
# as per BIP-371 first item is leaf hashes which has to be empty for internal key
|
||||
is_xonly = len(which_key) == 32
|
||||
node = self.check_pubkey_at_path(sv, inp.taproot_subpaths[which_key][1:],
|
||||
which_key, is_xonly=is_xonly)
|
||||
xfp_paths = [item[1:] for item in inp.taproot_subpaths.values()]
|
||||
int_path = inp.taproot_subpaths[which_key][1:]
|
||||
skp = keypath_to_str(int_path)
|
||||
else:
|
||||
node = self.check_pubkey_at_path(sv, inp.subpaths[which_key], which_key)
|
||||
xfp_paths = list(inp.subpaths.values())
|
||||
int_path = inp.subpaths[which_key]
|
||||
skp = keypath_to_str(int_path)
|
||||
|
||||
node = sv.derive_path(skp, register=False)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
# expensive test, but works... and important
|
||||
pu = node.pubkey()
|
||||
if pu == which_key:
|
||||
to_sign.append(node)
|
||||
if len(which_key) == 32 and pu[1:] == which_key:
|
||||
|
||||
to_sign.append(node)
|
||||
if is_xonly and pu[1:] == which_key:
|
||||
# get the script
|
||||
inner_tr_sh = []
|
||||
assert self.active_miniscript
|
||||
@ -2269,7 +2367,7 @@ class psbtObject(psbtProxy):
|
||||
for (script, lv), cb in inp.taproot_scripts.items():
|
||||
target_leaf = None
|
||||
# always exact check/match the script, if we would generate such
|
||||
for leaf in der_d.tapscript.iter_leaves(der_d.tapscript.tree):
|
||||
for leaf in der_d.tapscript.iter_leaves():
|
||||
sc = leaf.compile()
|
||||
if sc == script:
|
||||
target_leaf = leaf
|
||||
@ -2280,14 +2378,13 @@ class psbtObject(psbtProxy):
|
||||
if which_key in [k.key_bytes() for k in target_leaf.keys]:
|
||||
inner_tr_sh.append((script, lv))
|
||||
|
||||
to_sign.append(node)
|
||||
tr_sh.append(inner_tr_sh)
|
||||
|
||||
else:
|
||||
# single pubkey <=> single key
|
||||
which_key = inp.required_key
|
||||
|
||||
assert not inp.part_sig, "already done??"
|
||||
assert not inp.part_sigs, "already done??"
|
||||
assert not inp.taproot_key_sig, "already done taproot??"
|
||||
|
||||
if inp.subpaths and inp.subpaths.get(which_key) and inp.subpaths[which_key][0] == self.my_xfp:
|
||||
@ -2341,19 +2438,12 @@ class psbtObject(psbtProxy):
|
||||
if not inp.taproot_script_sigs:
|
||||
inp.taproot_script_sigs = {}
|
||||
|
||||
if not inp.part_sig:
|
||||
inp.part_sig = {}
|
||||
|
||||
for i, node in enumerate(to_sign):
|
||||
sk = node.privkey()
|
||||
kp = ngu.secp256k1.keypair(sk)
|
||||
pk = node.pubkey()
|
||||
xonly_pk = kp.xonly_pubkey().to_bytes()
|
||||
|
||||
# print("privkey %s" % b2a_hex(sk).decode('ascii'))
|
||||
# print(" pubkey %s" % b2a_hex(pk).decode('ascii'))
|
||||
# print(" digest %s" % b2a_hex(digest).decode('ascii'))
|
||||
|
||||
# Do the ACTUAL signature ... finally!!!
|
||||
if schnorrsig:
|
||||
if tr_sh:
|
||||
@ -2392,42 +2482,14 @@ class psbtObject(psbtProxy):
|
||||
# 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed
|
||||
inp.taproot_key_sig = sig
|
||||
else:
|
||||
# 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(sk, 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
|
||||
|
||||
# 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)
|
||||
inp.part_sig[pk] = der_sig
|
||||
# memory cleanup
|
||||
del result, r, s
|
||||
der_sig = self.ecdsa_grind_sign(sk, digest, inp.sighash)
|
||||
inp.part_sigs[pk] = der_sig
|
||||
|
||||
# private key no longer required
|
||||
stash.blank_object(sk)
|
||||
stash.blank_object(node)
|
||||
del sk, node
|
||||
|
||||
success.add(in_idx)
|
||||
gc.collect()
|
||||
|
||||
if self.is_v2:
|
||||
self.set_modifiable_flag(inp)
|
||||
|
||||
@ -2713,6 +2775,12 @@ class psbtObject(psbtProxy):
|
||||
# double SHA256
|
||||
return ngu.hash.sha256s(rv.digest())
|
||||
|
||||
def multi_input_complete(self, inp):
|
||||
# raises if input is not multisig or no active_multisig loaded
|
||||
assert inp.is_multisig
|
||||
if len(inp.part_sigs) >= self.active_multisig.M:
|
||||
return True
|
||||
|
||||
def is_complete(self):
|
||||
# Are all the inputs (now) signed?
|
||||
|
||||
@ -2720,20 +2788,71 @@ class psbtObject(psbtProxy):
|
||||
signed = len(self.presigned_inputs)
|
||||
|
||||
# plus we added some signatures
|
||||
for inp in self.inputs:
|
||||
if inp.is_multisig or (inp.is_miniscript and not inp.use_keypath):
|
||||
# but we can't combine/finalize multisig/miniscript stuff, so will never't be 'final'
|
||||
for i, inp in enumerate(self.inputs):
|
||||
if i in self.presigned_inputs: continue
|
||||
if inp.is_miniscript and not inp.use_keypath:
|
||||
# but we can't combine/finalize miniscript stuff, so will never't be 'final'7
|
||||
return False
|
||||
if inp.part_sig and len(inp.part_sig) == len(inp.subpaths):
|
||||
elif inp.is_multisig and self.active_multisig:
|
||||
if self.multi_input_complete(inp):
|
||||
signed += 1
|
||||
elif inp.part_sigs and len(inp.part_sigs) == len(inp.subpaths):
|
||||
signed += 1
|
||||
if inp.taproot_key_sig:
|
||||
elif inp.taproot_key_sig:
|
||||
signed += 1
|
||||
|
||||
return signed == self.num_inputs
|
||||
|
||||
def multisig_signatures(self, inp):
|
||||
assert self.active_multisig
|
||||
|
||||
if self.active_multisig.bip67:
|
||||
# BIP-67 easy just sort by public keys
|
||||
sigs = [sig for pk, sig in sorted(inp.part_sigs.items())]
|
||||
else:
|
||||
# need to respect the order of keys in actual descriptor
|
||||
sigs = []
|
||||
for xfp, _, _ in self.active_multisig.xpubs:
|
||||
for pk, pth in inp.subpaths.items():
|
||||
# if xfp matches but pk not in all_sigs -> signer haven't signed
|
||||
# it is ok in threshold multisig - just skip
|
||||
if (xfp == pth[0]) and (pk in inp.part_sigs):
|
||||
sigs.append(inp.part_sigs[pk])
|
||||
break
|
||||
|
||||
# save space and only provide necessary amount of signatures (smaller tx, less fees)
|
||||
sigs = sigs[:self.active_multisig.M]
|
||||
return sigs
|
||||
|
||||
def singlesig_signature(self, inp):
|
||||
# return signature that we added
|
||||
# or one signature from partial sigs if input is fully sign
|
||||
# (i.e. len(part_sigs)>=len(subpaths))
|
||||
ssig = None
|
||||
if inp.taproot_key_sig:
|
||||
return inp.taproot_key_sig
|
||||
|
||||
if inp.part_sigs:
|
||||
assert len(inp.part_sigs) == 1
|
||||
ssig = list(inp.part_sigs.items())[0]
|
||||
|
||||
return ssig
|
||||
|
||||
def multisig_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()
|
||||
for inp in self.inputs:
|
||||
for pk, pth in inp.subpaths.items():
|
||||
if pk in inp.part_sigs:
|
||||
continue
|
||||
|
||||
rv.add(pth[0])
|
||||
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
|
||||
@ -2756,10 +2875,22 @@ class psbtObject(psbtProxy):
|
||||
for in_idx, txi in self.input_iter():
|
||||
inp = self.inputs[in_idx]
|
||||
|
||||
if inp.is_segwit:
|
||||
# first check - if no signature(s) - fail soon
|
||||
if inp.is_multisig:
|
||||
assert self.multi_input_complete(inp), 'Incomplete signature set on input #%d' % in_idx
|
||||
else:
|
||||
# single signature
|
||||
ssig = self.singlesig_signature(inp)
|
||||
assert ssig, 'No signature on input #%d' % in_idx
|
||||
|
||||
if inp.is_p2sh:
|
||||
# multisig (p2sh) segwit still requires the script here.
|
||||
if inp.is_segwit:
|
||||
if inp.is_multisig:
|
||||
if inp.redeem_script:
|
||||
# p2sh-p2wsh
|
||||
txi.scriptSig = ser_string(self.get(inp.redeem_script))
|
||||
|
||||
elif inp.is_p2sh:
|
||||
# singlesig (p2sh) segwit still requires the script here.
|
||||
txi.scriptSig = ser_string(inp.scriptSig)
|
||||
else:
|
||||
# major win for segwit (p2pkh): no redeem script bloat anymore
|
||||
@ -2769,17 +2900,17 @@ class psbtObject(psbtProxy):
|
||||
|
||||
else:
|
||||
# insert the new signature(s), assuming fully signed txn.
|
||||
assert inp.part_sig, 'No signature on input #%d' % in_idx
|
||||
assert len(inp.part_sig) < 2, 'More signatures on input #%d' % in_idx
|
||||
assert not inp.is_multisig, 'Multisig PSBT combine not supported'
|
||||
|
||||
pubkey, der_sig = list(inp.part_sig.items())[0]
|
||||
|
||||
s = b''
|
||||
s += ser_push_data(der_sig)
|
||||
s += ser_push_data(pubkey)
|
||||
|
||||
txi.scriptSig = s
|
||||
if inp.is_multisig:
|
||||
# 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())
|
||||
|
||||
@ -2800,20 +2931,20 @@ class psbtObject(psbtProxy):
|
||||
for in_idx, wit in self.input_witness_iter():
|
||||
inp = self.inputs[in_idx]
|
||||
|
||||
if inp.is_segwit and (inp.part_sig or inp.taproot_key_sig):
|
||||
if inp.is_segwit and (inp.part_sigs or inp.taproot_key_sig): # TODO
|
||||
# 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'
|
||||
|
||||
# TODO tapscript can also be non multisig, we are not able to finalize that - yet
|
||||
if inp.taproot_key_sig:
|
||||
# segwit v1 (taproot)
|
||||
# can be 65 bytes if sighash != SIGHASH_DEFAULT (0x00)
|
||||
assert len(inp.taproot_key_sig) in (64, 65)
|
||||
wit.scriptWitness.stack = [inp.taproot_key_sig]
|
||||
elif inp.is_multisig:
|
||||
sigs = self.multisig_signatures(inp)
|
||||
wit.scriptWitness.stack = [b""] + sigs + [self.get(inp.witness_script)]
|
||||
else:
|
||||
# segwit v0
|
||||
pubkey, der_sig = list(inp.part_sig.items())[0]
|
||||
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]
|
||||
|
||||
|
||||
@ -17,7 +17,8 @@ MAX_V11_CHAR_LIMIT = const(321)
|
||||
class QRDisplaySingle(UserInteraction):
|
||||
# Show a single QR code for (typically) a list of addresses, or a single value.
|
||||
|
||||
def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None, is_addrs=False):
|
||||
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):
|
||||
self.is_alnum = is_alnum
|
||||
self.idx = 0 # start with first address
|
||||
self.invert = False # looks better, but neither mode is ideal
|
||||
@ -27,6 +28,10 @@ class QRDisplaySingle(UserInteraction):
|
||||
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
|
||||
|
||||
def calc_qr(self, msg):
|
||||
# Version 2 would be nice, but can't hold what we need, even at min error correction,
|
||||
@ -76,8 +81,18 @@ class QRDisplaySingle(UserInteraction):
|
||||
|
||||
# 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, is_addr=self.is_addrs)
|
||||
|
||||
if self.msg:
|
||||
msg = self.msg
|
||||
else:
|
||||
msg = None
|
||||
if isinstance(body, str):
|
||||
# sanity check
|
||||
msg = body
|
||||
|
||||
dis.draw_qr_display(self.qr_data, msg, self.is_alnum,
|
||||
self.sidebar, self.idx_hint(), self.invert,
|
||||
is_addr=self.is_addrs, force_msg=self.force_msg)
|
||||
|
||||
async def interact_bare(self):
|
||||
from glob import NFC, dis
|
||||
@ -92,13 +107,15 @@ class QRDisplaySingle(UserInteraction):
|
||||
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
|
||||
@ -120,6 +137,10 @@ class QRDisplaySingle(UserInteraction):
|
||||
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()
|
||||
|
||||
@ -72,7 +72,7 @@ class 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
|
||||
|
||||
@ -201,7 +201,7 @@ class QRScanner:
|
||||
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
|
||||
|
||||
251
shared/seed.py
251
shared/seed.py
@ -10,30 +10,42 @@
|
||||
# - 'abandon' * 17 + 'agent'
|
||||
# - 'abandon' * 11 + 'about'
|
||||
#
|
||||
import ngu, uctypes, bip39, random, stash, version
|
||||
import ngu, uctypes, bip39, random, version
|
||||
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, parse_extended_key, 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 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:
|
||||
@ -215,7 +227,7 @@ class WordNestMenu(MenuSystem):
|
||||
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 +285,16 @@ individual words if you wish.''')
|
||||
|
||||
|
||||
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 +302,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,15 +438,17 @@ 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
|
||||
@ -459,10 +488,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)
|
||||
|
||||
@ -471,9 +499,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):
|
||||
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:
|
||||
await add_seed_to_vault(encoded, meta=meta)
|
||||
await add_seed_to_vault(encoded, origin=origin, label=label)
|
||||
dis.fullscreen("Wait...")
|
||||
|
||||
applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw)
|
||||
@ -490,11 +519,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):
|
||||
@ -511,7 +540,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.
|
||||
@ -534,7 +563,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
|
||||
@ -548,17 +577,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):
|
||||
@ -636,8 +665,8 @@ def xprv_to_encoded_secret(xprv):
|
||||
|
||||
|
||||
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:
|
||||
@ -662,13 +691,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)
|
||||
@ -679,7 +707,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
|
||||
|
||||
@ -820,7 +848,7 @@ class SeedVaultMenu(MenuSystem):
|
||||
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)
|
||||
|
||||
@ -832,15 +860,15 @@ class SeedVaultMenu(MenuSystem):
|
||||
|
||||
esc = ""
|
||||
tmp_val = False
|
||||
idx, xfp_str, encoded = item.arg
|
||||
idx, rec, encoded = item.arg
|
||||
current_active = (pa.tmp_value == bytes(encoded))
|
||||
|
||||
msg = "Remove seed from seed vault "
|
||||
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"
|
||||
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
|
||||
@ -848,7 +876,7 @@ class SeedVaultMenu(MenuSystem):
|
||||
|
||||
msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere."
|
||||
|
||||
ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape=esc)
|
||||
ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
|
||||
if ch == "x": return
|
||||
|
||||
dis.fullscreen("Saving...")
|
||||
@ -882,13 +910,13 @@ class SeedVaultMenu(MenuSystem):
|
||||
|
||||
@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)
|
||||
|
||||
@ -898,30 +926,28 @@ class SeedVaultMenu(MenuSystem):
|
||||
from glob import dis
|
||||
from ux import ux_input_text
|
||||
|
||||
idx, xfp_str = item.arg
|
||||
idx, old = item.arg
|
||||
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
|
||||
|
||||
seeds = settings.master_get("seeds", [])
|
||||
chk_xfp, encoded, old_name, meta = seeds[idx]
|
||||
assert chk_xfp == xfp_str
|
||||
|
||||
new_name = await ux_input_text(old_name, 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()
|
||||
@ -949,10 +975,9 @@ class SeedVaultMenu(MenuSystem):
|
||||
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)
|
||||
|
||||
@ -966,16 +991,10 @@ class SeedVaultMenu(MenuSystem):
|
||||
# Dynamic menu with user-defined names of seeds shown
|
||||
from pincodes import pa
|
||||
|
||||
if pa.is_deltamode():
|
||||
# attacker has re-enabled SeedVault in Settings
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
|
||||
|
||||
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)'))
|
||||
@ -983,17 +1002,22 @@ class SeedVaultMenu(MenuSystem):
|
||||
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)),
|
||||
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)),
|
||||
]
|
||||
if is_active:
|
||||
submenu[1] = MenuItem("Seed In Use")
|
||||
@ -1004,7 +1028,7 @@ class SeedVaultMenu(MenuSystem):
|
||||
# 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
|
||||
|
||||
@ -1026,6 +1050,44 @@ class SeedVaultMenu(MenuSystem):
|
||||
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
|
||||
@ -1044,7 +1106,7 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
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 charcodes import KEY_QR
|
||||
|
||||
@ -1068,7 +1130,7 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
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
|
||||
]
|
||||
|
||||
return rv
|
||||
@ -1077,17 +1139,14 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
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()
|
||||
@ -1193,7 +1252,7 @@ class PassphraseMenu(MenuSystem):
|
||||
|
||||
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)
|
||||
@ -1208,7 +1267,9 @@ class PassphraseMenu(MenuSystem):
|
||||
@classmethod
|
||||
async def add_numbers(cls, *a):
|
||||
# Mk4 only: add some digits (quick, easy)
|
||||
pw = await ux_input_numbers(cls.pp_sofar)
|
||||
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()
|
||||
@ -1287,7 +1348,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:
|
||||
|
||||
@ -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,19 +348,19 @@ async def test_microsd():
|
||||
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:
|
||||
|
||||
@ -365,9 +377,7 @@ async def test_microsd():
|
||||
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
|
||||
|
||||
@ -62,12 +62,15 @@ def deser_compact_size(f, ret_num_bytes=False):
|
||||
num_bytes = 1
|
||||
if nit == 253:
|
||||
nit = struct.unpack("<H", f.read(2))[0]
|
||||
assert nit >= 253
|
||||
num_bytes += 2
|
||||
elif nit == 254:
|
||||
nit = struct.unpack("<I", f.read(4))[0]
|
||||
assert nit >= 0x1_0000
|
||||
num_bytes += 4
|
||||
elif nit == 255:
|
||||
nit = struct.unpack("<Q", f.read(8))[0]
|
||||
assert nit >= 0x1_0000_0000
|
||||
num_bytes += 8
|
||||
if ret_num_bytes:
|
||||
return nit, num_bytes
|
||||
@ -87,7 +90,6 @@ def deser_uint256(f):
|
||||
r += t << (i * 32)
|
||||
return r
|
||||
|
||||
|
||||
def ser_uint256(u):
|
||||
rs = b""
|
||||
for i in range(8):
|
||||
@ -95,7 +97,6 @@ def ser_uint256(u):
|
||||
u >>= 32
|
||||
return rs
|
||||
|
||||
|
||||
def uint256_from_str(s):
|
||||
r = 0
|
||||
t = struct.unpack("<IIIIIIII", s[:32])
|
||||
@ -103,13 +104,11 @@ def uint256_from_str(s):
|
||||
r += t[i] << (i * 32)
|
||||
return r
|
||||
|
||||
|
||||
def uint256_from_compact(c):
|
||||
nbytes = (c >> 24) & 0xFF
|
||||
v = (c & 0xFFFFFF) << (8 * (nbytes - 3))
|
||||
return v
|
||||
|
||||
|
||||
def deser_vector(f, c):
|
||||
nit = deser_compact_size(f)
|
||||
r = []
|
||||
@ -119,7 +118,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).
|
||||
@ -132,7 +130,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 = []
|
||||
@ -141,29 +138,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)
|
||||
@ -173,7 +163,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:
|
||||
@ -184,16 +173,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'<H', ll) + dd # # 0x4d = 77 => 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])
|
||||
@ -227,11 +218,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
|
||||
@ -244,7 +237,8 @@ def disassemble(script):
|
||||
# OP_0 included here
|
||||
#print('dis %d: opcode=%d' % (offset, c))
|
||||
yield (None, c)
|
||||
except:
|
||||
except Exception:
|
||||
# import sys;sys.print_exception(e)
|
||||
raise ValueError("bad script")
|
||||
|
||||
|
||||
@ -368,20 +362,13 @@ class CTxOut(object):
|
||||
# Detect type of output from scriptPubKey, and return 3-tuple:
|
||||
# (addr_type_code, addr, is_segwit)
|
||||
# 'addr' is byte string, either 20 or 32 long
|
||||
|
||||
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 len(self.scriptPubKey) == 34 and \
|
||||
self.scriptPubKey[0] == 81 and self.scriptPubKey[1] == 32:
|
||||
# aka. P2TR
|
||||
if self.is_p2tr():
|
||||
return 'p2tr', self.scriptPubKey[2:2+32], True
|
||||
|
||||
if len(self.scriptPubKey) == 34 and \
|
||||
self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 32:
|
||||
# aka. P2WSH
|
||||
if self.is_p2wpkh():
|
||||
return 'p2pkh', self.scriptPubKey[2:2+20], True
|
||||
|
||||
if self.is_p2wsh():
|
||||
return 'p2sh', self.scriptPubKey[2:2+32], True
|
||||
|
||||
if self.is_p2pkh():
|
||||
@ -394,9 +381,22 @@ class CTxOut(object):
|
||||
# rare, pay to full pubkey
|
||||
return 'p2pk', self.scriptPubKey[2:2+33], False
|
||||
|
||||
# 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())
|
||||
if self.scriptPubKey[0] == OP_RETURN:
|
||||
return 'op_return', self.scriptPubKey, False
|
||||
|
||||
return None, self.scriptPubKey, None
|
||||
|
||||
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
|
||||
|
||||
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 \
|
||||
@ -501,7 +501,7 @@ class CTransaction(object):
|
||||
self.nVersion = struct.unpack("<i", f.read(4))[0]
|
||||
self.vin = deser_vector(f, CTxIn)
|
||||
flags = 0
|
||||
if len(self.vin) == 0:
|
||||
if not self.vin:
|
||||
flags = struct.unpack("<B", f.read(1))[0]
|
||||
# Not sure why flags can't be zero, but this
|
||||
# matches the implementation in bitcoind
|
||||
|
||||
@ -49,8 +49,10 @@ def numwords_to_len(num_words):
|
||||
assert num_words in SEED_LEN_OPTS
|
||||
return (num_words * 8) // 6
|
||||
|
||||
def len_from_marker(marker):
|
||||
def _len_from_marker(marker):
|
||||
# calculates length of entropy from CC marker
|
||||
# - private detail of SecretStash
|
||||
assert marker & 0x80 # wasn't actual words, might be xprv, etc
|
||||
return ((marker & 0x3) + 2) * 8
|
||||
|
||||
class SecretStash:
|
||||
@ -107,7 +109,7 @@ class SecretStash:
|
||||
|
||||
elif marker & 0x80:
|
||||
# seed phrase
|
||||
ll = len_from_marker(marker)
|
||||
ll = _len_from_marker(marker)
|
||||
|
||||
# note:
|
||||
# - byte length > number of words
|
||||
@ -138,9 +140,34 @@ class SecretStash:
|
||||
|
||||
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 @@ class SecretStash:
|
||||
|
||||
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 @@ class SensitiveValues:
|
||||
|
||||
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 @@ class SensitiveValues:
|
||||
|
||||
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 @@ class SensitiveValues:
|
||||
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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -69,7 +69,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
|
||||
else:
|
||||
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
|
||||
|
||||
768
shared/teleport.py
Normal file
768
shared/teleport.py
Normal file
@ -0,0 +1,768 @@
|
||||
# (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 multisig import MultisigWallet
|
||||
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.
|
||||
|
||||
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 MultisigWallet.exists():
|
||||
await ux_show_story("Incoming PSBT requires multisig wallet(s) to be already setup, but you have none.")
|
||||
return
|
||||
|
||||
ses_key, body, sender_xfp = MultisigWallet.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
|
||||
|
||||
enc = None
|
||||
origin = 'Teleported'
|
||||
label = None
|
||||
|
||||
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.slip32_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
|
||||
|
||||
vals = text_bk_parser(raw)
|
||||
assert vals # empty?
|
||||
|
||||
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)
|
||||
else:
|
||||
# we have no secret, so... reboot if it works, else errors shown, etc.
|
||||
prob = await restore_from_dict(vals)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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), multisig 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 multisig 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
|
||||
ms = psbt.active_multisig
|
||||
all_xfps = [x for x,*p in ms.get_xfp_paths()]
|
||||
need = [x for x in psbt.multisig_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, ms.M - (ms.N - len(need))
|
||||
|
||||
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)
|
||||
|
||||
psbt.consider_keys()
|
||||
|
||||
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_multisig:
|
||||
await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT")
|
||||
return
|
||||
|
||||
await kt_send_psbt(psbt, psbt_len=psbt_len)
|
||||
|
||||
# EOF
|
||||
@ -299,9 +299,12 @@ class TrickPinMgmt:
|
||||
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 = tp.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():
|
||||
@ -371,8 +374,7 @@ class TrickPinMgmt:
|
||||
|
||||
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
|
||||
except: pass
|
||||
|
||||
|
||||
tp = TrickPinMgmt()
|
||||
@ -489,7 +491,7 @@ class TrickPinMenu(MenuSystem):
|
||||
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()
|
||||
@ -632,14 +634,17 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
|
||||
# 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)
|
||||
@ -706,7 +711,7 @@ You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
|
||||
|
||||
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):
|
||||
@ -747,7 +752,6 @@ so you may perform transactions with it. Reboot the Coldcard to restore \
|
||||
normal operation.''')
|
||||
if ch != 'y': return
|
||||
|
||||
from pincodes import pa, AE_SECRET_LEN
|
||||
b, slot = tp.get_by_pin(pin)
|
||||
assert slot
|
||||
|
||||
@ -771,7 +775,7 @@ normal operation.''')
|
||||
|
||||
# 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):
|
||||
@ -808,8 +812,7 @@ normal operation.''')
|
||||
# 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)
|
||||
except: pass
|
||||
|
||||
return va.index(cd_val), lgto_ch[1:], set_it
|
||||
|
||||
@ -833,7 +836,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
|
||||
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,14 +849,14 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
|
||||
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):
|
||||
|
||||
@ -169,6 +169,7 @@ class USBHandler:
|
||||
msg_len = 0
|
||||
|
||||
while 1:
|
||||
success = False
|
||||
yield core._io_queue.queue_read(self.blockable)
|
||||
|
||||
try:
|
||||
@ -212,14 +213,12 @@ class USBHandler:
|
||||
# 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 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,27 +226,31 @@ class USBHandler:
|
||||
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:
|
||||
# catch bugs and fuzzing too
|
||||
if is_simulator() or is_devmode:
|
||||
print("USB request caused this: ", end='')
|
||||
sys.print_exception(exc)
|
||||
# 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:
|
||||
@ -820,6 +823,9 @@ class USBHandler:
|
||||
if offset == 0:
|
||||
self.file_checksum = sha256()
|
||||
self.is_fw_upgrade = False
|
||||
dis.fullscreen("Receiving...", 0)
|
||||
else:
|
||||
dis.progress_sofar(offset, total_size)
|
||||
|
||||
assert offset % 256 == 0, 'alignment'
|
||||
assert offset+len(data) <= total_size <= MAX_UPLOAD_LEN, 'long'
|
||||
@ -830,13 +836,12 @@ class USBHandler:
|
||||
if offset == 0:
|
||||
assert data[0:5] == b'psbt\xff', 'psbt'
|
||||
|
||||
self.file_checksum.update(data)
|
||||
|
||||
for pos in range(offset, offset+len(data), 256):
|
||||
if pos % 4096 == 0:
|
||||
dis.fullscreen("Receiving...", offset/total_size)
|
||||
|
||||
# write up to 256 bytes
|
||||
here = data[pos-offset:pos-offset+256]
|
||||
self.file_checksum.update(here)
|
||||
|
||||
# Very special case for firmware upgrades: intercept and modify
|
||||
# header contents on the fly, and also fail faster if wouldn't work
|
||||
|
||||
203
shared/utils.py
203
shared/utils.py
@ -2,26 +2,16 @@
|
||||
#
|
||||
# utils.py - Misc utils. My favourite kind of source file.
|
||||
#
|
||||
import gc, sys, ustruct, ngu, chains, ure, time, version, uos, uio, bip39
|
||||
import gc, sys, ustruct, ngu, chains, ure, uos, uio, time, bip39, version, uasyncio
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import a2b_base64, b2a_base64
|
||||
from charcodes import OUT_CTRL_ADDRESS
|
||||
from uhashlib import sha256
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR, MAX_PATH_DEPTH
|
||||
from public_constants import AF_P2WSH, AF_P2WSH_P2SH
|
||||
|
||||
from public_constants import MAX_PATH_DEPTH, AF_CLASSIC
|
||||
|
||||
B2A = lambda x: str(b2a_hex(x), 'ascii')
|
||||
|
||||
STD_DERIVATIONS = {
|
||||
"p2pkh": "m/44h/{chain}h/0h/0/0",
|
||||
"p2sh-p2wpkh": "m/49h/{chain}h/0h/0/0",
|
||||
"p2wpkh-p2sh": "m/49h/{chain}h/0h/0/0",
|
||||
"p2wpkh": "m/84h/{chain}h/0h/0/0",
|
||||
"p2tr": "m/86h/{chain}h/0h/0/0",
|
||||
}
|
||||
|
||||
try:
|
||||
from font_iosevka import FontIosevka
|
||||
DOUBLE_WIDE = FontIosevka.DOUBLE_WIDE
|
||||
@ -231,6 +221,7 @@ def to_ascii_printable(s, strip=False, only_printable=True):
|
||||
def problem_file_line(exc):
|
||||
# return a string of just the filename.py and line number where
|
||||
# an exception occured. Best used on AssertionError.
|
||||
|
||||
tmp = uio.StringIO()
|
||||
sys.print_exception(exc, tmp)
|
||||
lines = tmp.getvalue().split('\n')[-3:]
|
||||
@ -260,7 +251,6 @@ def cleanup_deriv_path(bin_path, allow_star=False):
|
||||
# - assume 'm' prefix, so '34' becomes 'm/34', etc
|
||||
# - do not assume /// is m/0/0/0
|
||||
# - if allow_star, then final position can be * or *h (wildcard)
|
||||
from public_constants import MAX_PATH_DEPTH
|
||||
|
||||
s = to_ascii_printable(bin_path, strip=True).lower()
|
||||
|
||||
@ -446,7 +436,7 @@ 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, uasyncio
|
||||
import callgate
|
||||
|
||||
# save if anything pending
|
||||
from glob import settings
|
||||
@ -469,36 +459,44 @@ 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
|
||||
while True:
|
||||
# ln_len considers DOUBLE_WIDTH chars
|
||||
ln_len = 0
|
||||
idx = 0
|
||||
sp = None
|
||||
for idx, ch in enumerate(ln):
|
||||
if ch == ' ':
|
||||
# split point on space if possible
|
||||
sp = idx
|
||||
if ln_len < w:
|
||||
ln_len += 1
|
||||
if ch in DOUBLE_WIDE:
|
||||
ln_len += 1
|
||||
else:
|
||||
if (ln_len == w) and (ch in ".,:;"):
|
||||
# boundary of allowed width
|
||||
# if . or , allow one more character
|
||||
# even if only half visible on Mk4
|
||||
# on Q it's OK as (CHARS_W-1) is used as w
|
||||
sp = None
|
||||
idx += 1
|
||||
|
||||
if txtlen(ln) <= w:
|
||||
yield ln
|
||||
return
|
||||
break
|
||||
else:
|
||||
yield ln
|
||||
return
|
||||
|
||||
while ln:
|
||||
# find a space in (width) first part of remainder
|
||||
sp = ln.rfind(' ', 0, w-1)
|
||||
if sp == -1:
|
||||
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
|
||||
@ -514,8 +512,7 @@ def word_wrap(ln, w):
|
||||
return
|
||||
|
||||
# bad-break the line
|
||||
sp = min(txtlen(ln), w)
|
||||
nsp = sp
|
||||
sp = nsp = idx
|
||||
if ln[nsp:nsp+1] == ' ':
|
||||
nsp += 1
|
||||
else:
|
||||
@ -523,14 +520,10 @@ def word_wrap(ln, w):
|
||||
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_extended_key(ln, private=False):
|
||||
# read an xpub/ypub/etc and return BIP-32 node and what chain it's on.
|
||||
@ -557,27 +550,17 @@ def parse_extended_key(ln, private=False):
|
||||
|
||||
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 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
|
||||
|
||||
@ -626,7 +609,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.
|
||||
@ -646,10 +629,17 @@ 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
|
||||
|
||||
@ -666,7 +656,7 @@ def decode_bip21_text(got):
|
||||
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
|
||||
if not addr:
|
||||
@ -676,10 +666,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:
|
||||
@ -787,4 +779,91 @@ 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
|
||||
|
||||
def extract_cosigner(data, af_str):
|
||||
# decodes any text, looking for key expression [xfp/p/a/t/h]xpub123
|
||||
# BIP-380 https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions
|
||||
# only first key expression will be parsed from the data
|
||||
# key origin info is required
|
||||
# failure to find "proper" key expression results in None being returned
|
||||
pub = "%spub" % chains.current_chain().slip132[AF_CLASSIC].hint
|
||||
if pub not in data:
|
||||
return
|
||||
|
||||
o_start = data.find("[")
|
||||
o_end = data.find("]")
|
||||
if 0 <= o_start < o_end:
|
||||
key_orig_info = data[o_start+1:o_end]
|
||||
ss = key_orig_info.split("/")
|
||||
xfp = ss[0]
|
||||
if (len(xfp) == 8) and (data[o_end+1:o_end+1+len(pub)] == pub):
|
||||
deriv = "m"
|
||||
der_nums = "/".join(ss[1:])
|
||||
if der_nums:
|
||||
deriv += ("/" + der_nums)
|
||||
ek = data[o_end+1:o_end+1+112]
|
||||
key_deriv = "%s_deriv" % af_str
|
||||
# emulate coldcard export xpubs
|
||||
return {"xfp": xfp, af_str: ek, key_deriv: deriv}
|
||||
|
||||
# EOF
|
||||
|
||||
65
shared/ux.py
65
shared/ux.py
@ -6,6 +6,7 @@ from uasyncio import sleep_ms
|
||||
from queues import QueueEmpty
|
||||
import utime, gc, version
|
||||
from utils import word_wrap
|
||||
from version import has_qwerty, num_sd_slots, has_qr
|
||||
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, OUT_CTRL_TITLE)
|
||||
|
||||
@ -16,21 +17,24 @@ DEFAULT_IDLE_TIMEOUT = const(4*3600) # (seconds) 4 hours
|
||||
# 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"
|
||||
@ -170,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
|
||||
@ -246,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
|
||||
@ -341,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"
|
||||
@ -377,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
|
||||
@ -416,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'
|
||||
@ -457,18 +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, force_prompt=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,
|
||||
force_prompt=force_prompt)
|
||||
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
|
||||
@ -478,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)
|
||||
|
||||
|
||||
@ -58,17 +58,9 @@ class PressRelease:
|
||||
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):
|
||||
# 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):
|
||||
|
||||
dis.clear()
|
||||
dis.text(None, -1, footer, FontTiny)
|
||||
|
||||
if prompt:
|
||||
dis.text(0, 0, prompt)
|
||||
y += 8
|
||||
|
||||
dis.save()
|
||||
|
||||
while 1:
|
||||
@ -169,7 +166,7 @@ async def ux_input_numbers(val):
|
||||
# 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):
|
||||
|
||||
288
shared/ux_q1.py
288
shared/ux_q1.py
@ -2,16 +2,14 @@
|
||||
#
|
||||
# ux_q1.py - UX/UI interactions that are Q1 specific and use big screen, keyboard.
|
||||
#
|
||||
import utime, gc, ngu, sys, chains
|
||||
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 ubinascii import b2a_base64
|
||||
|
||||
from utils import problem_file_line, show_single_address
|
||||
@ -77,16 +75,8 @@ class PressRelease:
|
||||
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
|
||||
@ -96,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()
|
||||
@ -125,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)
|
||||
@ -141,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):
|
||||
# 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):
|
||||
@ -543,11 +529,9 @@ 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)
|
||||
@ -572,6 +556,7 @@ def ux_draw_words(y, num_words, words):
|
||||
cols = 2
|
||||
xpos = [2, 18]
|
||||
else:
|
||||
assert num_words in (18, 24)
|
||||
cols = 3
|
||||
xpos = [0, 11, 23]
|
||||
|
||||
@ -604,8 +589,9 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
|
||||
# - 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 and done_cb
|
||||
assert num_words and prompt
|
||||
|
||||
def redraw_words(wrds=None):
|
||||
if not wrds:
|
||||
@ -695,8 +681,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=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
|
||||
@ -717,7 +702,7 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=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
|
||||
@ -764,7 +749,10 @@ async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=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
|
||||
@ -791,7 +779,7 @@ class QRScannerInteraction:
|
||||
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.
|
||||
@ -810,6 +798,8 @@ class QRScannerInteraction:
|
||||
|
||||
task = asyncio.create_task(SCAN.scan_once())
|
||||
|
||||
escape = KEY_CANCEL + (KEY_ENTER if enter_quits else '')
|
||||
|
||||
ph = 0
|
||||
while 1:
|
||||
if task.done():
|
||||
@ -821,9 +811,9 @@ class QRScannerInteraction:
|
||||
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
|
||||
|
||||
@ -835,14 +825,14 @@ class QRScannerInteraction:
|
||||
|
||||
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
|
||||
|
||||
@ -852,7 +842,7 @@ class QRScannerInteraction:
|
||||
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
|
||||
|
||||
@ -882,9 +872,35 @@ class QRScannerInteraction:
|
||||
|
||||
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):
|
||||
# start a QR scan, and act on what we find, whatever it may be.
|
||||
from ux import ux_show_story
|
||||
problem = None
|
||||
while 1:
|
||||
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
|
||||
@ -897,104 +913,99 @@ class QRScannerInteraction:
|
||||
|
||||
# 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 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 == '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)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
if what == 'psbt':
|
||||
decoder, psbt_len, got = vals
|
||||
await qr_psbt_sign(decoder, psbt_len, got)
|
||||
return
|
||||
if what == 'psbt':
|
||||
decoder, psbt_len, got = vals
|
||||
await qr_psbt_sign(decoder, psbt_len, got)
|
||||
|
||||
if what == 'txn':
|
||||
bin_txn, = vals
|
||||
await ux_visualize_txn(bin_txn)
|
||||
return
|
||||
elif what == 'txn':
|
||||
bin_txn, = vals
|
||||
await ux_visualize_txn(bin_txn)
|
||||
|
||||
if what == 'addr':
|
||||
proto, addr, args = vals
|
||||
await ux_visualize_bip21(proto, addr, args)
|
||||
return
|
||||
elif what == 'addr':
|
||||
proto, addr, args = vals
|
||||
await ux_visualize_bip21(proto, addr, args)
|
||||
|
||||
if what in ("multi", "minisc"):
|
||||
from auth import maybe_enroll_xpub
|
||||
from ux import ux_show_story
|
||||
ms_config, = vals
|
||||
try:
|
||||
maybe_enroll_xpub(config=ms_config,
|
||||
miniscript=False if what == "multi" else None)
|
||||
except Exception as e:
|
||||
await ux_show_story(
|
||||
'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
return
|
||||
elif what in ("multi", "minisc"):
|
||||
from auth import maybe_enroll_xpub
|
||||
ms_config, = vals
|
||||
try:
|
||||
maybe_enroll_xpub(config=ms_config,
|
||||
miniscript=False if what == "multi" else None)
|
||||
except Exception as e:
|
||||
await ux_show_story(
|
||||
'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
return
|
||||
|
||||
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 == "wif":
|
||||
data, = vals
|
||||
wif_str, key_pair, compressed, testnet = data
|
||||
await ux_visualize_wif(wif_str, key_pair, compressed, testnet)
|
||||
|
||||
if what == "vmsg":
|
||||
data, = vals
|
||||
from auth import verify_armored_signed_msg
|
||||
await verify_armored_signed_msg(data)
|
||||
return
|
||||
elif what == "vmsg":
|
||||
data, = vals
|
||||
from msgsign import verify_armored_signed_msg
|
||||
await verify_armored_signed_msg(data)
|
||||
|
||||
if what == "smsg":
|
||||
data, = vals
|
||||
from auth import approve_msg_sign, msg_signing_done
|
||||
await approve_msg_sign(None, None, None,
|
||||
msg_sign_request=data, kill_menu=True,
|
||||
approved_cb=msg_signing_done)
|
||||
return
|
||||
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)
|
||||
|
||||
if what == 'text' or what == 'xpub':
|
||||
# we couldn't really decode it.
|
||||
txt, = vals
|
||||
await ux_visualize_textqr(txt)
|
||||
return
|
||||
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):
|
||||
# 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:
|
||||
@ -1008,39 +1019,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
|
||||
)
|
||||
the_ux.push(UserAuthorizedAction.active_request)
|
||||
|
||||
async def ux_visualize_txn(bin_txn):
|
||||
@ -1073,7 +1061,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")
|
||||
@ -1126,7 +1114,7 @@ async def ux_visualize_wif(wif_str, kp, compressed, testnet):
|
||||
|
||||
async def qr_msg_sign_done(signature, address, text):
|
||||
from ux import ux_show_story
|
||||
from auth import rfc_signature_template_gen
|
||||
from msgsign import rfc_signature_template
|
||||
from export import export_by_qr
|
||||
|
||||
sig = b2a_base64(signature).decode('ascii').strip()
|
||||
@ -1138,12 +1126,13 @@ async def qr_msg_sign_done(signature, address, text):
|
||||
if ch == "y":
|
||||
await export_by_qr(sig, "Signature", "U")
|
||||
if ch == "0":
|
||||
armored_str = "".join(rfc_signature_template_gen(addr=address, msg=text,
|
||||
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 auth import ux_sign_msg
|
||||
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):
|
||||
@ -1160,8 +1149,6 @@ async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH):
|
||||
msg = "%s\n\nAbove is text that was scanned. " % txt
|
||||
if escape:
|
||||
msg += " Press (0) to sign the text. "
|
||||
else:
|
||||
msg += "We can't do any more with it."
|
||||
|
||||
ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape)
|
||||
if escape and (ch == "0"):
|
||||
@ -1179,7 +1166,7 @@ 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
|
||||
@ -1207,20 +1194,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
|
||||
@ -1238,7 +1230,7 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
|
||||
|
||||
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
|
||||
|
||||
@ -79,7 +79,7 @@ class VirtDisk:
|
||||
# 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 @@ class VirtDisk:
|
||||
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 @@ class VirtDisk:
|
||||
|
||||
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 @@ class VirtDisk:
|
||||
|
||||
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:
|
||||
|
||||
@ -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,7 +77,6 @@ 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
|
||||
@ -124,11 +124,6 @@ def probe_system():
|
||||
# newer, edge code in effect?
|
||||
is_edge = (get_mpy_version()[1][-1] == 'X')
|
||||
|
||||
# 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
|
||||
|
||||
probe_system()
|
||||
|
||||
# EOF
|
||||
|
||||
@ -22,7 +22,7 @@ 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):
|
||||
|
||||
177
shared/web2fa.py
Normal file
177
shared/web2fa.py
Normal file
@ -0,0 +1,177 @@
|
||||
# (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(label, 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
|
||||
|
||||
qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss,
|
||||
nm=url_quote(label if has_qr else label[0:4]))
|
||||
|
||||
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 store it correctly
|
||||
ok = await perform_web2fa('Enroll: ' + label, 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':
|
||||
# mk4 only?
|
||||
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
|
||||
@ -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,12 +56,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
|
||||
|
||||
raw_secret = bytes(32)
|
||||
try:
|
||||
with stash.SensitiveValues() as sv:
|
||||
if sv.deltamode:
|
||||
# die rather than give up our secrets
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
|
||||
with SensitiveValues(enforce_delta=True) as sv:
|
||||
words = None
|
||||
if sv.mode == 'words':
|
||||
words = bip39.b2a_words(sv.raw).split(' ')
|
||||
@ -82,7 +64,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
|
||||
# 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
|
||||
|
||||
@ -106,7 +88,7 @@ Otherwise, press {ok} to continue.'''.format(n=num_parts, ok=OK), escape='2')
|
||||
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]
|
||||
|
||||
@ -147,11 +129,11 @@ async def xor_all_done(data):
|
||||
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()
|
||||
|
||||
@ -186,7 +168,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
|
||||
@ -203,7 +185,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
|
||||
@ -217,17 +199,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):
|
||||
@ -243,7 +223,7 @@ async def show_n_parts(parts, chk_word):
|
||||
|
||||
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))
|
||||
@ -294,12 +274,7 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
|
||||
if ch == 'x': return
|
||||
if ch == '1':
|
||||
dis.fullscreen("Wait...")
|
||||
with stash.SensitiveValues() as sv:
|
||||
if sv.deltamode:
|
||||
# die rather than give up our secrets
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
|
||||
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[:])
|
||||
@ -307,15 +282,17 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
|
||||
# Add from Seed Vault?
|
||||
# filter only those that are correct length and type from seed vault
|
||||
opt = []
|
||||
seeds = [] if pa.is_deltamode() else settings.master_get("seeds", [])
|
||||
for i, (xfp_str, hex_str, _, _) in enumerate(seeds):
|
||||
raw = pad_raw_secret(hex_str)
|
||||
if raw[0] & 0x80:
|
||||
# seed phrase
|
||||
sk = raw[1:1 + stash.len_from_marker(raw[0])]
|
||||
if stash.len_to_numwords(len(sk)) == desired_num_words:
|
||||
opt.append((i, xfp_str, sk))
|
||||
del seeds
|
||||
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."
|
||||
|
||||
2
stm32/.gitignore
vendored
2
stm32/.gitignore
vendored
@ -9,7 +9,7 @@ firmware.lss
|
||||
firmware-signed.*
|
||||
firmware.elf
|
||||
file_time.c
|
||||
*-RC1-coldcard.dfu
|
||||
*-RC1-*.dfu
|
||||
RC2-*.dfu
|
||||
|
||||
# somewhat useful binary snapshots
|
||||
|
||||
@ -12,7 +12,7 @@ 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)
|
||||
|
||||
@ -10,7 +10,7 @@ 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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
4
stm32/mk4-bootloader/releases/3.2.1.txt
Normal file
4
stm32/mk4-bootloader/releases/3.2.1.txt
Normal file
@ -0,0 +1,4 @@
|
||||
0904b790af34c8acd8e3156cd5b4e818ae09e93611e90c673a7953fec67802d0 bootloader.dfu
|
||||
7c7acbb849d17721f9a53b613d631f8bb8ed3b49c2bf5e1a413511c7d9105775 bootloader.bin
|
||||
e71a730d2025bfcc0bf334614c60022e8df3d847c7c6a53f172aace004d69553 bootloader.lss
|
||||
3.2.1 time=20250415.090935 git=master@adcf2c8e
|
||||
BIN
stm32/mk4-bootloader/releases/3.2.1/bootloader.bin
Normal file
BIN
stm32/mk4-bootloader/releases/3.2.1/bootloader.bin
Normal file
Binary file not shown.
BIN
stm32/mk4-bootloader/releases/3.2.1/bootloader.dfu
Normal file
BIN
stm32/mk4-bootloader/releases/3.2.1/bootloader.dfu
Normal file
Binary file not shown.
34612
stm32/mk4-bootloader/releases/3.2.1/bootloader.lss
Normal file
34612
stm32/mk4-bootloader/releases/3.2.1/bootloader.lss
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,3 +13,4 @@ Github is nearly free, so why not capture all the actual bits!
|
||||
- V3.1.4 - 12-word duress wallets
|
||||
- V3.1.5 - bugfix so slot 10 of trick pins is usable
|
||||
- V3.2.0 - share code with Q bootrom, no changes for Mk4 operation.
|
||||
- V3.2.1 - enable omitted if wrong PIN options & fix "Wipe -> Wallet" trick pin option
|
||||
|
||||
@ -729,12 +729,12 @@ se2_test_trick_pin(const char *pin, int pin_len, trick_slot_t *found_slot, bool
|
||||
uint16_t todo = found_slot->tc_flags;
|
||||
|
||||
// hmm: don't need this data if safety is off.. but we have it anyway
|
||||
if(found_slot->tc_flags & TC_WORD_WALLET) {
|
||||
if(todo & TC_WORD_WALLET) {
|
||||
// it's a 12/24-word BIP-39 seed phrase, un-encrypted.
|
||||
if(found+1 < NUM_TRICKS) {
|
||||
memcpy(found_slot->xdata, &slots[found+1][0], 32);
|
||||
}
|
||||
} else if(found_slot->tc_flags & TC_XPRV_WALLET) {
|
||||
} else if(todo & TC_XPRV_WALLET) {
|
||||
// it's an xprv-based wallet
|
||||
if(found+2 < NUM_TRICKS) {
|
||||
memcpy(&found_slot->xdata[0], &slots[found+1][0], 32);
|
||||
@ -875,14 +875,24 @@ se2_handle_bad_pin(int num_fails)
|
||||
if(slot.tc_flags & TC_WIPE) {
|
||||
// Wipe keys and stop. They can power cycle and keep trying
|
||||
// so only do this if a valid key currently exists.
|
||||
bool valid;
|
||||
const mcu_key_t *cur = mcu_key_get(&valid);
|
||||
if(slot.tc_flags & TC_BRICK) {
|
||||
// special case TC_WIPE|TC_BRICK
|
||||
bool valid;
|
||||
const mcu_key_t *cur = mcu_key_get(&valid);
|
||||
if(valid) {
|
||||
mcu_key_clear(cur);
|
||||
oled_show(screen_wiped);
|
||||
LOCKUP_FOREVER();
|
||||
}
|
||||
// else fall-thru if no keys to wipe and WIPE|BRICK mode, will now brick
|
||||
// used in "Last Chance" mode
|
||||
|
||||
if(valid) {
|
||||
mcu_key_clear(cur);
|
||||
oled_show(screen_wiped);
|
||||
|
||||
LOCKUP_FOREVER();
|
||||
} else {
|
||||
mcu_key_clear(NULL); // does valid key check
|
||||
if(slot.tc_flags == TC_WIPE) {
|
||||
oled_show(screen_wiped);
|
||||
LOCKUP_FOREVER();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -893,6 +903,13 @@ se2_handle_bad_pin(int num_fails)
|
||||
// brick code will happen.
|
||||
fast_brick();
|
||||
}
|
||||
|
||||
if(slot.tc_flags & TC_REBOOT) {
|
||||
NVIC_SystemReset();
|
||||
}
|
||||
//if(slot.tc_flags & TC_FAKE_OUT) {//nothing to do here - Silent Wipe}
|
||||
// only used together with TC_WIPE. At this point we are already wiped
|
||||
// EPIN_AUTH_FAIL handled by caller
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
// Public version number for humans. Lots more version data added by Makefile.
|
||||
// - update ../MK4-Makefile BOOTLOADER_VERSION once this is qualified version
|
||||
#define RELEASE_VERSION "3.2.0"
|
||||
#define RELEASE_VERSION "3.2.1"
|
||||
|
||||
extern const char version_string[];
|
||||
|
||||
|
||||
@ -8,8 +8,11 @@
|
||||
# clobber - delete all build products
|
||||
#
|
||||
|
||||
# for any file it cant find, look in ../mk4-bootloader
|
||||
VPATH = ../mk4-bootloader
|
||||
# for any source file needed, look also in ../mk4-bootloader
|
||||
vpath %.c ../mk4-bootloader
|
||||
vpath %.S ../mk4-bootloader
|
||||
vpath %.h ../mk4-bootloader
|
||||
vpath %.py ../mk4-bootloader
|
||||
|
||||
# Toolchain
|
||||
TOOLCHAIN = arm-none-eabi-
|
||||
|
||||
4
stm32/q1-bootloader/releases/1.1.0.txt
Normal file
4
stm32/q1-bootloader/releases/1.1.0.txt
Normal file
@ -0,0 +1,4 @@
|
||||
f85eb3fcc2bbaa3056ef1efb5f5de94c1527eea17c21e21f0fb4fcd6a988c8b6 bootloader.dfu
|
||||
62aaa45a663e9765125f1bd9d36bd498c402a94b0fd7dfc3c9cdb771c8f2384b bootloader.bin
|
||||
e16d7e6a6f7379327799e3add8948a8c903f0f8b8429084b7731fd73b60d9274 bootloader.lss
|
||||
1.1.0 time=20250415.093631 git=master@28926acd
|
||||
BIN
stm32/q1-bootloader/releases/1.1.0/bootloader.bin
Normal file
BIN
stm32/q1-bootloader/releases/1.1.0/bootloader.bin
Normal file
Binary file not shown.
BIN
stm32/q1-bootloader/releases/1.1.0/bootloader.dfu
Normal file
BIN
stm32/q1-bootloader/releases/1.1.0/bootloader.dfu
Normal file
Binary file not shown.
35454
stm32/q1-bootloader/releases/1.1.0/bootloader.lss
Normal file
35454
stm32/q1-bootloader/releases/1.1.0/bootloader.lss
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,3 +13,4 @@ Github is nearly free, so why not capture all the actual bits!
|
||||
- V1.0.2 - bugfix w/ power btn release
|
||||
- V1.0.3 - bugfix to allow (mpy) version numbers < 3.0.0
|
||||
- V1.0.4 - cleanups and such; final version for first-run of boards.
|
||||
- V1.1.0 - enable omitted if wrong PIN options & fix "Wipe -> Wallet" trick pin option
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
// Public version number for humans. Lots more version data added by Makefile.
|
||||
// - update ../Q1-Makefile BOOTLOADER_VERSION once this is qualified version
|
||||
#define RELEASE_VERSION "1.0.4"
|
||||
#define RELEASE_VERSION "1.1.0"
|
||||
|
||||
extern const char version_string[];
|
||||
|
||||
|
||||
@ -58,6 +58,7 @@ class Bitcoind:
|
||||
"-noprinttoconsole",
|
||||
"-fallbackfee=0.0002",
|
||||
"-server=1",
|
||||
"-listen=0",
|
||||
"-keypool=1",
|
||||
f"-port={self.p2p_port}",
|
||||
f"-rpcport={self.rpc_port}"
|
||||
@ -68,8 +69,9 @@ class Bitcoind:
|
||||
# Wait for cookie file to be created
|
||||
cookie_path = os.path.join(self.datadir, "regtest", ".cookie")
|
||||
for i in range(20):
|
||||
if not os.path.exists(cookie_path):
|
||||
time.sleep(0.5)
|
||||
if os.path.exists(cookie_path):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
RuntimeError("'.cookie' not found. Is bitcoind running?")
|
||||
# Read .cookie file to get user and pass
|
||||
|
||||
@ -781,5 +781,9 @@ class BIP32Node:
|
||||
def chain_code(self):
|
||||
return self.node.chain_code
|
||||
|
||||
def privkey(self):
|
||||
assert isinstance(self.node, PrvKeyNode)
|
||||
return bytes(self.node.private_key)
|
||||
|
||||
def parent_fingerprint(self):
|
||||
return self.node.parent_fingerprint
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, bech32, pdb
|
||||
import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, bech32, pdb, base64
|
||||
from subprocess import check_output
|
||||
from ckcc.protocol import CCProtocolPacker
|
||||
from helpers import B2A, U2SAT, hash160, taptweak, addr_from_display_format
|
||||
@ -31,6 +31,8 @@ def pytest_addoption(parser):
|
||||
default=False, help="run on real dev")
|
||||
parser.addoption("--sim", action="store_true",
|
||||
default=True, help="run on simulator")
|
||||
parser.addoption("--localhost", action="store_true",
|
||||
default=False, help="test web stuff against coldcard.com code running on localhost:5070")
|
||||
parser.addoption("--manual", action="store_true",
|
||||
default=False, help="operator must press keys on real CC")
|
||||
|
||||
@ -816,6 +818,7 @@ def open_microsd(simulator, microsd_path):
|
||||
# open a file from the simulated microsd
|
||||
|
||||
def doit(fn, mode='rb'):
|
||||
assert fn, 'empty fname'
|
||||
return open(microsd_path(fn), mode)
|
||||
|
||||
return doit
|
||||
@ -833,7 +836,7 @@ def settings_path(simulator):
|
||||
@pytest.fixture
|
||||
def settings_slots(settings_path):
|
||||
def doit():
|
||||
return [fn
|
||||
return [settings_path(fn)
|
||||
for fn in os.listdir(settings_path(""))
|
||||
if fn.endswith(".aes")]
|
||||
return doit
|
||||
@ -935,7 +938,18 @@ def use_regtest(request, settings_set):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
def set_seed_words(change_seed_words, reset_seed_words):
|
||||
def doit(w):
|
||||
return change_seed_words(w)
|
||||
|
||||
yield doit
|
||||
|
||||
# Important cleanup: restore normal key, because other tests assume that
|
||||
|
||||
reset_seed_words()
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def change_seed_words(sim_exec, sim_execfile, simulator):
|
||||
# load simulator w/ a specific bip32 master key
|
||||
|
||||
def doit(words):
|
||||
@ -950,30 +964,19 @@ def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
#print("sim xfp: 0x%08x" % simulator.master_fingerprint)
|
||||
return simulator.master_fingerprint
|
||||
|
||||
yield doit
|
||||
|
||||
# Important cleanup: restore normal key, because other tests assume that
|
||||
|
||||
reset_seed_words()
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_seed_words(sim_exec, sim_execfile, simulator):
|
||||
def reset_seed_words(change_seed_words):
|
||||
# load simulator w/ a specific bip39 seed phrase
|
||||
|
||||
def doit():
|
||||
words = simulator_fixed_words
|
||||
cmd = 'import main; main.WORDS = %r;' % words.split()
|
||||
sim_exec(cmd)
|
||||
rv = sim_execfile('devtest/set_seed.py')
|
||||
if rv: pytest.fail(rv)
|
||||
|
||||
simulator.start_encryption()
|
||||
simulator.check_mitm()
|
||||
new_xfp = change_seed_words(simulator_fixed_words)
|
||||
|
||||
#print("sim xfp: 0x%08x (reset)" % simulator.master_fingerprint)
|
||||
assert simulator.master_fingerprint == simulator_fixed_xfp
|
||||
assert new_xfp == simulator_fixed_xfp
|
||||
|
||||
return words
|
||||
return simulator_fixed_words
|
||||
|
||||
return doit
|
||||
|
||||
@ -1004,7 +1007,7 @@ def settings_get(sim_exec):
|
||||
def master_settings_get(sim_exec):
|
||||
|
||||
def doit(key):
|
||||
cmd = f"RV.write(repr(settings.master_get('{key}')))"
|
||||
cmd = f"RV.write(repr(settings.master_get('{key}', False)))"
|
||||
resp = sim_exec(cmd)
|
||||
assert 'Traceback' not in resp, resp
|
||||
return eval(resp)
|
||||
@ -1293,17 +1296,23 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home,
|
||||
for r in range(10):
|
||||
time.sleep(0.1)
|
||||
title, story = cap_story()
|
||||
if title == 'PSBT Signed': break
|
||||
if 'Updated PSBT' in story: break
|
||||
if 'Finalized transaction' in story: break
|
||||
else:
|
||||
assert False, 'timed out'
|
||||
|
||||
txid = None
|
||||
lines = story.split('\n')
|
||||
if 'Final TXID:' in lines:
|
||||
txid = lines[-1]
|
||||
result_fname = lines[-4]
|
||||
else:
|
||||
result_fname = lines[-1]
|
||||
txid = None
|
||||
if 'TXID:' in lines:
|
||||
txid = lines[lines.index('TXID:')+1]
|
||||
|
||||
# This is fragile!
|
||||
# ignore "Press (T) to use Key Teleport to send PSBT to other co-signers" footer
|
||||
# ignore "Press (0) to save again by..."
|
||||
# - want the .txn if present, else the .psbt file
|
||||
t, = [l for l in lines if l.endswith('.txn')] or [None]
|
||||
p, = [l for l in lines if l.endswith('.psbt')] or [None]
|
||||
result_fname = t or p
|
||||
|
||||
result = open_microsd(result_fname, 'rb').read()
|
||||
|
||||
@ -1360,9 +1369,11 @@ def try_sign_microsd(open_microsd, cap_story, pick_menu_item, goto_home,
|
||||
@pytest.fixture
|
||||
def try_sign(start_sign, end_sign):
|
||||
|
||||
def doit(filename_or_data, accept=True, finalize=False, accept_ms_import=False):
|
||||
def doit(filename_or_data, accept=True, finalize=False, accept_ms_import=False,
|
||||
exit_export_loop=True):
|
||||
ip = start_sign(filename_or_data, finalize=finalize)
|
||||
return ip, end_sign(accept, finalize=finalize, accept_ms_import=accept_ms_import)
|
||||
return ip, end_sign(accept, finalize=finalize, accept_ms_import=accept_ms_import,
|
||||
exit_export_loop=exit_export_loop)
|
||||
|
||||
return doit
|
||||
|
||||
@ -1388,29 +1399,30 @@ def start_sign(dev):
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def end_sign(dev, need_keypress):
|
||||
def end_sign(dev, need_keypress, press_cancel):
|
||||
from ckcc_protocol.protocol import CCUserRefused
|
||||
|
||||
def doit(accept=True, in_psbt=None, finalize=False, accept_ms_import=False, expect_txn=True):
|
||||
def doit(accept=True, finalize=False, accept_ms_import=False, expect_txn=True,
|
||||
exit_export_loop=True):
|
||||
|
||||
if accept_ms_import:
|
||||
# XXX would be better to do cap_story here, but that would limit test to simulator
|
||||
need_keypress('y', timeout=None)
|
||||
time.sleep(0.050)
|
||||
|
||||
if accept != None:
|
||||
if accept is not None:
|
||||
need_keypress('y' if accept else 'x', timeout=None)
|
||||
|
||||
if accept == False:
|
||||
if accept is False:
|
||||
with pytest.raises(CCUserRefused):
|
||||
done = None
|
||||
while done == None:
|
||||
while done is None:
|
||||
time.sleep(0.050)
|
||||
done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
|
||||
return
|
||||
else:
|
||||
done = None
|
||||
while done == None:
|
||||
while done is None:
|
||||
time.sleep(0.00)
|
||||
done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
|
||||
|
||||
@ -1446,6 +1458,9 @@ def end_sign(dev, need_keypress):
|
||||
for sig in sigs:
|
||||
assert len(sig) <= 71, "overly long signature observed"
|
||||
|
||||
if exit_export_loop:
|
||||
press_cancel() # landed back to export prompt - exit
|
||||
|
||||
return psbt_out
|
||||
|
||||
return doit
|
||||
@ -1617,6 +1632,24 @@ def nfc_read(request, needs_nfc):
|
||||
except:
|
||||
return doit_usb
|
||||
|
||||
@pytest.fixture()
|
||||
def nfc_read_url(nfc_read, press_cancel):
|
||||
# gives URL from ndef
|
||||
|
||||
def doit():
|
||||
contents = nfc_read()
|
||||
|
||||
press_cancel() # exit NFC animation
|
||||
|
||||
# expect a single record, a URL
|
||||
got, = ndef.message_decoder(contents)
|
||||
|
||||
assert got.type == 'urn:nfc:wkt:U'
|
||||
|
||||
return got.uri
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def nfc_write(request, needs_nfc, is_q1):
|
||||
# WRITE data into NFC "chip"
|
||||
@ -1645,11 +1678,17 @@ def enable_nfc(needs_nfc, sim_exec, settings_set):
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def nfc_disabled(needs_nfc, settings_get):
|
||||
def nfc_disabled(settings_get):
|
||||
def doit():
|
||||
return not bool(settings_get('nfc', 0))
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def vdisk_disabled(settings_get):
|
||||
def doit():
|
||||
return not bool(settings_get('vidsk', 0))
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def scan_a_qr(sim_exec, is_q1):
|
||||
# simulate a QR being scanned
|
||||
@ -1712,6 +1751,42 @@ def nfc_read_text(nfc_read):
|
||||
return got.text
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def nfc_read_txn(nfc_read, press_select):
|
||||
def doit(txid=None, contents=None):
|
||||
if contents is None:
|
||||
contents = nfc_read()
|
||||
time.sleep(.5)
|
||||
press_select()
|
||||
|
||||
got_txid = None
|
||||
got_txn = None
|
||||
got_psbt = None
|
||||
got_hash = None
|
||||
for got in ndef.message_decoder(contents):
|
||||
if got.type == 'urn:nfc:wkt:T':
|
||||
assert 'Transaction' in got.text or 'PSBT' in got.text
|
||||
if 'Transaction' in got.text and txid:
|
||||
assert b2a_hex(txid).decode() in got.text
|
||||
elif got.type == 'urn:nfc:ext:bitcoin.org:txid':
|
||||
got_txid = b2a_hex(got.data).decode('ascii')
|
||||
elif got.type == 'urn:nfc:ext:bitcoin.org:txn':
|
||||
got_txn = got.data
|
||||
elif got.type == 'urn:nfc:ext:bitcoin.org:psbt':
|
||||
got_psbt = got.data
|
||||
elif got.type == 'urn:nfc:ext:bitcoin.org:sha256':
|
||||
got_hash = got.data
|
||||
else:
|
||||
raise ValueError(got.type)
|
||||
|
||||
assert got_psbt or got_txn, 'no data?'
|
||||
assert got_hash
|
||||
assert got_hash == hashlib.sha256(got_psbt or got_txn).digest()
|
||||
|
||||
return got_txid, got_psbt, got_txn
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nfc_block4rf(sim_eval):
|
||||
# wait until RF is enabled and something to read (doesn't read it tho)
|
||||
@ -1816,17 +1891,55 @@ def load_export_and_verify_signature(microsd_path, virtdisk_path, verify_detache
|
||||
contents, address = verify_detached_signature_file([fname], sig_fn, way, addr_fmt)
|
||||
|
||||
if is_json:
|
||||
return json.loads(contents), address
|
||||
return contents, address
|
||||
return json.loads(contents), address, fname
|
||||
return contents, address, fname
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def file_tx_signing_done(virtdisk_path, microsd_path):
|
||||
def doit(story, encoding="base64", is_vdisk=False):
|
||||
path_f = virtdisk_path if is_vdisk else microsd_path
|
||||
enc = "rb" if encoding == "binary" else "r"
|
||||
_split = story.split("\n\n")
|
||||
export = None
|
||||
if 'Updated PSBT is:' == _split[0]:
|
||||
fname = _split[1]
|
||||
path = path_f(fname)
|
||||
with open(path, enc) as f:
|
||||
export = f.read().strip()
|
||||
|
||||
export_tx = None
|
||||
if "Finalized transaction (ready for broadcast)" in _split[2]:
|
||||
fname_tx = _split[3]
|
||||
path_tx = path_f(fname_tx)
|
||||
with open(path_tx, enc) as f:
|
||||
export_tx = f.read().strip()
|
||||
else:
|
||||
# just finalized tx
|
||||
assert "Finalized transaction (ready for broadcast):" == _split[0]
|
||||
fname_tx = _split[1]
|
||||
path_tx = path_f(fname_tx)
|
||||
with open(path_tx, enc) as f:
|
||||
export_tx = f.read()
|
||||
|
||||
txid = None
|
||||
for l in _split:
|
||||
if "TXID" in l:
|
||||
txid = l.split("\n")[-1].strip()
|
||||
assert len(txid) == 64, "wrong txid"
|
||||
break
|
||||
|
||||
return export, export_tx, txid
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_text, nfc_read_json,
|
||||
load_export_and_verify_signature, is_q1, press_cancel, press_select, readback_bbqr,
|
||||
cap_screen_qr, garbage_collector):
|
||||
cap_screen_qr, nfc_read_txn, file_tx_signing_done, garbage_collector):
|
||||
def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr=False,
|
||||
tail_check=None, sd_key=None, vdisk_key=None, nfc_key=None, ret_fname=False,
|
||||
fpattern=None, qr_key=None, skip_query=False):
|
||||
fpattern=None, qr_key=None, is_tx=False, encoding="base64", skip_query=False):
|
||||
|
||||
s_label = None
|
||||
if label == "Address summary":
|
||||
@ -1842,7 +1955,8 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
time.sleep(0.2)
|
||||
title, story = cap_story()
|
||||
if way == "sd":
|
||||
if f"({key_map['sd']}) to save {s_label if s_label else label} file to SD Card" in story:
|
||||
if (f"({key_map['sd']}) to save {s_label if s_label else label} "
|
||||
f"{'' if is_tx else 'file '}to SD Card") in story:
|
||||
need_keypress(key_map['sd'])
|
||||
|
||||
elif way == "nfc":
|
||||
@ -1851,6 +1965,10 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
else:
|
||||
need_keypress(key_map['nfc'])
|
||||
time.sleep(0.2)
|
||||
if is_tx:
|
||||
nfc_export = nfc_read_txn()
|
||||
return nfc_export[1:]
|
||||
|
||||
if is_json:
|
||||
nfc_export = nfc_read_json()
|
||||
else:
|
||||
@ -1873,6 +1991,8 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
return json.loads(data)
|
||||
elif file_type == "U":
|
||||
return data.decode('utf-8') if not isinstance(data, str) else data
|
||||
elif file_type in ("P", "T"):
|
||||
return data
|
||||
else:
|
||||
raise NotImplementedError
|
||||
except:
|
||||
@ -1890,11 +2010,15 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
|
||||
time.sleep(0.2)
|
||||
title, story = cap_story()
|
||||
path_f = microsd_path if way == "sd" else virtdisk_path
|
||||
if sig_check:
|
||||
export, sig_addr = load_export_and_verify_signature(story, way, is_json=is_json,
|
||||
addr_fmt=addr_fmt, label=label,
|
||||
tail_check=tail_check,
|
||||
fpattern=fpattern)
|
||||
export, sig_addr, fname = load_export_and_verify_signature(
|
||||
story, way, is_json=is_json, addr_fmt=addr_fmt,
|
||||
label=label, tail_check=tail_check, fpattern=fpattern
|
||||
)
|
||||
elif is_tx:
|
||||
export, export_tx, _ = file_tx_signing_done(story, encoding, is_vdisk=(way == "vdisk"))
|
||||
return export, export_tx
|
||||
else:
|
||||
assert f"{label} file written" in story
|
||||
if tail_check:
|
||||
@ -1906,10 +2030,8 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
assert fpattern in fname
|
||||
if is_json:
|
||||
assert fname.endswith(".json")
|
||||
if way == "sd":
|
||||
path = microsd_path(fname)
|
||||
else:
|
||||
path = virtdisk_path(fname)
|
||||
|
||||
path = path_f(fname)
|
||||
with open(path, "r") as f:
|
||||
export = f.read()
|
||||
if is_json:
|
||||
@ -1928,6 +2050,111 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signing_artifacts_reexport(cap_story, need_keypress, load_export, press_cancel, is_q1,
|
||||
settings_get):
|
||||
|
||||
def doit(way, tx_final=False, txid=None, encoding=None, del_after=False, is_usb=False):
|
||||
label = "Finalized TX ready for broadcast" if tx_final else "Partly Signed PSBT"
|
||||
def _check_story(the_way):
|
||||
time.sleep(.2)
|
||||
title, story = cap_story()
|
||||
|
||||
if the_way in ["qr", "nfc"]:
|
||||
what = label + " shared via %s." % the_way.upper()
|
||||
assert what in story
|
||||
else:
|
||||
if not del_after:
|
||||
assert "Updated PSBT is" in story
|
||||
if tx_final:
|
||||
assert "Finalized transaction (ready for broadcast)" in story
|
||||
if txid:
|
||||
assert txid in story
|
||||
|
||||
to_do = ["sd", "vdisk", "nfc", "qr"]
|
||||
if not is_usb:
|
||||
_check_story(way)
|
||||
to_do.remove(way) # put it as the last item
|
||||
to_do.append(way)
|
||||
|
||||
if not is_q1:
|
||||
to_do.remove("qr")
|
||||
|
||||
if not settings_get("nfc", None):
|
||||
to_do.remove("nfc")
|
||||
|
||||
res = []
|
||||
res_tx = []
|
||||
for _way in to_do:
|
||||
try:
|
||||
rv = load_export(_way, label, is_json=False, sig_check=False,
|
||||
is_tx=True, encoding=encoding)
|
||||
if isinstance(rv, tuple):
|
||||
_psbt, _tx = rv
|
||||
if _psbt:
|
||||
res.append(_psbt)
|
||||
if _tx:
|
||||
res_tx.append(_tx)
|
||||
else:
|
||||
if tx_final:
|
||||
res_tx.append(rv)
|
||||
else:
|
||||
res.append(rv)
|
||||
if _way in ("qr", "nfc"):
|
||||
# nfc now needs cancel as it keeps reexporting
|
||||
# qr needs to go back from qr view
|
||||
press_cancel()
|
||||
_check_story(_way)
|
||||
except BaseException as e:
|
||||
if _way != "vdisk":
|
||||
raise
|
||||
|
||||
# check we exported the same - even if in different format
|
||||
final_res = []
|
||||
for x in res:
|
||||
if x is not None:
|
||||
x = x.strip()
|
||||
if isinstance(x, bytearray):
|
||||
x = bytes(x)
|
||||
if not isinstance(x, bytes):
|
||||
try:
|
||||
# is just a hex string
|
||||
x = bytes.fromhex(x)
|
||||
except:
|
||||
x = base64.b64decode(x)
|
||||
else:
|
||||
try:
|
||||
x = base64.b64decode(x.decode())
|
||||
except: pass
|
||||
|
||||
final_res.append(x)
|
||||
|
||||
final_res_tx = []
|
||||
for y in res_tx:
|
||||
if y is not None:
|
||||
y = y.strip()
|
||||
try:
|
||||
y = a2b_hex(y)
|
||||
except: pass
|
||||
if isinstance(y, bytearray):
|
||||
# bytearray is unhashable type
|
||||
y = bytes(y)
|
||||
|
||||
final_res_tx.append(y)
|
||||
|
||||
if not del_after and final_res:
|
||||
assert len(set(final_res)) == 1
|
||||
|
||||
fin_tx = None
|
||||
if final_res_tx:
|
||||
assert len(set(final_res_tx)) == 1
|
||||
fin_tx = final_res_tx[0]
|
||||
|
||||
return final_res[0] if final_res else None, fin_tx
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tapsigner_encrypted_backup(microsd_path, virtdisk_path):
|
||||
def doit(way, testnet=True):
|
||||
@ -2024,38 +2251,16 @@ def check_and_decrypt_backup(microsd_path):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
|
||||
def restore_backup_unpacked(unit_test, pick_menu_item, cap_story, cap_menu,
|
||||
press_select, word_menu_entry, get_setting, is_q1,
|
||||
need_keypress, scan_a_qr, cap_screen):
|
||||
# restore backup with clear seed as first step
|
||||
def doit(fn, passphrase, avail_settings=None, pass_way=None):
|
||||
unit_test('devtest/clear_seed.py')
|
||||
need_keypress, scan_a_qr, cap_screen, enter_complex):
|
||||
|
||||
m = cap_menu()
|
||||
assert m[0] == 'New Seed Words'
|
||||
pick_menu_item('Import Existing')
|
||||
pick_menu_item('Restore Backup')
|
||||
|
||||
time.sleep(.1)
|
||||
pick_menu_item(fn)
|
||||
|
||||
time.sleep(.1)
|
||||
if is_q1 and pass_way and pass_way == "qr":
|
||||
need_keypress(KEY_QR)
|
||||
time.sleep(.1)
|
||||
qr = ' '.join(w[:4] for w in passphrase)
|
||||
scan_a_qr(qr)
|
||||
for _ in range(20):
|
||||
scr = cap_screen()
|
||||
if 'ENTER if all done' in scr:
|
||||
break
|
||||
time.sleep(.1)
|
||||
press_select()
|
||||
else:
|
||||
word_menu_entry(passphrase, has_checksum=False)
|
||||
# check things are right after unpack & install; FTUX shown
|
||||
def doit(avail_settings=None):
|
||||
|
||||
time.sleep(.3)
|
||||
title, body = cap_story()
|
||||
|
||||
# on simulator Disable USB is always off - so FTUX all the time
|
||||
assert title == 'NO-TITLE' # no Welcome!
|
||||
assert "best security practices" in body
|
||||
@ -2084,6 +2289,48 @@ def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
|
||||
press_select, word_menu_entry, get_setting, is_q1,
|
||||
need_keypress, scan_a_qr, cap_screen, enter_complex, restore_backup_unpacked):
|
||||
# restore backup with clear seed as first step
|
||||
def doit(fn, passphrase, avail_settings=None, pass_way=None, custom_bkpw=False):
|
||||
unit_test('devtest/clear_seed.py')
|
||||
|
||||
m = cap_menu()
|
||||
assert m[0] == 'New Seed Words'
|
||||
if custom_bkpw:
|
||||
pick_menu_item('Advanced/Tools')
|
||||
pick_menu_item('I Am Developer.')
|
||||
pick_menu_item('Restore Bkup')
|
||||
else:
|
||||
pick_menu_item('Import Existing')
|
||||
pick_menu_item('Restore Backup')
|
||||
|
||||
time.sleep(.1)
|
||||
pick_menu_item(fn)
|
||||
|
||||
time.sleep(.1)
|
||||
if is_q1 and pass_way and pass_way == "qr":
|
||||
need_keypress(KEY_QR)
|
||||
time.sleep(.1)
|
||||
qr = ' '.join(w[:4] for w in passphrase)
|
||||
scan_a_qr(qr)
|
||||
for _ in range(20):
|
||||
scr = cap_screen()
|
||||
if 'ENTER if all done' in scr:
|
||||
break
|
||||
time.sleep(.1)
|
||||
press_select()
|
||||
elif custom_bkpw:
|
||||
enter_complex(passphrase, b39pass=False)
|
||||
else:
|
||||
word_menu_entry(passphrase, has_checksum=False)
|
||||
|
||||
restore_backup_unpacked(avail_settings=avail_settings)
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def seed_story_to_words():
|
||||
# Q may display words in a number of different ways to get them all onto the screen,
|
||||
@ -2277,16 +2524,18 @@ def validate_address():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skip_if_useless_way(is_q1, nfc_disabled):
|
||||
def skip_if_useless_way(is_q1, nfc_disabled, vdisk_disabled):
|
||||
# when NFC is disabled, no point trying to do a PSBT via NFC
|
||||
# - important: run_sim_tests.py will enable NFC for complete testing
|
||||
# - similarly: the Mk4 and earlier had no QR scanner, so cannot use that as input
|
||||
def doit(way):
|
||||
if way == "qr" and not is_q1:
|
||||
raise pytest.skip("mk4 QR not supported")
|
||||
if way == 'nfc' and nfc_disabled():
|
||||
elif way == 'nfc' and nfc_disabled():
|
||||
# runner will test these cases, but fail faster otherwise
|
||||
raise pytest.skip("NFC disabled")
|
||||
elif way == "vdisk" and vdisk_disabled():
|
||||
raise pytest.skip("VirtualDisk disabled")
|
||||
|
||||
return doit
|
||||
|
||||
@ -2330,9 +2579,33 @@ def garbage_collector():
|
||||
os.remove(pth)
|
||||
except: pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_test_seed_vault():
|
||||
def doit():
|
||||
from test_ephemeral import SEEDVAULT_TEST_DATA
|
||||
sv = []
|
||||
for item in SEEDVAULT_TEST_DATA:
|
||||
xfp, entropy, mnemonic = item
|
||||
|
||||
# build stashed encoded secret
|
||||
entropy_bytes = bytes.fromhex(entropy)
|
||||
if mnemonic:
|
||||
vlen = len(entropy_bytes)
|
||||
assert vlen in [16, 24, 32]
|
||||
marker = 0x80 | ((vlen // 8) - 2)
|
||||
stored_secret = bytes([marker]) + entropy_bytes
|
||||
else:
|
||||
stored_secret = entropy_bytes
|
||||
|
||||
sv.append((xfp, stored_secret.hex(), f"[{xfp}]", "meta"))
|
||||
return sv
|
||||
return doit
|
||||
|
||||
|
||||
# useful fixtures
|
||||
from test_backup import backup_system
|
||||
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll
|
||||
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
|
||||
from test_bip39pw import set_bip39_pw
|
||||
from test_drv_entro import derive_bip85_secret, activate_bip85_ephemeral
|
||||
from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto_eph_seed_menu
|
||||
@ -2342,9 +2615,12 @@ from test_msg import verify_msg_sign_story, sign_msg_from_text, msg_sign_export,
|
||||
from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn
|
||||
from test_miniscript import offer_minsc_import, get_cc_key, bitcoin_core_signer
|
||||
from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig
|
||||
from test_notes import need_some_notes, need_some_passwords
|
||||
from test_nfc import try_sign_nfc, ndef_parse_txn_psbt
|
||||
from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed
|
||||
from test_seed_xor import restore_seed_xor
|
||||
from test_ux import pass_word_quiz, word_menu_entry
|
||||
from test_sign import txid_from_export_prompt
|
||||
from test_ux import pass_word_quiz, word_menu_entry, enable_hw_ux
|
||||
from txn import fake_txn
|
||||
|
||||
# EOF
|
||||
|
||||
@ -15,7 +15,7 @@ async def doit():
|
||||
from users import UsersMenu
|
||||
from flow import has_secrets, nfc_enabled, vdisk_enabled, word_based_seed
|
||||
from flow import hsm_policy_available, is_not_tmp, has_real_secret
|
||||
from flow import has_se_secrets, hsm_available
|
||||
from flow import has_se_secrets, hsm_available, qr_and_has_secrets
|
||||
|
||||
print("%s%s"% (indent, label), file=fd)
|
||||
|
||||
@ -24,7 +24,7 @@ async def doit():
|
||||
m = []
|
||||
|
||||
# recursing into functions that do stuff doesn't work well, skip
|
||||
avoid = {'Clone Coldcard', 'Debug Functions', 'Migrate COLDCARD'}
|
||||
avoid = {'Clone Coldcard', 'Debug Functions', 'Migrate Coldcard'}
|
||||
if any(label.startswith(a) for a in avoid):
|
||||
return
|
||||
|
||||
@ -65,11 +65,11 @@ async def doit():
|
||||
# trick pins are not available in EmptyWallet
|
||||
continue
|
||||
|
||||
pred = getattr(mi, 'predicate', None)
|
||||
pred = getattr(mi, '_predicate', None)
|
||||
if pred in (True, False):
|
||||
if here in ("NFC Tools", "Import via NFC", "NFC File Share"):
|
||||
here += ' [IF NFC ENABLED]'
|
||||
if "QR" in here and "Scan" in here:
|
||||
if "QR" in here or "Scan" in here or "BBQr" in here:
|
||||
here += ' [IF QR SCANNER]'
|
||||
if "battery" in here:
|
||||
here += ' [IF BATTERIES]'
|
||||
@ -97,8 +97,10 @@ async def doit():
|
||||
here += ' [IF SECRET AND NOT TMP SEED]'
|
||||
elif pred == hsm_available:
|
||||
here += ' [IF HSM AND SECRET]'
|
||||
elif pred == qr_and_has_secrets:
|
||||
here += ' [IF QR AND SECRET]'
|
||||
elif pred:
|
||||
if here == "Secure Notes & Passwords":
|
||||
if here in ("Secure Notes & Passwords", "Push Transaction"):
|
||||
here += ' [IF ENBALED]'
|
||||
else:
|
||||
here += ' [MAYBE]'
|
||||
@ -140,6 +142,11 @@ async def doit():
|
||||
settings.put("axskip", 1)
|
||||
settings.put("b39skip", 1)
|
||||
settings.put("sd2fa", ["a"])
|
||||
settings.put("ptxurl", 'https://coldcard.com/pushtx#')
|
||||
|
||||
# saved passphrase on MicroSD
|
||||
with open("MicroSD/.tmp.tmp", "wb") as f:
|
||||
f.write(b'\xf0\xc9\xff\x00\xf37c\xdd\x8bz\xfa\x0b\xd9\x16;g8\xf8S0\xa5\x129\x99\xd4\xa2=\n\x01\xf9q$w\xb2sb,\xa7\xf9')
|
||||
|
||||
with open('menudump.txt', 'wt') as fd:
|
||||
for nm, m in [
|
||||
|
||||
@ -16,6 +16,6 @@ raw = main.ENCODED_SECRET
|
||||
pa.change(new_secret=raw)
|
||||
pa.new_main_secret(raw)
|
||||
|
||||
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
|
||||
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
|
||||
print("New key in effect (encoded): %s" % settings.get('xpub', 'MISSING'))
|
||||
print(".. w/ XFP= %s" % xfp2str(settings.get('xfp', 0)))
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# load up the simulator w/ indicated list of seed words
|
||||
# Load up the simulator w/ indicated list of seed words
|
||||
#
|
||||
from sim_settings import sim_defaults
|
||||
import stash, chains
|
||||
from pincodes import pa
|
||||
@ -11,7 +12,6 @@ from utils import xfp2str
|
||||
from actions import goto_top_menu
|
||||
from nvstore import SettingsObject
|
||||
|
||||
|
||||
tn = chains.BitcoinTestnet
|
||||
|
||||
stash.bip39_passphrase = ''
|
||||
@ -23,14 +23,16 @@ PassphraseMenu.pp_sofar = ''
|
||||
SettingsObject.master_sv_data = {}
|
||||
SettingsObject.master_nvram_key = None
|
||||
set_seed_value(main.WORDS)
|
||||
stash.SensitiveValues.clear_cache()
|
||||
|
||||
settings.set('chain', 'XTN')
|
||||
settings.set('words', len(main.WORDS))
|
||||
settings.set('terms_ok', True)
|
||||
settings.set('idle_to', 0)
|
||||
|
||||
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
|
||||
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
|
||||
print("TESTING: New key in effect [%s]: %s..%s = %s" % (
|
||||
xfp2str(settings.get('xfp', 0)), main.WORDS[0], main.WORDS[-1],
|
||||
settings.get('xpub', 'MISSING')))
|
||||
|
||||
# impt: if going from xprv => seed words, main menu needs updating
|
||||
goto_top_menu()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# load up the simulator w/ indicated test master key
|
||||
# load up the simulator w/ indicated test master key in TPRV format.
|
||||
#
|
||||
import main, ngu
|
||||
from sim_settings import sim_defaults
|
||||
import stash, chains
|
||||
@ -34,8 +35,9 @@ pa.change(new_secret=raw)
|
||||
pa.new_main_secret(raw)
|
||||
settings.set('words', False)
|
||||
|
||||
print("New key in effect: %s" % settings.get('xpub', 'MISSING'))
|
||||
print("Fingerprint: %s" % xfp2str(settings.get('xfp', 0)))
|
||||
|
||||
assert settings.get('xfp', 0) == swab32(node.my_fp())
|
||||
|
||||
print("TESTING: New tprv in effect [%s]: %s" % (
|
||||
settings.get('xpub', 'MISSING'),
|
||||
xfp2str(settings.get('xfp', 0))))
|
||||
|
||||
|
||||
53
testing/devtest/unit_script.py
Normal file
53
testing/devtest/unit_script.py
Normal file
@ -0,0 +1,53 @@
|
||||
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
from uio import BytesIO
|
||||
from serializations import ser_push_data, ser_string_vector, deser_string_vector
|
||||
from serializations import ser_compact_size, deser_compact_size, disassemble
|
||||
|
||||
test_data = [
|
||||
# data, result
|
||||
(55*b"a", b'7aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
||||
(75*b"a", b'Kaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
||||
(76*b"a", b'LLaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
||||
(77*b"a", b'LMaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
||||
(254*b"a", b'L\xfe' + (254 * b"a")),
|
||||
(255*b"a", b'L\xff' + (255 * b"a")),
|
||||
(256*b"a", b'M\x00\x01' + (256 * b"a")),
|
||||
(500*b"a", b'M\xf4\x01' + (500 * b"a")),
|
||||
(65535*b"a", b'M\xff\xff' + (65535 * b"a")),
|
||||
]
|
||||
|
||||
for i, (data, result) in enumerate(test_data):
|
||||
assert ser_push_data(data) == result, i
|
||||
d, _ = list(disassemble(result))[0]
|
||||
assert d == data
|
||||
|
||||
try:
|
||||
# PUSHDATA 4 not implemented
|
||||
ser_push_data(65536 * b"a")
|
||||
raise RuntimeError
|
||||
except AssertionError: pass
|
||||
|
||||
# test serialization/deserialization
|
||||
# all M/N combinations
|
||||
V = range(1, 16)
|
||||
for i, v1 in enumerate(V):
|
||||
for j in range(i+1, len(V)):
|
||||
M, N = v1, V[j]
|
||||
# number of pubkeys times 1 pushdata + 33 pubkey = 34 * N
|
||||
# +1 M
|
||||
# +1 N
|
||||
# +1 OP_CHECKMULTISIG
|
||||
ms_script_len = (34 * N) + 1 + 1 + 1
|
||||
vec = [b"\x00"] + (M * [71*b"s"]) + [ms_script_len*b"w"]
|
||||
assert vec == deser_string_vector(BytesIO(ser_string_vector(vec)))
|
||||
|
||||
|
||||
for i in [253, 0x10000, 0x100000000]:
|
||||
for j in [-1, 0, 1]:
|
||||
num = i + j
|
||||
x = ser_compact_size(num)
|
||||
|
||||
assert num == deser_compact_size(BytesIO(x))
|
||||
|
||||
# EOF
|
||||
@ -79,7 +79,7 @@ def make_change_addr(wallet, style):
|
||||
is_segwit = False
|
||||
elif style == 'p2wpkh':
|
||||
redeem_scr = bytes([0, 20]) + target
|
||||
elif style == 'p2wpkh-p2sh':
|
||||
elif style in ('p2wpkh-p2sh', 'p2sh-p2wpkh'):
|
||||
redeem_scr = bytes([0, 20]) + target
|
||||
actual_scr = bytes([0xa9, 0x14]) + hash160(redeem_scr) + bytes([0x87])
|
||||
elif style == 'p2tr':
|
||||
@ -103,6 +103,11 @@ def xfp2str(xfp):
|
||||
from struct import pack
|
||||
return b2a_hex(pack('<I', xfp)).decode('ascii').upper()
|
||||
|
||||
def str2xfp(s):
|
||||
assert len(s) == 8
|
||||
b = bytes.fromhex(s)
|
||||
return int.from_bytes(b, 'little')
|
||||
|
||||
def addr_from_display_format(dis_addr):
|
||||
assert dis_addr[0] == '\x02' # OUT_CTRL_ADDRESS
|
||||
return dis_addr[1:]
|
||||
@ -220,4 +225,15 @@ def seconds2human_readable(s):
|
||||
|
||||
return " ".join(msg)
|
||||
|
||||
def bitcoind_addr_fmt(script_type):
|
||||
if script_type == "p2wsh":
|
||||
addr_type = "bech32"
|
||||
elif script_type == "p2sh":
|
||||
addr_type = "legacy"
|
||||
else:
|
||||
assert script_type == "p2sh-p2wsh"
|
||||
addr_type = "p2sh-segwit"
|
||||
|
||||
return addr_type
|
||||
|
||||
# EOF
|
||||
|
||||
@ -23,3 +23,5 @@ git+https://github.com/coinkite/bsms-bitcoin-secure-multisig-setup.git@master#eg
|
||||
# BBQr library
|
||||
git+https://github.com/coinkite/BBQr.git@master#egg=bbqr&subdirectory=python
|
||||
|
||||
# for backend testing
|
||||
requests==2.32.3
|
||||
|
||||
@ -284,11 +284,12 @@ def main():
|
||||
test_args = ["--eject"] + DEFAULT_SIMULATOR_ARGS + ["--set", "vidsk=1"]
|
||||
if test_module == "test_bip39pw.py":
|
||||
test_args = []
|
||||
if test_module in ["test_unit.py", "test_se2.py", "test_backup.py"]:
|
||||
if test_module in ["test_unit.py", "test_se2.py", "test_backup.py", "test_teleport.py"]:
|
||||
# test_nvram_mk4 needs to run without --eff
|
||||
# se2 duress wallet activated as ephemeral seed requires proper `settings.load`
|
||||
test_args = ["--set", "nfc=1"]
|
||||
if test_module in ["test_ephemeral.py", "test_notes.py"]:
|
||||
if test_module in ["test_ephemeral.py", "test_notes.py", "test_ccc.py"]:
|
||||
# proper `settings.load` _ virtual disk
|
||||
test_args = ["--set", "nfc=1", "--set", "vidsk=1"]
|
||||
|
||||
if args.q1 and '--q1' not in test_args:
|
||||
|
||||
138
testing/teleport_cli.py
Normal file
138
testing/teleport_cli.py
Normal file
@ -0,0 +1,138 @@
|
||||
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# Key Teleport protocol re-implementation: CLI for humans (testing purposes only).
|
||||
#
|
||||
import click, pyqrcode, json
|
||||
from bbqr import split_qrs
|
||||
from pysecp256k1.extrakeys import keypair_create, keypair_sec
|
||||
from teleport_protocol import (receiver_step1, sender_step1, txt_grouper, stash_encode_secret,
|
||||
stash_decode_secret, receiver_step2)
|
||||
|
||||
|
||||
def show_payload(payload, type_code, title, msg):
|
||||
vers, parts = split_qrs(payload, type_code, max_version=5)
|
||||
qs = [pyqrcode.create(part, error='L', version=vers, mode='alphanumeric')
|
||||
for part in parts]
|
||||
|
||||
for q in qs:
|
||||
click.echo(q.terminal())
|
||||
|
||||
click.echo("\nBBQr payload:")
|
||||
for p in parts:
|
||||
click.echo(p)
|
||||
|
||||
click.echo()
|
||||
click.echo(title)
|
||||
click.echo(msg)
|
||||
|
||||
def show_received(dtype, data):
|
||||
if dtype == 's':
|
||||
# words / bip 32 master / xprv, etc
|
||||
noun, decoded = stash_decode_secret(data)
|
||||
print(f"Received {noun} via teleport:\n", decoded)
|
||||
|
||||
elif dtype == 'x':
|
||||
# TODO seems can be removed
|
||||
# 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
|
||||
raise NotImplementedError
|
||||
|
||||
elif dtype == 'p':
|
||||
# raw PSBT -- much bigger more complex
|
||||
raise NotImplementedError
|
||||
|
||||
elif dtype == 'b':
|
||||
# full system backup, including master: text lines
|
||||
print("Received backup via Teleport:\n")
|
||||
for ln in data.decode().split('\n'):
|
||||
if not ln: continue
|
||||
print(ln)
|
||||
|
||||
elif dtype == 'v':
|
||||
# one key export from a seed vault
|
||||
# - watch for incompatibility here if we ever change VaultEntry
|
||||
print("Received Seed Vault entry via Teleport:\n", json.loads(data))
|
||||
elif dtype == 'n':
|
||||
# import secure note(s)
|
||||
print("Received secure note(s) via Teleport:\n", json.loads(data))
|
||||
else:
|
||||
raise ValueError("Unknown type", dtype)
|
||||
|
||||
@click.group()
|
||||
def main():
|
||||
pass
|
||||
|
||||
@main.command('recv_init')
|
||||
@click.option('--secret', '-k', type=str, default=None,
|
||||
help='Ephemeral private key used to create shared ECDH key')
|
||||
def recv_init(secret):
|
||||
number_pass, enc_pubkey, kp_receiver = receiver_step1(secret=secret)
|
||||
msg = (f'To receive sensitive data from another COLDCARD,'
|
||||
f'share this Receiver Password with sender:\n\t{number_pass}'
|
||||
f' = {txt_grouper(number_pass)}')
|
||||
|
||||
show_payload(enc_pubkey, "R", 'Key Teleport: Receive', msg)
|
||||
if secret is None:
|
||||
# if user haven't specified secret for ECDH keypair dump it to stdout
|
||||
# it is needed as second arguemnt to "recv" cmd
|
||||
click.echo("Picked ephemeral ECDH key: " + keypair_sec(kp_receiver).hex())
|
||||
|
||||
click.echo()
|
||||
# encrypted pubkey payload is first argument to "send" cmd
|
||||
click.echo("Encrypted pubkey (payload): " + enc_pubkey.hex())
|
||||
|
||||
|
||||
@main.command('send')
|
||||
@click.argument('payload', type=str)
|
||||
@click.option('--secret', '-k', type=str, default=None,
|
||||
help='Ephemeral private key used to create shared ECDH key')
|
||||
@click.option('--password', prompt=True, required=True)
|
||||
@click.option('--mnemonic', type=str, default=None)
|
||||
@click.option('--xprv', type=str, default=None)
|
||||
@click.option('--text', type=str, default=None)
|
||||
@click.option('--backup', type=click.Path(exists=True), default=None)
|
||||
def send(payload, secret, password, mnemonic, xprv, text, backup):
|
||||
if mnemonic:
|
||||
cleartext = b"s" + stash_encode_secret(words=mnemonic)
|
||||
elif xprv:
|
||||
cleartext = b"s" + stash_encode_secret(xprv=xprv)
|
||||
elif text:
|
||||
cleartext = b"n" + json.dumps([{"title": "Quick Note", "misc":text}]).encode()
|
||||
else:
|
||||
assert backup
|
||||
out = []
|
||||
with open(backup, "r") as f: # this needs to be cleartext backup
|
||||
for ln in f.readlines():
|
||||
if not ln: continue
|
||||
if ln[0] == '#': continue
|
||||
out.append(ln.encode())
|
||||
|
||||
cleartext = b"b" + b'\n'.join(ln for ln in out)
|
||||
|
||||
noid_txt, encrypted_payload, kp_sender, pk_rec = sender_step1(
|
||||
password, bytes.fromhex(payload), cleartext, secret=secret
|
||||
)
|
||||
msg = ("Share this password with the receiver, via some different channel:"
|
||||
"\n\n\t%s = %s" % (noid_txt, txt_grouper(noid_txt)))
|
||||
show_payload(encrypted_payload, "S", 'Teleport Password', msg)
|
||||
|
||||
click.echo()
|
||||
# encrypted payload is first arguemnt to "recv" cmd
|
||||
click.echo("Encrypted payload: " + encrypted_payload.hex())
|
||||
|
||||
|
||||
@main.command('recv')
|
||||
@click.argument('payload', type=str)
|
||||
@click.argument('secret', type=str)
|
||||
@click.option('--password', prompt=True, required=True)
|
||||
def recv(payload, secret, password):
|
||||
dtype, received = receiver_step2(password.upper(), bytes.fromhex(payload),
|
||||
keypair_create(bytes.fromhex(secret)))
|
||||
click.echo()
|
||||
show_received(dtype, received)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# EOF
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user