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:
scgbckbone 2025-05-23 20:29:50 +02:00
parent c10aff8a02
commit e209980630
129 changed files with 80172 additions and 3782 deletions

View File

@ -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
View 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.

View File

@ -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.

View File

@ -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

View File

@ -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
View 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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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-----

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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:

View File

@ -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")

View File

@ -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

View File

@ -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
View 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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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.

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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
View 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

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View 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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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
View 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

View File

@ -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
View File

@ -9,7 +9,7 @@ firmware.lss
firmware-signed.*
firmware.elf
file_time.c
*-RC1-coldcard.dfu
*-RC1-*.dfu
RC2-*.dfu
# somewhat useful binary snapshots

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
0904b790af34c8acd8e3156cd5b4e818ae09e93611e90c673a7953fec67802d0 bootloader.dfu
7c7acbb849d17721f9a53b613d631f8bb8ed3b49c2bf5e1a413511c7d9105775 bootloader.bin
e71a730d2025bfcc0bf334614c60022e8df3d847c7c6a53f172aace004d69553 bootloader.lss
3.2.1 time=20250415.090935 git=master@adcf2c8e

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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
}
}

View File

@ -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[];

View File

@ -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-

View File

@ -0,0 +1,4 @@
f85eb3fcc2bbaa3056ef1efb5f5de94c1527eea17c21e21f0fb4fcd6a988c8b6 bootloader.dfu
62aaa45a663e9765125f1bd9d36bd498c402a94b0fd7dfc3c9cdb771c8f2384b bootloader.bin
e16d7e6a6f7379327799e3add8948a8c903f0f8b8429084b7731fd73b60d9274 bootloader.lss
1.1.0 time=20250415.093631 git=master@28926acd

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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[];

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 [

View File

@ -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)))

View File

@ -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()

View File

@ -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))))

View 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

View File

@ -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

View File

@ -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

View File

@ -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
View 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