Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158c8a772b | ||
|
|
5af5a9ae2f | ||
|
|
12e54d379b | ||
|
|
57f9b367d5 | ||
|
|
742ac8e67e | ||
|
|
837d181612 | ||
|
|
4b82668a6a | ||
|
|
1c0fc15ca9 | ||
|
|
c11616d415 | ||
|
|
8df7b6ff3b | ||
|
|
b1a7d2b1b2 | ||
|
|
2c46a22679 | ||
|
|
04d2099724 | ||
|
|
d8dd62732c | ||
|
|
5e2e435583 | ||
|
|
6d8c1ee96a | ||
|
|
3ad4262fcf | ||
|
|
06869c5304 | ||
|
|
99230c185f | ||
|
|
5a21760e2f | ||
|
|
54192f7c50 | ||
|
|
6182bf33d5 | ||
|
|
d293d08f8d | ||
|
|
b80dd72f2d | ||
|
|
74d0a92cbc | ||
|
|
f190edb302 | ||
|
|
4fefcb6dc6 | ||
|
|
b7e345515f | ||
|
|
2cd038c431 | ||
|
|
2bd2b361a8 | ||
|
|
eef6aaf95a | ||
|
|
eb258f4b2a | ||
|
|
d341ed3f57 | ||
|
|
8c519a5bf3 | ||
|
|
a9c402cba3 | ||
|
|
b3480c3ea6 | ||
|
|
db93d769d7 | ||
|
|
408b9d0ef6 | ||
|
|
2f70733007 | ||
|
|
b78b30f0b1 | ||
|
|
05bd7ab39c | ||
|
|
5de8c9672b | ||
|
|
84dda56746 | ||
|
|
336bfbfe08 | ||
|
|
5d69d5c039 | ||
|
|
545b23f1b4 | ||
|
|
ea92a049af | ||
|
|
aa4580adbe | ||
|
|
80106f300e | ||
|
|
422a3a2834 | ||
|
|
ddae5020df | ||
|
|
478c421627 | ||
|
|
ee38dbb28e | ||
|
|
93b3bea43f | ||
|
|
2388efa8d7 | ||
|
|
0c68d1a4f4 | ||
|
|
e6cb8e9e8a | ||
|
|
c2e76b3132 | ||
|
|
202c2cae16 | ||
|
|
8b7c91c949 | ||
|
|
6e134a2c9c | ||
|
|
0dda18b04c | ||
|
|
6a53f3524e | ||
|
|
e6dda17124 | ||
|
|
280aa07d4d | ||
|
|
91197bc90f | ||
|
|
c7936da8b2 | ||
|
|
976772b7be | ||
|
|
3bf5dc319c | ||
|
|
4416c531e9 | ||
|
|
0f43069e6c | ||
|
|
28c0fb4516 | ||
|
|
73fa603200 | ||
|
|
cfe86f5363 | ||
|
|
394edaadbc | ||
|
|
b57cf3b629 | ||
|
|
ccc0ac039c | ||
|
|
fdbfc85fd4 | ||
|
|
35c637804b | ||
|
|
e3212ed1a8 | ||
|
|
7bf89568e6 | ||
|
|
b34a514006 | ||
|
|
731cd11841 | ||
|
|
a293c05a4e | ||
|
|
4414783e1c | ||
|
|
abb1d5c2c3 | ||
|
|
75d30c4229 | ||
|
|
8d810b107f | ||
|
|
3f5e9789b6 | ||
|
|
56a755c6ec | ||
|
|
e1cdafb272 | ||
|
|
ea37bc0b69 | ||
|
|
4247b6427a | ||
|
|
22fd6b4010 | ||
|
|
342af8f78e | ||
|
|
615c0a5064 | ||
|
|
bc18f3dd79 | ||
|
|
931d26962c | ||
|
|
99fc4d3d23 | ||
|
|
c6d6adcf4e | ||
|
|
ecb4804c2f | ||
|
|
4e552fc6c3 | ||
|
|
503641d8bf | ||
|
|
e0615a13f0 | ||
|
|
fa05c28501 | ||
|
|
760ef7f168 | ||
|
|
0f76b4c74a | ||
|
|
c89a8deef7 | ||
|
|
804ec34bd3 | ||
|
|
971f602f7d | ||
|
|
3120267464 | ||
|
|
f1059e0972 | ||
|
|
0c4175aa33 | ||
|
|
56bf11bd3e | ||
|
|
6aa707074c | ||
|
|
c2d0a7f26f | ||
|
|
20b14b7da0 | ||
|
|
91f7aa9b2b | ||
|
|
5de1d72f9f | ||
|
|
74c6dcc7f3 | ||
|
|
3f2ce08f2b | ||
|
|
ba6fd7025e | ||
|
|
0ac7c433e1 | ||
|
|
33753cfebb | ||
|
|
7a6442f96b | ||
|
|
dd3f79dc80 | ||
|
|
14350452f0 | ||
|
|
7a21a37fb9 | ||
|
|
7193e284b2 | ||
|
|
336f5f6e04 | ||
|
|
23ae4cb317 | ||
|
|
9b2019c466 | ||
|
|
1aeb74ada5 | ||
|
|
b35e04be39 | ||
|
|
e3c5b32419 | ||
|
|
2623e1cc88 | ||
|
|
c43cc7130f | ||
|
|
5ea5d3f793 | ||
|
|
6fd2cb86e9 | ||
|
|
594690d187 | ||
|
|
3cd47cdc5c | ||
|
|
15005d307e | ||
|
|
4b1a13a199 | ||
|
|
df4819f0b8 | ||
|
|
950589215b | ||
|
|
9411b4bb96 | ||
|
|
f5d5ee0620 | ||
|
|
2c44856a84 | ||
|
|
487cc78635 | ||
|
|
e0b0b1f51f | ||
|
|
e2876dcc94 | ||
|
|
5023cd4517 | ||
|
|
9401bcd31a | ||
|
|
6f4b4f6363 | ||
|
|
7d855ebe5f | ||
|
|
47d1aac44e | ||
|
|
7e4ecbf9b0 | ||
|
|
7fcfd55d3e | ||
|
|
0f18c1fc59 | ||
|
|
df39a9166a | ||
|
|
321ab3d836 | ||
|
|
95e943a32b | ||
|
|
448fae8bdd | ||
|
|
91c579fadd | ||
|
|
aa7d17bf8f | ||
|
|
b05bd09998 | ||
|
|
a982c86d91 | ||
|
|
25403b017c | ||
|
|
70aa6de8b9 | ||
|
|
824bbbc3b2 | ||
|
|
ebc1832b91 | ||
|
|
306f4d31b8 | ||
|
|
0883d6f347 | ||
|
|
8d7f6dee7a | ||
|
|
79e820fea1 | ||
|
|
0a506ecec6 | ||
|
|
0da4088c01 | ||
|
|
c7762eedf2 | ||
|
|
2749bc00fb |
11
README.md
11
README.md
@ -28,6 +28,17 @@ has been automated using Docker. Steps are as follows:
|
||||
|
||||
4. At the end of the process a clear confirmation message is shown, or the differences.
|
||||
5. Build products can be found `firmware/stm32/built`.
|
||||
6. If you do not trust the results of `make repro` refer to `docs/notes-on-repro.md` which breaks down the process.
|
||||
|
||||
## Long-Lived Branches
|
||||
|
||||
We are now maintaining two branches: `master` and `edge`.
|
||||
|
||||
"Edge" will contain features that may not be ready for prime time,
|
||||
such as Taproot or Miniscript. Our standards for releasing new Edge
|
||||
versions are lower, so we can iterate faster and get these advancements
|
||||
out to other developers.
|
||||
|
||||
|
||||
## Check-out and Setup
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ from setuptools import setup
|
||||
setup(
|
||||
name='signit',
|
||||
version='1.0',
|
||||
py_modules=['signit'],
|
||||
py_modules=['signit', 'sigheader'],
|
||||
install_requires=[
|
||||
'Click',
|
||||
],
|
||||
|
||||
@ -293,8 +293,12 @@ def doit(keydir, outfn=None, build_dir=None, high_water=False,
|
||||
# bugfix: size must be non-page aligned, so extra bytes are erased past end
|
||||
if (body_len % 4096) == 0:
|
||||
body_len += 512
|
||||
|
||||
assert body_len % 512 == 0, body_len
|
||||
assert body_len % 512 == 0, body_len
|
||||
else:
|
||||
# bugfix: PSRAM-based products (Mk4, Q1) need to erase 4k blocks, so
|
||||
# trouble happens if final binary isn't aligned to that size.
|
||||
body_len = align_to(body_len, 4096)
|
||||
assert body_len % 4096 == 0, body_len
|
||||
|
||||
# pad out
|
||||
vectors = pad_to(vectors, FW_HEADER_OFFSET)
|
||||
|
||||
@ -38,6 +38,17 @@ a single file, which is a simple text file and
|
||||
easy to read. Before version 4.0.0, this text file was always
|
||||
called `ckcc-backup.txt`, but the filename is now picked randomly.
|
||||
|
||||
## BIP39 Passphrase
|
||||
|
||||
If BIP39 passphrase is active the default behavior is to back-up
|
||||
main wallet - not BIP39 passphrase wallet. From version `5.2.0`
|
||||
users can choose to back-up also BIP39 passphrase wallet.
|
||||
|
||||
## Ephemeral Seeds
|
||||
|
||||
If ephemeral seed is active the default behavior is to always
|
||||
back-up ephemeral wallet instead of the main wallet.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The archive file names are not encrypted. You can see there is a single
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
# BIP-85 Passwords
|
||||
|
||||
This feature derives a deterministic password according from your seed,
|
||||
according to [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki)
|
||||
(with the recent changes
|
||||
[proposed here](https://github.com/scgbckbone/bips/blob/passwords/bip-0085.mediawiki)).
|
||||
This feature derives a deterministic password from your seed,
|
||||
according to [BIP-85 PWD BASE64](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#pwd-base64).
|
||||
Generated passwords can be sent as keystrokes via USB to the host computer,
|
||||
effectively using Coldcard as specialized password manager.
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ Step 2: Export descriptor from Coldcard to Core
|
||||
- in Bitcoin Core, go to Windows -> Console
|
||||
- select your newly created descriptor wallet in the wallet pulldown (top left)
|
||||
- paste the `importdescriptor` command. It should respond with a success message
|
||||
- in Bitcoin Core v24.1, the console response will include `"message": "Ranged descriptors should not have a label"` and Bitcoin Core won't allow address generation. Removing the entry `"label": "Coldcard x0x0x0x0"` from the .txt file fixes this issue.
|
||||
|
||||
NOTE: If you are importing an existing wallet this way, with UTXO on the blockchain,
|
||||
you may need to rescan and/or delete "timestamp=now" from the command. If the
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
# Ephemeral Seeds
|
||||
|
||||
Ephemeral seed is temporary secret stored only in Coldcard volatile
|
||||
memory (RAM). It only survives single boot, meaning after Coldcard
|
||||
restart it is gone. Ephemeral seeds *completely* defeats the design
|
||||
of Coldcard's security model, based on secure elements.
|
||||
|
||||
Make sure you know what you're doing!
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
- go to `Advanced/Tools -> Ephemeral Seed`
|
||||
- if ephemeral seed is already in use, top menu item `[<xfp>]` is visible
|
||||
with fingerprint of ephemeral master secret
|
||||
- an ephemeral seed can be Imported or Generated at random
|
||||
- go to `Advanced/Tools -> Ephemeral Seed`
|
||||
- `Generate Words`:
|
||||
- same options as generating new seed words, dice rolls included
|
||||
- Import words via NFC with `Import via NFC` option
|
||||
- `Import Words`:
|
||||
- same options as importing seed words
|
||||
- `Import XPRV`:
|
||||
- import extended private key
|
||||
- `Tapsigner Backup`
|
||||
- import TAPSIGNER encrypted backup
|
||||
- an ephemeral seed can also be a BIP-85 derived value
|
||||
|
||||
## Trick PIN Notes
|
||||
|
||||
If you intend to use the ephemeral seed feature frequently, you can
|
||||
define a "Trick PIN" which takes you to a "look blank" trick wallet
|
||||
(ie. no seed set appears to be set). Then you may then safely
|
||||
unlock your Coldcard, without revealing the true PIN, and perform
|
||||
all your ephemeral seed work in that state.
|
||||
|
||||
## Purpose
|
||||
|
||||
This feature is intended for those one-off signings, like recovering
|
||||
a lost seed from some other system or importing some seed as an
|
||||
balance check. We do not recommend handing unencrypted seed material
|
||||
on a regular basis!
|
||||
|
||||
@ -70,7 +70,11 @@
|
||||
- change outputs (indicated with paths, scripts in output section) must correspond to
|
||||
the active multisig wallet, and cannot be used to describe an unrelated (multisig) wallet.
|
||||
- derivation path for each cosigner must be known and consistent with PSBT
|
||||
- fixed: XFP values (fingerprints) for each of the co-signers must be unique (limitation removed)
|
||||
- XFP values (fingerprints) MUST be unique for each of the co-signers
|
||||
|
||||
|
||||
# Taproot
|
||||
- more background and detail in `docs/taproot.md`
|
||||
|
||||
|
||||
# SIGHASH types
|
||||
@ -120,6 +124,8 @@ We will summarize transaction outputs as "change" back into same wallet, however
|
||||
- `p2wsh-p2sh`: _redeemScript_ (which is: `0x00 + 0x20 + sha256(witnessScript)`), and
|
||||
_witnessScript_ (which contains the multisig script)
|
||||
- `p2wsh`: only _witnessScript_ (which contains the actual multisig script)
|
||||
- `p2tr`(keypath singlesig): no _redeemScript_, no _witnessScript_ and output key MUST commit to an unspendable script path as follows `Q = P + int(hashTapTweak(bytes(P)))G`
|
||||
- `p2tr`(scriptpath multisig): _taproot_merkle_root_ and _leaf_script_ more info in docs/taproot.md
|
||||
|
||||
|
||||
# Derivation Paths
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
Choose PIN Code
|
||||
Advanced/Tools
|
||||
View Identity
|
||||
Ephemeral Seed
|
||||
Temporary Seed
|
||||
Generate Words
|
||||
24 Words
|
||||
12 Words
|
||||
24 Word Dice Roll
|
||||
24 Words
|
||||
12 Word Dice Roll
|
||||
24 Word Dice Roll
|
||||
Import Words
|
||||
24 Words
|
||||
18 Words
|
||||
12 Words
|
||||
18 Words
|
||||
24 Words
|
||||
Import via NFC
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
@ -29,16 +29,16 @@
|
||||
|
||||
[IF BLANK WALLET]
|
||||
New Seed Words
|
||||
24 Word (default)
|
||||
12 Word
|
||||
24 Word Dice Roll
|
||||
12 Word Dice Roll
|
||||
Import Existing
|
||||
12 Words
|
||||
24 Words
|
||||
12 Word Dice Roll
|
||||
24 Word Dice Roll
|
||||
Import Existing
|
||||
12 Words
|
||||
[SEED WORD MENUS]
|
||||
18 Words
|
||||
[SEED WORD MENUS]
|
||||
12 Words
|
||||
24 Words
|
||||
[SEED WORD MENUS]
|
||||
Restore Backup
|
||||
Clone Coldcard
|
||||
@ -48,16 +48,16 @@
|
||||
Help
|
||||
Advanced/Tools
|
||||
View Identity
|
||||
Ephemeral Seed
|
||||
Temporary Seed
|
||||
Generate Words
|
||||
24 Words
|
||||
12 Words
|
||||
24 Word Dice Roll
|
||||
24 Words
|
||||
12 Word Dice Roll
|
||||
24 Word Dice Roll
|
||||
Import Words
|
||||
24 Words
|
||||
18 Words
|
||||
12 Words
|
||||
18 Words
|
||||
24 Words
|
||||
Import via NFC
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
@ -84,7 +84,6 @@
|
||||
Settings
|
||||
Login Settings
|
||||
Change Main PIN
|
||||
PIN Options
|
||||
Trick PINs
|
||||
Add New Trick
|
||||
Add If Wrong
|
||||
@ -94,21 +93,23 @@
|
||||
Kill Key
|
||||
Login Countdown
|
||||
Disabled
|
||||
5 minutes
|
||||
15 minutes
|
||||
30 minutes
|
||||
4 hours
|
||||
1 hour
|
||||
5 minutes
|
||||
2 hours
|
||||
4 hours
|
||||
8 hours
|
||||
12 hours
|
||||
2 hours
|
||||
3 days
|
||||
48 hours
|
||||
1 week
|
||||
24 hours
|
||||
48 hours
|
||||
3 days
|
||||
1 week
|
||||
28 days later
|
||||
MicroSD 2FA [MAYBE]
|
||||
Add Card
|
||||
Check Card
|
||||
Remove Card #1
|
||||
Test Login Now
|
||||
Hardware On/Off
|
||||
USB Port
|
||||
@ -133,7 +134,7 @@
|
||||
Bitcoin Core
|
||||
Electrum Wallet
|
||||
Import from File
|
||||
Import via NFC
|
||||
Import via NFC [MAYBE]
|
||||
Export XPUB
|
||||
Create Airgapped
|
||||
Trust PSBT?
|
||||
@ -170,6 +171,21 @@
|
||||
Start HSM Mode [IF HSM POLICY]
|
||||
Address Explorer
|
||||
Type Passwords
|
||||
Seed Vault
|
||||
(none saved yet)
|
||||
Temporary Seed
|
||||
Generate Words
|
||||
12 Words
|
||||
24 Words
|
||||
12 Word Dice Roll
|
||||
24 Word Dice Roll
|
||||
Import Words
|
||||
12 Words
|
||||
18 Words
|
||||
24 Words
|
||||
Import via NFC
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
Secure Logout
|
||||
Advanced/Tools
|
||||
Backup
|
||||
@ -179,9 +195,10 @@
|
||||
Clone Coldcard
|
||||
Export Wallet
|
||||
Bitcoin Core
|
||||
Sparrow Wallet
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Unchained Capital
|
||||
Unchained
|
||||
Lily Wallet
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
@ -204,9 +221,10 @@
|
||||
Backup System
|
||||
Export Wallet
|
||||
Bitcoin Core
|
||||
Sparrow Wallet
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Unchained Capital
|
||||
Unchained
|
||||
Lily Wallet
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
@ -220,24 +238,25 @@
|
||||
Current XFP
|
||||
Dump Summary
|
||||
Sign Text File
|
||||
Clone Coldcard
|
||||
Batch Sign PSBT
|
||||
List Files
|
||||
Verify Sig File
|
||||
NFC File Share [IF NFC ENABLED]
|
||||
Clone Coldcard
|
||||
Format SD Card
|
||||
Format RAM Disk [IF VIRTDISK ENABLED]
|
||||
Derive Seed B85
|
||||
View Identity
|
||||
Ephemeral Seed
|
||||
Temporary Seed
|
||||
Generate Words
|
||||
24 Words
|
||||
12 Words
|
||||
24 Word Dice Roll
|
||||
24 Words
|
||||
12 Word Dice Roll
|
||||
24 Word Dice Roll
|
||||
Import Words
|
||||
24 Words
|
||||
18 Words
|
||||
12 Words
|
||||
18 Words
|
||||
24 Words
|
||||
Import via NFC
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
@ -267,6 +286,9 @@
|
||||
Wipe LFS
|
||||
Warm Reset
|
||||
Restore Txt Bkup
|
||||
Seed Vault
|
||||
Default Off
|
||||
Enable
|
||||
Perform Selftest
|
||||
Set High-Water
|
||||
Wipe HSM Policy [IF HSM POLICY]
|
||||
@ -283,7 +305,6 @@
|
||||
Settings
|
||||
Login Settings
|
||||
Change Main PIN
|
||||
PIN Options
|
||||
Trick PINs
|
||||
Add New Trick
|
||||
Add If Wrong
|
||||
@ -293,21 +314,23 @@
|
||||
Kill Key
|
||||
Login Countdown
|
||||
Disabled
|
||||
5 minutes
|
||||
15 minutes
|
||||
30 minutes
|
||||
4 hours
|
||||
1 hour
|
||||
5 minutes
|
||||
2 hours
|
||||
4 hours
|
||||
8 hours
|
||||
12 hours
|
||||
2 hours
|
||||
3 days
|
||||
48 hours
|
||||
1 week
|
||||
24 hours
|
||||
48 hours
|
||||
3 days
|
||||
1 week
|
||||
28 days later
|
||||
MicroSD 2FA [MAYBE]
|
||||
Add Card
|
||||
Check Card
|
||||
Remove Card #1
|
||||
Test Login Now
|
||||
Hardware On/Off
|
||||
USB Port
|
||||
@ -332,7 +355,7 @@
|
||||
Bitcoin Core
|
||||
Electrum Wallet
|
||||
Import from File
|
||||
Import via NFC
|
||||
Import via NFC [MAYBE]
|
||||
Export XPUB
|
||||
Create Airgapped
|
||||
Trust PSBT?
|
||||
|
||||
27
docs/miniscript.md
Normal file
27
docs/miniscript.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Miniscript
|
||||
|
||||
**COLDCARD<sup>®</sup>** Mk4 experimental `EDGE` versions
|
||||
support Miniscript and MiniTapscript.
|
||||
|
||||
## Import/Export
|
||||
|
||||
* `Settings` -> `Miniscript` -> `Import from file`
|
||||
* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) allowed for import
|
||||
* `Settings` -> `Miniscript` -> `<name>` -> `Descriptors`
|
||||
* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) are exported
|
||||
* export extended keys to participate in miniscript:
|
||||
* `Advanced/Tools` -> `Export Wallet` -> `Generic JSON`
|
||||
* `Settings` -> `Multisig Wallets` -> `Export XPUB`
|
||||
|
||||
## Address Explorer
|
||||
|
||||
Same as with basic multisig. After miniscript wallet is imported,
|
||||
item with `<name>` is added to `Address Explorer` menu.
|
||||
|
||||
|
||||
## Limitations
|
||||
* no duplicate keys in miniscript (at least change indexes in subderivation has to be different)
|
||||
* subderivation may be omitted during the import - default `<0;1>/*` is implied
|
||||
* only keys with key origin info `[xfp/p/a/t/h]xpub`
|
||||
* maximum number of keys allowed in segwit v0 miniscript is 20
|
||||
* check MiniTapscript limitations in `docs/taproot.md`
|
||||
190
docs/notes-on-repro.md
Normal file
190
docs/notes-on-repro.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Notes on Reproducible Builds
|
||||
|
||||
The following document aims to breakdown how reproducibility is verified in the `make repro` build step.
|
||||
|
||||
## stm32/shared.mk
|
||||
|
||||
The entrypoint makefile for repro builds.
|
||||
|
||||
### repro
|
||||
|
||||
The `repro` command in `shared.mk` is the first step in the repro build process, which triggers a docker build and run process.
|
||||
|
||||
```makefile
|
||||
repro:
|
||||
docker build -t coldcard-build - < dockerfile.build
|
||||
(cd ..; docker run $(DOCK_RUN_ARGS) sh src/stm32/repro-build.sh $(VERSION_STRING) $(MK_NUM))
|
||||
```
|
||||
|
||||
Below are interesting sections from the docker logs that give an idea as to what is going on in build process:
|
||||
|
||||
```stdout
|
||||
+ mkdir /tmp/checkout
|
||||
+ mount -t tmpfs tmpfs /tmp/checkout
|
||||
|
||||
...
|
||||
```
|
||||
We will pull the release from coldcard.com into the `/tmp/checkout` directory.
|
||||
|
||||
```
|
||||
+ git clone /work/src/.git firmware
|
||||
|
||||
...
|
||||
|
||||
+ cd firmware/external
|
||||
+ git submodule update --init
|
||||
|
||||
...
|
||||
|
||||
Successfully installed signit-1.0
|
||||
|
||||
...
|
||||
|
||||
+ cd ../stm32
|
||||
+ cd ../releases
|
||||
+ '[' -f '*-v5.0.7-mk4-coldcard.dfu' ]
|
||||
+ dd 'bs=66' 'skip=1'
|
||||
+ grep -F v5.0.7-mk4-coldcard.dfu signatures.txt
|
||||
0+1 records in
|
||||
0+1 records out
|
||||
+ PUBLISHED_BIN=2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
|
||||
+ '[' -z 2022-10-05T1724-v5.0.7-mk4-coldcard.dfu ]
|
||||
+ wget -S https://coldcard.com/downloads/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
|
||||
|
||||
...
|
||||
|
||||
'2022-10-05T1724-v5.0.7-mk4-coldcard.dfu' saved
|
||||
|
||||
...
|
||||
|
||||
+ PUBLISHED_BIN=/tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
|
||||
|
||||
...
|
||||
|
||||
+ make -f MK4-Makefile setup
|
||||
|
||||
...
|
||||
|
||||
+ make -f MK4-Makefile firmware-signed.bin firmware-signed.dfu production.bin dev.dfu firmware.lss firmware.elf
|
||||
|
||||
...
|
||||
|
||||
signit sign -b l-port/build-COLDCARD_MK4 -m 4 5.0.7 -o firmware-signed.bin
|
||||
|
||||
...
|
||||
|
||||
signit sign -m 4 5.0.7 -r firmware-signed.bin -k 1 -o production.bin
|
||||
You don't have that key (1), so using key zero instead!
|
||||
...
|
||||
|
||||
cd ../external/micropython/ports/stm32 && make BOARD=COLDCARD_MK4 -j 4 EXCLUDE_NGU_TESTS=1 DEBUG_BUILD=0
|
||||
|
||||
...
|
||||
|
||||
../external/micropython/tools/dfu.py -b 0x08020000:dev.bin dev.dfu
|
||||
arm-none-eabi-objdump -h -S l-port/build-COLDCARD_MK4/firmware.elf > firmware.lss
|
||||
cp l-port/build-COLDCARD_MK4/firmware.elf .
|
||||
+ '[' /tmp/checkout/firmware/stm32 '!=' /work/src/stm32 ]
|
||||
+ rsync -av --ignore-missing-args firmware-signed.bin firmware-signed.dfu production.bin dev.dfu firmware.lss firmware.elf /work/built
|
||||
sending incremental file list
|
||||
dev.dfu
|
||||
firmware-signed.bin
|
||||
firmware-signed.dfu
|
||||
firmware.elf
|
||||
firmware.lss
|
||||
production.bin
|
||||
|
||||
...
|
||||
|
||||
+ make -f MK4-Makefile 'PUBLISHED_BIN=/tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu' check-repro
|
||||
|
||||
...
|
||||
|
||||
Comparing against: /tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
|
||||
test -n "/tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu" -a -f /tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu
|
||||
rm -f -f check-fw.bin check-bootrom.bin
|
||||
signit split /tmp/checkout/firmware/releases/2022-10-05T1724-v5.0.7-mk4-coldcard.dfu check-fw.bin check-bootrom.bin
|
||||
start 293 for 870400 bytes: Firmware => check-fw.bin
|
||||
start 870701 for 114688 bytes: Bootrom => check-bootrom.bin
|
||||
signit check check-fw.bin
|
||||
magic_value: 0xcc001234
|
||||
timestamp: 2022-10-05 17:24:55 UTC
|
||||
version_string: 5.0.7
|
||||
pubkey_num: 1
|
||||
firmware_length: 870400
|
||||
install_flags: 0x0 =>
|
||||
hw_compat: 0x8 => Mk4
|
||||
best_ts: b'\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
future: 0000000000000000 ... 0000000000000000
|
||||
signature: 293948e7ce4a3555 ... 766437aa65d3e88a
|
||||
sha256^2: 7f3a7c5f794ce72f68280447cddc837fa62245fdf4b795822127624f8775dca2
|
||||
ECDSA Signature: CORRECT
|
||||
signit check firmware-signed.bin
|
||||
magic_value: 0xcc001234
|
||||
timestamp: 2022-10-24 13:33:16 UTC
|
||||
version_string: 5.0.7
|
||||
pubkey_num: 0
|
||||
firmware_length: 870400
|
||||
install_flags: 0x0 =>
|
||||
hw_compat: 0x8 => Mk4
|
||||
best_ts: b'\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
future: 0000000000000000 ... 0000000000000000
|
||||
signature: deb643d0a140d89e ... c544f09cd80fa65c
|
||||
sha256^2: a46ddd6e599a49a573bf76054f438c9efe1ee031bfae74a00b0e7bbe76f516c3
|
||||
ECDSA Signature: CORRECT
|
||||
hexdump -C firmware-signed.bin | sed -e 's/^00003f[89abcdef]0 .*/(firmware signature here)/' > repro-got.txt
|
||||
hexdump -C check-fw.bin | sed -e 's/^00003f[89abcdef]0 .*/(firmware signature here)/' > repro-want.txt
|
||||
diff repro-got.txt repro-want.txt
|
||||
|
||||
SUCCESS.
|
||||
|
||||
You have built a bit-for-bit identical copy of Coldcard firmware for v5.0.7
|
||||
```
|
||||
|
||||
## check-repro
|
||||
|
||||
The `check-repro` section of the makefile contains the steps required to verify that the build artifacts are infact a bit-for-bit match to the release candidates.
|
||||
|
||||
```makefile
|
||||
check-repro: TRIM_SIG = sed -e 's/^00003f[89abcdef]0 .*/(firmware signature here)/'
|
||||
check-repro: firmware-signed.bin
|
||||
ifeq ($(PUBLISHED_BIN),)
|
||||
@echo ""
|
||||
@echo "Need published binary for: $(VERSION_STRING)"
|
||||
@echo ""
|
||||
@echo "Copy it into ../releases"
|
||||
@echo ""
|
||||
else
|
||||
@echo Comparing against: $(PUBLISHED_BIN)
|
||||
test -n "$(PUBLISHED_BIN)" -a -f $(PUBLISHED_BIN)
|
||||
$(RM) -f check-fw.bin check-bootrom.bin
|
||||
$(SIGNIT) split $(PUBLISHED_BIN) check-fw.bin check-bootrom.bin
|
||||
$(SIGNIT) check check-fw.bin
|
||||
$(SIGNIT) check firmware-signed.bin
|
||||
hexdump -C firmware-signed.bin | $(TRIM_SIG) > repro-got.txt
|
||||
hexdump -C check-fw.bin | $(TRIM_SIG) > repro-want.txt
|
||||
diff repro-got.txt repro-want.txt
|
||||
@echo ""
|
||||
@echo "SUCCESS. "
|
||||
@echo ""
|
||||
@echo "You have built a bit-for-bit identical copy of Coldcard firmware for v$(VERSION_STRING)"
|
||||
endif
|
||||
```
|
||||
|
||||
To summarize `check-repro`:
|
||||
|
||||
- At the final `check-repro` step, we have a locally built `firmware-signed.bin` and we want to check that it matches the binary release provided by Coinkite.
|
||||
|
||||
- This step verifies the signature of the binary is valid, using either the Coinkite key factory key or the "debug" key zero which is public.
|
||||
|
||||
- An identical checksum match will not be possible as is, since there is signature data embedded into into the binary, which must be removed.
|
||||
|
||||
- The specific release of the version that is being built is fetched, and placed it under /tmp/checkout/firmware/releases/*.dfu
|
||||
|
||||
- `split` (cli/signit.py: Line 153-175) is run against the release `*.dfu` resulting in a `check-fw.bin` and `check-bootrom.bin`. "This splits the DFU file into the two parts it contains: the main firmware (COLDCARD application) and the boot loader code."
|
||||
|
||||
- `check` (cli/signit.py: Line 176-241) is run against each the release `check-fw.bin` and our built `firmware-signed.bin`.
|
||||
|
||||
- a hexdump is taken of each the release `check-fw.bin` and our built `firmware-signed.bin` piped through $TRIM_SIG which removes 64 bytes of signature data and subsitutes it with a common string.
|
||||
|
||||
- Finally the diff of the two hexdumps are compared to prove reproducibility.
|
||||
@ -81,7 +81,7 @@ of your duress PIN.
|
||||
The attackers could tell when the brick-me PIN has worked, but when
|
||||
the brick-me PIN works, the Coldcard will immediately use it to
|
||||
destroy the main pairing secret. This renders the security element
|
||||
useless. This happens in about 50 milliseconds and is done long before
|
||||
useless. This happens in about 50 milliseconds and is done long
|
||||
before anyone gets an on-screen confirmation that it worked.
|
||||
|
||||
There is little time to interrupt this or jam the bus to stop it.
|
||||
|
||||
66
docs/taproot.md
Normal file
66
docs/taproot.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Taproot
|
||||
|
||||
**COLDCARD<sup>®</sup>** Mk4 experimental `EDGE` versions
|
||||
support Schnorr signatures ([BIP-0340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)),
|
||||
Taproot ([BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki))
|
||||
and Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support.
|
||||
|
||||
## Output script (a.k.a address) generation
|
||||
|
||||
If the spending conditions do not require a script path, the output key MUST commit to an unspendable script path.
|
||||
`Q = P + int(hashTapTweak(bytes(P)))G` a.k.a internal key MUST be tweaked by `TapTweak` tagged hash of itself. If
|
||||
the spending conditions require script path, internal key MUST be tweaked by `TapTweak` tagged hash of tree merkle root.
|
||||
|
||||
Addresses in `Address Explorer` for `p2tr` are generated with above-mentioned methods. Outputs `scriptPubkeys` in PSBT
|
||||
MUST be generated with above-mentoned methods to be considered change.
|
||||
|
||||
## Allowed descriptors
|
||||
|
||||
1. Single signature wallet without script path: `tr(key)`
|
||||
2. Tapscript multisig with internal key and up to 8 leaf scripts:
|
||||
* `tr(internal_key, sortedmulti_a(2,@0,@1))`
|
||||
* `tr(internal_key, pk(@0))`
|
||||
* `tr(internal_key, {sortedmulti_a(2,@0,@1),pk(@2)})`
|
||||
* `tr(internal_key, {or_d(pk(@0),and_v(v:pkh(@1),older(1000))),pk(@2)})`
|
||||
|
||||
## Provably unspendable internal key
|
||||
|
||||
There are few methods to provide/generate provably unspendable internal key, if users wish to only use script path
|
||||
for multisig.
|
||||
|
||||
1. use provably unspendable internal key H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs). This way is leaking the information that key path spending is not possible and therefore not recommended privacy-wise.
|
||||
|
||||
`tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0, sortedmulti_a(2,@0,@1))`
|
||||
|
||||
2. use COLDCARD specific placeholder `@` to let HWW pick a fresh integer r in the range 0...n-1 uniformly at random and use `H + rG` as internal key. COLDCARD will not store r and therefore user is not able to prove to other party how the key was generated and whether it is actually unspendable.
|
||||
|
||||
`tr(r=@, sortedmulti_a(MofN))`
|
||||
|
||||
3. pick a fresh integer r in the range 0...n-1 uniformly at random yourself and provide that in the descriptor. COLDCARD generates internal key with `H + rG`. It is possible to prove to other party that this internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then reconstruct how the internal key was created.
|
||||
|
||||
`tr(r=77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76, sortedmulti_a(2,@0,@1))`
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
### Tapscript Limitations
|
||||
|
||||
In current version only `TREE` of max depth 4 is allowed (max 8 leaf script allowed).
|
||||
Taproot single leaf multisig has artificial limit of max 32 signers (M=N=32).
|
||||
Number of keys in taptree is limited to 32.
|
||||
|
||||
If Coldcard can sign by both key path and script path - key path has precedence.
|
||||
|
||||
### PSBT Requirements
|
||||
|
||||
PSBT provider MUST provide following Taproot specific input fields in PSBT:
|
||||
1. `PSBT_IN_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
|
||||
2. `PSBT_IN_TAP_INTERNAL_KEY` MUST match internal key provided in `PSBT_IN_TAP_BIP32_DERIVATION`
|
||||
3. `PSBT_IN_TAP_MERKLE_ROOT` MUST be empty if there is no script path. Otherwise it MUST match what Coldcard can calculate from registered descriptor.
|
||||
4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path. Currently MUST be of length 1 (only one script allowed)
|
||||
|
||||
PSBT provider MUST provide following Taproot specific output fields in PSBT:
|
||||
1. `PSBT_OUT_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
|
||||
2. `PSBT_OUT_TAP_INTERNAL_KEY` must match internal key provided in `PSBT_OUT_TAP_BIP32_DERIVATION`
|
||||
3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined. Currently only one script is allowed.
|
||||
|
||||
122
docs/temporary-seeds.md
Normal file
122
docs/temporary-seeds.md
Normal file
@ -0,0 +1,122 @@
|
||||
# Temporary Seeds
|
||||
|
||||
|
||||
[_(new in v5.0.7, requires Mk4)_](upgrade.md)
|
||||
|
||||
|
||||
Temporary seed (renamed in `5.2.0` from Ephemeral seed) is a temporary secret completely separate
|
||||
from the master seed, typically held in **COLDCARD<sup>®</sup>** RAM and
|
||||
not persisted between reboots in the Secure Element.
|
||||
Temporary seeds *completely* defeat the design
|
||||
of Coldcard's security model, based on secure elements.
|
||||
Enable the `Seed Vault` feature to store these secrets longer-term.
|
||||
Read more about `Seed Vault` feature below.
|
||||
|
||||
|
||||
!!! warning "Make sure you know what you're doing!"
|
||||
|
||||
This feature is intended for those one-off signings, like recovering
|
||||
a lost seed from some other system or importing some seed as a
|
||||
balance check. We do not recommend handing unencrypted seed material
|
||||
on a regular basis!
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
* if temporary seed is already in use, first home menu option `[<xfp>]` is visible with fingerprint of temporary master secret
|
||||
* go to `Advanced/Tools > Temporary Seed`
|
||||
|
||||
* temporary seed words can be Generated with TRNG
|
||||
- `Advanced/Tools > Temporary Seed > Generate Words`
|
||||
|
||||
* temporary seed words can be imported
|
||||
- `Advanced/Tools > Temporary Seed > Import Words`
|
||||
|
||||
* importing extended private keys
|
||||
- `Advanced/Tools > Temporary Seed > Import XPRV`
|
||||
- `Advanced/Tools > Temporary Seed > Tapsigner Backup`
|
||||
|
||||
* temporary seed can be activated from BIP-85 derived secrets - go to `Advanced/Tools > Derive Seed B85` and pick types of secret. Keep in mind that only word based and xprv based secrets can be used as temporary seed.
|
||||
- `12 words`
|
||||
- `18 words`
|
||||
- `24 words`
|
||||
- `XPRV (BIP-32)`
|
||||
- pick derivation `Index` in next prompt, or just press OK for index 0
|
||||
- Press (2) in next prompt to activate derived secret as a temporary seed
|
||||
|
||||
* temporary seed can be activated from Duress Wallet
|
||||
- go to `Settings -> Login Settings -> Trick Pins`
|
||||
- add new Duress Wallet trick pin and save it
|
||||
- choose newly created trick pin in trick pins menu and use `Activate Wallet` option
|
||||
|
||||
* temporary seed can be obtained from `SeedXOR`
|
||||
- go to `Advanced/Tools -> Danger Zone -> Seed Functions -> SeedXOR`
|
||||
- pick `Restore Seed XOR` option and provide all XOR parts
|
||||
- Press (2) to activate restored seed as temporary seed
|
||||
|
||||
* BIP-39 passphrase is from version `5.2.0` handled internally as temporary seed
|
||||
|
||||
|
||||
Ability to generate and use **Temporary seed** is available on Coldcard when:
|
||||
|
||||
1. no PIN chosen and no secret chosen (newly unpacked Coldcard)
|
||||
2. PIN set up but no secret chosen yet
|
||||
3. with both PIN and secret already picked
|
||||
|
||||
|
||||
# Restore Master
|
||||
|
||||
[_(new in v5.2.0, requires Mk4)_](upgrade.md)
|
||||
|
||||
From version `5.2.0` users no longer need to reboot COLDCARD to return
|
||||
to their "master seed" (one stored in SE2). Once COLDCARD has temporary
|
||||
seed active, first item in home menu is `[xfp]` and is a clone of `Ready To Sign`.
|
||||
Last item in home menu is `Restore Master`.
|
||||
|
||||
`Restore Master` offers two options. First, if user presses OK, COLDCARD wipes temporary seed settings
|
||||
and switches back to master seed and its settings.
|
||||
If user presses (1) temporary seed settings are preserved for later use and COLDCARD only switches
|
||||
back to master seed and its settings.
|
||||
|
||||
If current temporary seed is also saved in Seed Vault, option to wipe settings is not available.
|
||||
Seed Vault entries can only be deleted in Seed Vault menu.
|
||||
|
||||
|
||||
# Seed Vault
|
||||
|
||||
[_(new in v5.2.0, requires Mk4)_](upgrade.md)
|
||||
|
||||
Seed Vault adds the ability to store multiple temporary secrets into encrypted settings for simple
|
||||
recall and later use (AES-256-CTR encrypted with your master seed's key).
|
||||
Users can capture and hold master secret from any temporary seed source, including: TRNG, Dice Rolls,
|
||||
SeedXOR, TAPSIGNER backups, BIP-85 derived values, BIP-39 passphrase wallets.
|
||||
|
||||
## Enable Seed Vault
|
||||
|
||||
Enable this functionality in `Advanced/Tools -> Danger Zone -> Seed Vault -> Enable`.
|
||||
Once seed vault is enabled new menu item is visible in home menu `Seed Vault`.
|
||||
To disable Seed Vault user needs to remove all entries from Seed Vault first.
|
||||
|
||||
|
||||
## Add Seed to Vault
|
||||
|
||||
After `Seed Vault` is enabled, users will see a new prompt, after
|
||||
creation of temporary seed, asking whether to save this temporary
|
||||
seed to Seed Vault. Press (1) to save or any other key to ignore.
|
||||
|
||||
If option to save was chosen, confirmation prompt is shown - `Saved to seed vault.`
|
||||
|
||||
|
||||
## Seed Vault menu
|
||||
|
||||
* if Seed Vault is empty `(none saved yet)` is the first menu item followed by shortcut to `Temporary Seed` menu.
|
||||
* if not empty, saved seeds are listed in menu as `[xfp]`
|
||||
* if current active temporary seed is stored in Seed Vault - it has checkmark next to it
|
||||
* if temporary seed is active - last menu item of Seed Vault menu is `Restore Master`
|
||||
|
||||
## Seed Vault entry submenu
|
||||
|
||||
1. by default `[xfp]` but can be renamed to allow user labeling and leads to additional information about the seed
|
||||
2. `Use This Seed` allows to switch to the saved temporary seed. If it is already active `In Use` is shown instead.
|
||||
3. `Rename` allows to change 1. menu item to something personalized to user (limited to 40 characters)
|
||||
4. `Delete` allows to remove temporary seed from Seed Vault and optionally to completely wipe its settings.
|
||||
2
external/README.md
vendored
2
external/README.md
vendored
@ -1,7 +1,7 @@
|
||||
|
||||
## Background on Submodules
|
||||
|
||||
This project uses many submodules, and to build the final produts, you will
|
||||
This project uses many submodules, and to build the final products, you will
|
||||
have to get all the submodules into place and build them in appropriate orders.
|
||||
|
||||
A good resource, from an unrelated project, is:
|
||||
|
||||
2
external/ckcc-protocol
vendored
2
external/ckcc-protocol
vendored
@ -1 +1 @@
|
||||
Subproject commit 2216e4db0e2dd17ca16bd8772d09c69f6296f6df
|
||||
Subproject commit 11c711e929a090ec29ccd2a05d094aa3d2cbc113
|
||||
2
external/libngu
vendored
2
external/libngu
vendored
@ -1 +1 @@
|
||||
Subproject commit 356b9137cf7ddf5de66ec4cdc0a4d757b2e42790
|
||||
Subproject commit 7bdb03864630ff68b143e3e5b4521ca3ef6588cc
|
||||
@ -9,7 +9,6 @@ font_files = {
|
||||
}
|
||||
|
||||
# test with:
|
||||
#
|
||||
# ./build.py build --portable && ./testit.py --msg "hello→world←\n↳this\n•Bullet\n•Text" -f small
|
||||
#
|
||||
special_chars = dict(small=[
|
||||
@ -65,5 +64,14 @@ special_chars = dict(small=[
|
||||
|
||||
xxxxx
|
||||
'''),
|
||||
('⋯', dict(y=0), '''\
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
x x x x x
|
||||
'''),
|
||||
|
||||
])
|
||||
|
||||
@ -1,9 +1,100 @@
|
||||
## 5.1.3 - 2023-XX-XX
|
||||
## 5.2.3 - 2024-XX-XX
|
||||
|
||||
- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot
|
||||
- Bugfix: Very obscure bug in low level code could cause txid to be miscalculated
|
||||
if all the conditions occured just right
|
||||
|
||||
## 5.2.2 - 2023-12-21
|
||||
|
||||
- Bugfix: Re-enable `Lock Down Seed` feature which was disabled by accident
|
||||
|
||||
## 5.2.1 - 2023-12-19
|
||||
|
||||
- New Feature: Temporary Seed import from a COLDCARD encrypted backup.
|
||||
- New Feature: Export seed words in SeedQR format (on screen QR).
|
||||
- New Feature: Provide user with info about transaction level timelocks
|
||||
([nLockTime](https://en.bitcoin.it/wiki/NLockTime),
|
||||
[nSequence](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki))
|
||||
when signing.
|
||||
- Enhancement: New submenu for saved BIP-39 Passphrases allowing delete of saved entries.
|
||||
- Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu.
|
||||
If current seed is temporary and not saved yet, `Add current tmp` menu item is
|
||||
shown in Seed Vault menu.
|
||||
- Enhancement: Speed up opening `Passphrase` menu when MicroSD card is available, by
|
||||
deferring card read (and decryption) until after `Restore Saved` menu item is selected.
|
||||
- Enhancement: `12 Words` menu option preferred on the top of the menu in all the seed menus
|
||||
(rather than 24 words).
|
||||
- Enhancement: Allow passphrase via USB if passphrase already set - operates on master seed.
|
||||
- Enhancement: Improve BIP39 Passphrase UX when temporary seed is active and applicable.
|
||||
- Enhancement: Continuation of removal of obsolete Mk2/Mk3 code-paths from master branch.
|
||||
- Bugfix: Confusing first-time UX replaced with simple welcome screen.
|
||||
- Bugfix: One instant retry on SE1 communication failures
|
||||
- Bugfix: Handle any failures in slot reading when loading settings
|
||||
- Bugfix: Add missing "First Time UX" for extended key import as master seed
|
||||
- Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active (it cannot work)
|
||||
- Bugfix: Disallow using master seed as temporary seed
|
||||
- Bugfix: Do not allow `APPLY` of empty BIP-39 passphrase. Use "Restore Master" instead.
|
||||
- Bugfix: Fix yikes in `Clone Coldcard` (thanks to AnchorWatch)
|
||||
|
||||
## 5.2.0 - 2023-10-10
|
||||
|
||||
- New Feature: Seed Vault. Store multiple temporary secrets into encrypted settings for simple
|
||||
recall and later use (AES-256-CTR encrypted by key based on the seed).
|
||||
Enable this functionality in `Advanced/Tools -> Danger Zone -> Seed Vault -> Enable`.
|
||||
Use stored seeds from Seed Vault with top-level `Seed Vault` menu choice (once enabled).
|
||||
Can capture and hold master secret from any temporary (ephemeral) seed source,
|
||||
including: TRNG, Dice Rolls, SeedXOR, TAPSIGNER backups, Duress Wallets, BIP-85 derived
|
||||
values, BIP-39 passphrase wallets.
|
||||
- New Feature: PSBTv2 support added! Enables new PSBT workflows and applications.
|
||||
- New Feature: `Lock Down Seed` now works with every temporary secret (not just BIP39 passphrase)
|
||||
- New Feature: BIP-39 Passphrase can now be added to any words-based temporary seed.
|
||||
- New Feature: Add ability to back-up BIP39 Passphrase wallet (with passphrase encoded).
|
||||
- New Feature: Return to main secret from temporary without need to reboot the device.
|
||||
- Enhancement: Shortcut to `Batch Sign PSBT` via `Ready To Sign` -> `Press (9)`
|
||||
- Enhancement: Waste less storage space by removing old plausible deniability code
|
||||
which was only needed for Mk1 - Mk3 where SPI flash was an external chip.
|
||||
- Enhancement: Remove obsolete Mk2/Mk3 code-paths from master branch.
|
||||
- Enhancement: BIP39 Passphrase is now internally handled as an temporary secret.
|
||||
Ability to see BIP-39 Passphrase after wallet is active via `View Seed Words`
|
||||
was removed as a consequence of this change. Benefit: passphrase no longer held
|
||||
in memory while in operation.
|
||||
- Enhancement: Showing secrets now also displays extended private key (XPRV) for BIP-39
|
||||
passphrase wallets.
|
||||
- Enhancement: Increase number of slots in settings memory from 64 to 100.
|
||||
- Bugfix: Fixed off by one bug in `Trick Pins -> Login Countdown` menu.
|
||||
- Nomenclature: "Ephemeral Seed" will now be called "Temporary Seed".
|
||||
|
||||
## 5.1.4 - 2023-09-08
|
||||
|
||||
- Bugfix: Most users would see a red light after upgrade to 5.1.3 from 5.1.2. Fixed.
|
||||
|
||||
## 5.1.3 - 2023-09-07
|
||||
|
||||
- New Feature: Batch sign multiple PSBT files. `Advanced/Tools -> File Management -> Batch Sign PSBT`
|
||||
- Enhancement: `Sparrow Wallet` added as an individual export option (same file contents)
|
||||
- Enhancement: change key origin information export format in multisig `addresses.csv` to match
|
||||
[BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions)
|
||||
was `(m=0F056943)/m/48'/1'/0'/2'/0/0` now `[0F056943/48'/1'/0'/2'/0/0]`
|
||||
- Enhancement: Address explorer UX cosmetics, now with arrows and dots.
|
||||
- Enhancement: Linked settings (multisig, trick pins, backup password, hsm users and utxo cache)
|
||||
separation for new main secret.
|
||||
- Rename `Unchained Capital` to `Unchained`
|
||||
- Bugfix: Correct `scriptPubkey` parsing for segwit v1-v16
|
||||
- Bugfix: Do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT.
|
||||
- Bugfix: Remove label from Bitcoin Core `importdescriptors` export as it is no longer supported
|
||||
with ranged descriptors in version `24.1` of Core.
|
||||
- Bugfix: Empty number during BIP-39 passphrase entry could cause crash.
|
||||
- Bugfix: Signing with BIP39 Passphrase showed master fingerprint as integer. Fixed to show hex.
|
||||
- Bugfix: Fixed inability to generate paper wallet without secrets
|
||||
- Bugfix: Activating trick pin duress wallet copied multisig settings from main wallet
|
||||
- Bugfix: SD2FA setting is cleared when seed is wiped after failed login due to policy SD2FA enforce.
|
||||
Prevents infinite seed wipe loop when restoring backup after 2FA MicroSD lost or damaged.
|
||||
SD2FA is not backed up and also not restored from older backups. If SD2FA is set up,
|
||||
it will not survive restore of backup.
|
||||
- Bugfix: Terms only presented if main PIN was not chosen already.
|
||||
- Bugfix: Preserve defined order of Login Countdown settings list.
|
||||
- Bugfix: Remove unsupported trick pin option `Look Blank` from `if wrong` (not supported by bootrom).
|
||||
|
||||
- Enhancement: change Key Origin Information export format in multisig `addresses.csv` according to [BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions)
|
||||
`(m=0F056943)/m/48'/1'/0'/2'/0/0` --> `[0F056943/48'/1'/0'/2'/0/0]`
|
||||
- Bugfix: correct `scrptPubkey` parsing for segwit v1-v16
|
||||
- Bugfix: do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT
|
||||
|
||||
## 5.1.2 - 2023-04-07
|
||||
|
||||
|
||||
54
releases/EdgeChangeLog.md
Normal file
54
releases/EdgeChangeLog.md
Normal file
@ -0,0 +1,54 @@
|
||||
## Warning: Edge Version
|
||||
|
||||
```diff
|
||||
- This preview version of firmware has not yet been qualified
|
||||
- and tested to the same standard as normal Coinkite products.
|
||||
- It is recommended only for developers and early adopters
|
||||
- for experimental use. DO NOT use for large Bitcoin amounts.
|
||||
```
|
||||
|
||||
## 6.2.2X - 2024-01-18
|
||||
|
||||
- New Feature: Miniscript [USB interface](https://github.com/Coldcard/ckcc-protocol/blob/master/README.md#miniscript)
|
||||
- New Feature: Named miniscript imports. Wrap descriptor in json
|
||||
`{"name:"n0", "desc":"<descriptor>"}` with `name` key to use this name instead of the
|
||||
filename. Mostly usefull for USB and NFC imports that have no file, in which case name
|
||||
was created from descriptor checksum.
|
||||
- Enhancement: Allow keys with same origin, differentiated only by change index derivation
|
||||
in miniscript descriptor.
|
||||
- Enhancement: HSM `wallet` rule enabled for miniscript
|
||||
- Enhancement: Add `msas` in to the `share_addrs` HSM [rule](https://coldcard.com/docs/hsm/rules/)
|
||||
to be able to check miniscript addresses in HSM mode.
|
||||
- Enhancement: HW Accelerated AES CTR for BSMS and passphrase saver
|
||||
- Bugfix: Do not allow to import duplicate miniscript
|
||||
wallets (thanks to [AnchorWatch](https://www.anchorwatch.com/))
|
||||
- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot
|
||||
|
||||
## 6.2.1X - 2023-10-26
|
||||
|
||||
- New Feature: Enroll Miniscript wallet via USB (requires ckcc `v1.4.0`)
|
||||
- New Feature: Temporary Seed from COLDCARD encrypted backup
|
||||
- Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu.
|
||||
If current active temporary seed is not saved yet, `Add current tmp` menu item is
|
||||
present in Seed Vault menu.
|
||||
- Reorg: `12 Words` menu option preferred on the top of the menu in all the seed menus
|
||||
- Enhancement: Mainnet/Testnet separation. Only show wallets for current active chain.
|
||||
- contains all the changes from the newest stable `5.2.0-mk4` firmware
|
||||
|
||||
## 6.1.0X - 2023-06-20
|
||||
|
||||
- New Feature: Miniscript and MiniTapscript support (`docs/miniscript.md`)
|
||||
- Enhancement: Tapscript up to 8 leafs
|
||||
- Address explorer display refined slightly (cosmetic)
|
||||
|
||||
## 6.0.0X - 2023-05-12
|
||||
|
||||
- New Feature: Taproot keyspend & Tapscript multisig `sortedmulti_a` (tree depth = 0)
|
||||
- New Feature: Support BIP-0129 Bitcoin Secure Multisig Setup (BSMS).
|
||||
Both Coordinator and Signer roles are supported.
|
||||
- Enhancement: change Key Origin Information export format in multisig `addresses.csv` according to [BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions)
|
||||
`(m=0F056943)/m/48'/1'/0'/2'/0/0` --> `[0F056943/48'/1'/0'/2'/0/0]`
|
||||
- Bugfix: correct `scriptPubkey` parsing for segwit v1-v16
|
||||
- Bugfix: do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT
|
||||
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
## 4.1.9 - Jun 26, 2023
|
||||
|
||||
- Bugfix: QR codes could not be rendered in 4.1.8 release due to a regression.
|
||||
|
||||
## 4.1.8 - Jun 19, 2023
|
||||
|
||||
- Bugfix: "Validating..." screen would be shown twice in some cases. Improves signing performance.
|
||||
- Bugfix: Reproducible builds corrected.
|
||||
|
||||
## 4.1.7 - Nov 15, 2022
|
||||
|
||||
- Bugfix: Upgrades to 4.1.6 version using SD Card did not work due to an obscure alignment
|
||||
bug. USB upgrade did work. A workaround to this issue has been added for this release.
|
||||
|
||||
@ -2,45 +2,26 @@
|
||||
Hash: SHA256
|
||||
|
||||
715a3ec7a91d2366788b14a243e7343de875714b647d1e2bc906ecc5b752d8d9 README.md
|
||||
8f71336a78573ccbd19b782e4f6e5930a8f944517884fe02d26eb0f38bf5c3ac Mk3ChangeLog.md
|
||||
a0c4d0ac3881a36704f0b620a13c72704531f656ee29368d5aac87dc5f21c7a1 History.md
|
||||
b05ab997d09ebbd93a59b14b2be9574ab9a1facb176efcc3fee630ec562b5cf8 ChangeLog.md
|
||||
ffdd44e985d5a2bdc0de6b98d93038304f46f9c942ae4a5ac053135ebf49374f ChangeLog-mk3.md
|
||||
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
|
||||
0a7b4a32af7bfcc3ce079461752697d9da787a2e7d7a36c156f1368e20847816 EdgeChangeLog.md
|
||||
bf86232f94f40a3fef1e0e5e8dd1f184f7e2782606001a121f71a911c1eab95d ChangeLog.md
|
||||
a9d0b416c3cb4f122f2826283fce82bbc5fe4464817b601a3a5787b1f8aaba20 2024-01-18T1507-v6.2.2X-mk4-coldcard.dfu
|
||||
cc93209e800bc05386b5613969e62c27b9acd4388e3a922686525da90a505778 2024-01-18T1507-v6.2.2X-mk4-coldcard-factory.dfu
|
||||
f4457dc44d08cbed9517e6260aa7163ecc254457276d3cdb0c2611af0f49ba9b 2023-10-26T1343-v6.2.1X-mk4-coldcard.dfu
|
||||
1dcfb450f81883afe8f655239f06e238de7bae51e740cd4aa5ae6a0541772ad8 2023-10-26T1343-v6.2.1X-mk4-coldcard-factory.dfu
|
||||
489e161f686a0c631fc605054f8e7271208b16191b669174b8a58f5af28b0f4a 2023-06-20T1506-v6.1.0X-mk4-coldcard.dfu
|
||||
66c83c3f95fd3d0796b1e452d2e8ed8ac6a4abead53faf5ae793eceb6f7bbdb5 2023-06-20T1506-v6.1.0X-mk4-coldcard-factory.dfu
|
||||
2e8ed970f518a476d0b34752ecbad75bab246669aa65de8f43801364c6f5753e 2023-05-12T1316-v6.0.0X-mk4-coldcard.dfu
|
||||
8dd5ff029bb2b08c857604f0c9b5773931f6683ee331ecbc35d9ab4c460b745f 2023-05-12T1316-v6.0.0X-mk4-coldcard-factory.dfu
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmQwGu0ACgkQo6MbrVoq
|
||||
WxDgdggAnuubC/F0g53PNOFunqbRe9pCQPAvg47JjwZT9XQfzNL9DWqARZ4R2YZP
|
||||
AJPMlF4mXiNi1JJ+hJ5iwwLJaHwPWdaTFX1xr3M8tbIrgjIQ8i2UXPgFXfqlQcmt
|
||||
lPrYemYKXdojGvtjFMBrDQavWRwuGIK9Rc2pp+gtAfrji7vCevEBus39W9eHod61
|
||||
ZQKsyaJKDI3HqfaKTPaTyVVQYYQwSAoe9RVJxZSXoaW3kAxnfvKU2Xzy8B3cH3jE
|
||||
y22o82rnIdRNUZowk9YjzfvSK/NcGLpisJv7MjxCme04qDV5QbKG2gvTZ3p/ysn6
|
||||
0yrxUq2CuPHHky1j9bLkiEAlDke2AA==
|
||||
=qpFw
|
||||
iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmWpPsYACgkQo6MbrVoq
|
||||
WxBgpQf9EyG2cBaejQovqvDfywP0Mz7UA4gt0f6w7BO2cJ8wapfFC0rCv01Ya8l9
|
||||
87GLP9fqkKi34l8m+ujvG9OpQYCIwKStgS3J7Uz10aBx5P13Zl7ZxIQQ5pD7keZN
|
||||
tmowJKq5us2as15zs4CCqHqCsh7DWRHqpaGFdNoRjxQgTrJtVz32pJcqVN+IVIKO
|
||||
9O0OikiMRGaEdnqdyaO6feCrDbWknTjYAaW7fNO1+OrbBEubK5n1PFVGxJsY0gZf
|
||||
66TVKze0bxFyhYwueMWSi5/QV+pX6Rnt9twT8ebIl5Yq7O1UW5tgp9HYoQpZB47z
|
||||
VV+afwiGGF26hK6bYm/c/WfF7ikcnw==
|
||||
=70KC
|
||||
-----END PGP SIGNATURE-----
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,18 +7,16 @@
|
||||
import chains, stash
|
||||
from ux import ux_show_story, the_ux, ux_enter_bip32_index
|
||||
from menu import MenuSystem, MenuItem
|
||||
from public_constants import AFC_BECH32, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
|
||||
from public_constants import AFC_BECH32, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
|
||||
from multisig import MultisigWallet
|
||||
from miniscript import MiniScriptWallet
|
||||
from uasyncio import sleep_ms
|
||||
from ucollections import OrderedDict
|
||||
from uhashlib import sha256
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from glob import settings
|
||||
from auth import write_sig_file
|
||||
from utils import addr_fmt_label, truncate_address
|
||||
|
||||
def truncate_address(addr):
|
||||
# Truncates address to width of screen, replacing middle chars
|
||||
# - 16 chars screen width, so show 8 prefix, dash, and 7 of end of address
|
||||
return addr[0:8] + '-' + addr[-7:]
|
||||
|
||||
class KeypathMenu(MenuSystem):
|
||||
def __init__(self, path=None, nl=0):
|
||||
@ -27,14 +25,15 @@ class KeypathMenu(MenuSystem):
|
||||
if path is None:
|
||||
# Top level menu; useful shortcuts, and special case just "m"
|
||||
items = [
|
||||
MenuItem("m/..", f=self.deeper),
|
||||
MenuItem("m/49'/..", f=self.deeper),
|
||||
MenuItem("m/84'/..", f=self.deeper),
|
||||
MenuItem("m/44'/..", f=self.deeper),
|
||||
MenuItem("m/0/{idx}", menu=self.done),
|
||||
MenuItem("m/{idx}", menu=self.done),
|
||||
MenuItem("m", f=self.done),
|
||||
]
|
||||
MenuItem("m/..", f=self.deeper),
|
||||
MenuItem("m/44'/..", f=self.deeper),
|
||||
MenuItem("m/49'/..", f=self.deeper),
|
||||
MenuItem("m/84'/..", f=self.deeper),
|
||||
MenuItem("m/86'/..", f=self.deeper),
|
||||
MenuItem("m/0/{idx}", menu=self.done),
|
||||
MenuItem("m/{idx}", menu=self.done),
|
||||
MenuItem("m", f=self.done),
|
||||
]
|
||||
else:
|
||||
# drill down one layer: (nl) is the current leaf
|
||||
# - hardened choice first
|
||||
@ -69,7 +68,7 @@ class KeypathMenu(MenuSystem):
|
||||
dis.clear_rect(0, y, dis.WIDTH, 8)
|
||||
dis.text(-1, y+4, self.prefix, FontTiny, invert=False)
|
||||
|
||||
def done(self, _1, menu_idx, item):
|
||||
async def done(self, _1, menu_idx, item):
|
||||
final_path = item.arg or item.label
|
||||
self.chosen = menu_idx
|
||||
self.show()
|
||||
@ -86,7 +85,7 @@ class KeypathMenu(MenuSystem):
|
||||
|
||||
return PickAddrFmtMenu(final_path, top)
|
||||
|
||||
def deeper(self, _1, _2, item):
|
||||
async def deeper(self, _1, _2, item):
|
||||
val = item.arg or item.label
|
||||
assert val.endswith('/..')
|
||||
cpath = val[:-3]
|
||||
@ -97,9 +96,10 @@ class PickAddrFmtMenu(MenuSystem):
|
||||
def __init__(self, path, parent):
|
||||
self.parent = parent
|
||||
items = [
|
||||
MenuItem("Classic P2PKH", f=self.done, arg=(path, AF_CLASSIC)),
|
||||
MenuItem("Segwit P2WPKH", f=self.done, arg=(path, AF_P2WPKH)),
|
||||
MenuItem("P2SH-P2WPKH", f=self.done, arg=(path, AF_P2WPKH_P2SH)),
|
||||
MenuItem(addr_fmt_label(AF_CLASSIC), f=self.done, arg=(path, AF_CLASSIC)),
|
||||
MenuItem(addr_fmt_label(AF_P2WPKH), f=self.done, arg=(path, AF_P2WPKH)),
|
||||
MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=self.done, arg=(path, AF_P2WPKH_P2SH)),
|
||||
MenuItem(addr_fmt_label(AF_P2TR), f=self.done, arg=(path, AF_P2TR)),
|
||||
]
|
||||
super().__init__(items)
|
||||
if path.startswith("m/84'"):
|
||||
@ -107,7 +107,7 @@ class PickAddrFmtMenu(MenuSystem):
|
||||
if path.startswith("m/49'"):
|
||||
self.goto_idx(2)
|
||||
|
||||
def done(self, _1, _2, item):
|
||||
async def done(self, _1, _2, item):
|
||||
the_ux.pop()
|
||||
await self.parent.got_custom_path(*item.arg)
|
||||
|
||||
@ -123,7 +123,7 @@ class ApplicationsMenu(MenuSystem):
|
||||
]
|
||||
super().__init__(items)
|
||||
|
||||
def done(self, _1, _2, item):
|
||||
async def done(self, _1, _2, item):
|
||||
path = item.arg[0]
|
||||
addr_fmt = item.arg[1]
|
||||
await self.parent.show_n_addresses(path, addr_fmt, None, n=10, allow_change=True)
|
||||
@ -179,8 +179,13 @@ class AddressListMenu(MenuSystem):
|
||||
|
||||
stash.blank_object(node)
|
||||
|
||||
items = [MenuItem(address, f=self.pick_single, arg=(path, addr_fmt))
|
||||
for i, (address, path, addr_fmt) in enumerate(choices)]
|
||||
items = []
|
||||
for i, (address, path, addr_fmt) in enumerate(choices):
|
||||
axi = address[-4:] # last 4 address characters
|
||||
items.append(MenuItem(addr_fmt_label(addr_fmt), f=self.pick_single,
|
||||
arg=(path, addr_fmt, axi)))
|
||||
items.append(MenuItem('↳'+address, f=self.pick_single,
|
||||
arg=(path, addr_fmt, axi)))
|
||||
|
||||
# some other choices
|
||||
if self.account_num == 0:
|
||||
@ -191,27 +196,36 @@ class AddressListMenu(MenuSystem):
|
||||
# if they have MS wallets, add those next
|
||||
for ms in MultisigWallet.iter_wallets():
|
||||
if not ms.addr_fmt: continue
|
||||
items.append(MenuItem(ms.name, f=self.pick_multisig, arg=ms))
|
||||
items.append(MenuItem(ms.name, f=self.pick_miniscript, arg=ms))
|
||||
|
||||
# if they have miniscript wallets, add those next
|
||||
for msc in MiniScriptWallet.iter_wallets():
|
||||
items.append(MenuItem(msc.name, f=self.pick_miniscript, arg=msc))
|
||||
else:
|
||||
items.append(MenuItem("Account: %d" % self.account_num, f=self.change_account))
|
||||
|
||||
self.goto_idx(settings.get('axi', 0)) # weak
|
||||
|
||||
self.replace_items(items)
|
||||
axi = settings.get('axi', 0)
|
||||
if isinstance(axi, str):
|
||||
ok = self.goto_label(axi)
|
||||
if not ok:
|
||||
self.goto_idx(0)
|
||||
else:
|
||||
self.goto_idx(axi)
|
||||
|
||||
async def change_account(self, *a):
|
||||
self.account_num = await ux_enter_bip32_index('Account Number:') or 0
|
||||
await self.render()
|
||||
|
||||
async def pick_single(self, _1, menu_idx, item):
|
||||
settings.put('axi', menu_idx) # update last clicked address
|
||||
path, addr_fmt = item.arg
|
||||
async def pick_single(self, _1, _2, item):
|
||||
path, addr_fmt, axi = item.arg
|
||||
settings.put('axi', axi) # update last clicked address
|
||||
await self.show_n_addresses(path, addr_fmt, None)
|
||||
|
||||
async def pick_multisig(self, _1, menu_idx, item):
|
||||
ms_wallet = item.arg
|
||||
settings.put('axi', menu_idx) # update last clicked address
|
||||
await self.show_n_addresses(None, None, ms_wallet)
|
||||
async def pick_miniscript(self, _1, _2, item):
|
||||
msc_wallet = item.arg
|
||||
settings.put('axi', item.label) # update last clicked address
|
||||
await self.show_n_addresses(None, None, msc_wallet)
|
||||
|
||||
async def make_custom(self, *a):
|
||||
# picking a custom derivation path: makes a tree of menus, with chance
|
||||
@ -239,11 +253,18 @@ Press (3) if you really understand and accept these risks.
|
||||
# - also for other {account} numbers
|
||||
# - or multisig case
|
||||
from glob import dis, NFC, VD
|
||||
import version
|
||||
|
||||
def make_msg(change=0):
|
||||
# speed up UI (do not recalculate addresses in while loop)
|
||||
# cache can only have 2 values external, internal (0,1)
|
||||
_cache = OrderedDict()
|
||||
|
||||
def make_msg(change=0, start=start, n=n):
|
||||
nonlocal _cache
|
||||
if (change, start, n) in _cache:
|
||||
return _cache[(change, start, n)]
|
||||
|
||||
export_msg = "Press (1) to save Address summary file to SD Card."
|
||||
if version.has_fatram and not ms_wallet:
|
||||
if not ms_wallet:
|
||||
export_msg += " Press (2) to view QR Codes."
|
||||
if NFC:
|
||||
export_msg += " Press (3) to share via NFC."
|
||||
@ -273,16 +294,8 @@ Press (3) if you really understand and accept these risks.
|
||||
# but show enough they can verify addrs shown elsewhere.
|
||||
# - makes a redeem script
|
||||
# - converts into addr
|
||||
# - assumes 0/0 is first address.
|
||||
for (i, paths, addr, script) in ms_wallet.yield_addresses(start, n, change_idx=change):
|
||||
if i == 0 and ms_wallet.N <= 4:
|
||||
msg += '\n'.join(paths) + '\n =>\n'
|
||||
else:
|
||||
msg += '.../%d/%d =>\n' % (change, i)
|
||||
|
||||
addrs.append(addr)
|
||||
msg += truncate_address(addr) + '\n\n'
|
||||
dis.progress_bar_show(i/n)
|
||||
# - assumes <0;1>/0 is first address.
|
||||
msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change)
|
||||
|
||||
else:
|
||||
# single-singer wallets
|
||||
@ -304,10 +317,15 @@ Press (3) if you really understand and accept these risks.
|
||||
if n > 1:
|
||||
msg += "Press (9) to see next group, (7) to go back. X to quit."
|
||||
|
||||
if len(_cache) < 4:
|
||||
_cache[(change, start, n)] = (msg, addrs)
|
||||
else:
|
||||
# LIFO
|
||||
_cache = OrderedDict(list(_cache.items())[:-1])
|
||||
return msg, addrs
|
||||
|
||||
msg, addrs = make_msg()
|
||||
change = 0
|
||||
msg, addrs = make_msg(change, start)
|
||||
while 1:
|
||||
ch = await ux_show_story(msg, escape='1234679')
|
||||
|
||||
@ -327,8 +345,7 @@ Press (3) if you really understand and accept these risks.
|
||||
|
||||
elif ch == '2':
|
||||
# switch into a mode that shows them as QR codes
|
||||
if not version.has_fatram or ms_wallet:
|
||||
# requires mk3 and not multisig
|
||||
if ms_wallet:
|
||||
continue
|
||||
|
||||
from ux import show_qr_codes
|
||||
@ -355,23 +372,13 @@ Press (3) if you really understand and accept these risks.
|
||||
else:
|
||||
continue # 3 in non-NFC mode
|
||||
|
||||
msg, addrs = make_msg(change)
|
||||
msg, addrs = make_msg(change, start, n)
|
||||
|
||||
def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, change=0):
|
||||
# Produce CSV file contents as a generator
|
||||
|
||||
if ms_wallet:
|
||||
# For multisig, include redeem script and derivation for each signer
|
||||
yield '"' + '","'.join(['Index', 'Payment Address',
|
||||
'Redeem Script (%d of %d)' % (ms_wallet.M, ms_wallet.N)]
|
||||
+ (['Derivation'] * ms_wallet.N)) + '"\n'
|
||||
|
||||
for (idx, derivs, addr, script) in ms_wallet.yield_addresses(start, n, change_idx=change):
|
||||
ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode())
|
||||
ln += '","'.join(derivs)
|
||||
ln += '"\n'
|
||||
|
||||
yield ln
|
||||
for line in ms_wallet.generate_address_csv(start, n, change):
|
||||
yield line
|
||||
|
||||
return
|
||||
|
||||
@ -420,7 +427,7 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num,
|
||||
dis.progress_bar_show(idx / count)
|
||||
|
||||
sig_nice = None
|
||||
if not ms_wallet:
|
||||
if not ms_wallet and addr_fmt != AF_P2TR:
|
||||
derive = path.format(account=account_num, change=change, idx=0) # first addr
|
||||
sig_nice = write_sig_file([(h.digest(), fname)], derive, addr_fmt)
|
||||
|
||||
|
||||
273
shared/auth.py
273
shared/auth.py
@ -3,12 +3,12 @@
|
||||
# Operations that require user authorization, like our core features: signing messages
|
||||
# and signing bitcoin transactions.
|
||||
#
|
||||
import stash, ure, ux, chains, sys, gc, uio, version, ngu
|
||||
import stash, ure, chains, sys, gc, uio, ngu, ujson
|
||||
from ubinascii import b2a_base64, a2b_base64
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS
|
||||
from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P2WPKH_P2SH
|
||||
from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
|
||||
from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED
|
||||
from sffile import SFFile
|
||||
from ux import ux_aborted, ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys
|
||||
@ -18,7 +18,7 @@ from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path
|
||||
from utils import B2A, parse_addr_fmt_str
|
||||
from psbt import psbtObject, FatalPSBTIssue, FraudulentChangeOutput
|
||||
from exceptions import HSMDenied
|
||||
from version import has_psram, has_fatram, MAX_TXN_LEN
|
||||
from version import MAX_TXN_LEN
|
||||
|
||||
# Where in SPI flash/PSRAM the two PSBT files are (in and out)
|
||||
TXN_INPUT_OFFSET = 0
|
||||
@ -153,31 +153,33 @@ 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)
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
dis.progress_bar_show(.50)
|
||||
if pk is None:
|
||||
# if private key is provided, derivation subpath is ignored
|
||||
# and provided private key is used for signing
|
||||
if pk is None:
|
||||
with stash.SensitiveValues() as sv:
|
||||
# if private key is provided, derivation subpath is ignored
|
||||
# and provided private key is used for signing
|
||||
node = sv.derive_path(subpath)
|
||||
dis.progress_bar_show(.50)
|
||||
pk = node.privkey()
|
||||
else:
|
||||
node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
|
||||
addr = ch.address(node, addr_fmt)
|
||||
else:
|
||||
node = ngu.hdnode.HDNode().from_chaincode_privkey(bytes(32), pk)
|
||||
dis.progress_bar_show(.50)
|
||||
addr = ch.address(node, addr_fmt)
|
||||
|
||||
addr = chains.current_chain().address(node, addr_fmt)
|
||||
sv.register(pk)
|
||||
|
||||
dis.progress_bar_show(.75)
|
||||
rv = ngu.secp256k1.sign(pk, digest, 0).to_bytes()
|
||||
# AF_CLASSIC header byte base 31 is returned by default from ngu - NOOP
|
||||
if addr_fmt != AF_CLASSIC:
|
||||
header_byte, rs = rv[0], rv[1:]
|
||||
# ngu only produces header base for compressed p2pkh, anyways get only rec_id
|
||||
rec_id = (header_byte - 27) & 0x03
|
||||
new_header_byte = rec_id + sv.chain.sig_hdr_base(addr_fmt=addr_fmt)
|
||||
rv = bytes([new_header_byte]) + rs
|
||||
dis.progress_bar_show(.75)
|
||||
rv = ngu.secp256k1.sign(pk, digest, 0).to_bytes()
|
||||
# AF_CLASSIC header byte base 31 is returned by default from ngu - NOOP
|
||||
if addr_fmt != AF_CLASSIC:
|
||||
header_byte, rs = rv[0], rv[1:]
|
||||
# ngu only produces header base for compressed p2pkh, anyways get only rec_id
|
||||
rec_id = (header_byte - 27) & 0x03
|
||||
new_header_byte = rec_id + ch.sig_hdr_base(addr_fmt=addr_fmt)
|
||||
rv = bytes([new_header_byte]) + rs
|
||||
|
||||
dis.progress_bar_show(1)
|
||||
|
||||
@ -310,6 +312,9 @@ class ApproveMessageSign(UserAuthorizedAction):
|
||||
self.text = validate_text_for_signing(text)
|
||||
self.subpath = cleanup_deriv_path(subpath)
|
||||
self.addr_fmt = parse_addr_fmt_str(addr_fmt)
|
||||
# temporary - no p2tr support
|
||||
if self.addr_fmt == AF_P2TR:
|
||||
raise ValueError("Unsupported address format: 'p2tr'")
|
||||
self.approved_cb = approved_cb
|
||||
|
||||
from glob import dis
|
||||
@ -360,7 +365,7 @@ def sign_msg(text, subpath, addr_fmt):
|
||||
abort_and_goto(UserAuthorizedAction.active_request)
|
||||
|
||||
|
||||
def sign_txt_file(filename):
|
||||
async def sign_txt_file(filename):
|
||||
# sign a one-line text file found on a MicroSD card
|
||||
# - not yet clear how to do address types other than 'classic'
|
||||
from files import CardSlot, CardMissingError
|
||||
@ -383,7 +388,7 @@ def sign_txt_file(filename):
|
||||
if not addr_fmt:
|
||||
addr_fmt = AF_CLASSIC
|
||||
|
||||
def done(signature, address, text):
|
||||
async def done(signature, address, text):
|
||||
# complete. write out result
|
||||
from glob import dis
|
||||
|
||||
@ -629,7 +634,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
# Prompt user w/ details and get approval
|
||||
from glob import dis, hsm_active
|
||||
|
||||
# step 1: parse PSBT from sflash into in-memory objects.
|
||||
# step 1: parse PSBT from PSRAM into in-memory objects.
|
||||
|
||||
try:
|
||||
with SFFile(TXN_INPUT_OFFSET, length=self.psbt_len, message='Reading...') as fd:
|
||||
@ -709,6 +714,13 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
self.output_change_text(msg)
|
||||
gc.collect()
|
||||
|
||||
if self.psbt.ux_notes:
|
||||
# currently we only have locktimes in ux_notes
|
||||
msg.write('\nTX LOCKTIMES\n\n')
|
||||
|
||||
for label, m in self.psbt.ux_notes:
|
||||
msg.write('- %s: %s\n\n' % (label, m))
|
||||
|
||||
if self.psbt.warnings:
|
||||
msg.write('\n---WARNING---\n\n')
|
||||
|
||||
@ -796,16 +808,14 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
while 1:
|
||||
# Show txid when we can; advisory
|
||||
# - maybe even as QR, hex-encoded in alnum mode
|
||||
tmsg = txid + '\n\n'
|
||||
tmsg = txid + '\n\nPress (1) for QR Code of TXID. '
|
||||
|
||||
if has_fatram:
|
||||
tmsg += 'Press (1) for QR Code of TXID. '
|
||||
if NFC:
|
||||
tmsg += 'Press (3) to share signed txn via NFC.'
|
||||
|
||||
ch = await ux_show_story(tmsg, "Final TXID", escape='13')
|
||||
|
||||
if ch=='1' and has_fatram:
|
||||
if ch == '1':
|
||||
await show_qr_code(txid, True)
|
||||
continue
|
||||
|
||||
@ -819,7 +829,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
#if NFC:
|
||||
#NFC.share_signed_psbt(TXN_OUTPUT_OFFSET, self.result[0], self.result[1])
|
||||
|
||||
def save_visualization(self, msg, sign_text=False):
|
||||
async def save_visualization(self, msg, sign_text=False):
|
||||
# write text into spi flash, maybe signing it as we go
|
||||
# - return length and checksum
|
||||
txt_len = msg.seek(0, 2)
|
||||
@ -838,7 +848,6 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
fd.write(blk)
|
||||
|
||||
if chk:
|
||||
from ubinascii import b2a_base64
|
||||
# append the signature
|
||||
digest = ngu.hash.sha256s(chk.digest())
|
||||
sig = sign_message_digest(digest, 'm', None, AF_CLASSIC)[0]
|
||||
@ -951,7 +960,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
|
||||
|
||||
def sign_transaction(psbt_len, flags=0x0, psbt_sha=None):
|
||||
# transaction (binary) loaded into sflash/PSRAM already, checksum checked
|
||||
# transaction (binary) loaded into PSRAM already, checksum checked
|
||||
UserAuthorizedAction.check_busy(ApproveTransaction)
|
||||
UserAuthorizedAction.active_request = ApproveTransaction(psbt_len, flags, psbt_sha=psbt_sha)
|
||||
|
||||
@ -985,9 +994,10 @@ async def sign_psbt_file(filename, force_vdisk=False):
|
||||
from files import CardSlot, CardMissingError
|
||||
from glob import dis
|
||||
from ux import the_ux
|
||||
from sram2 import tmp_buf
|
||||
|
||||
# copy file into our spiflash
|
||||
tmp_buf = bytearray(1024)
|
||||
|
||||
# copy file into PSRAM
|
||||
# - can't work in-place on the card because we want to support writing out to different card
|
||||
# - accepts hex or base64 encoding, but binary prefered
|
||||
with CardSlot(force_vdisk, readonly=True) as card:
|
||||
@ -1191,25 +1201,38 @@ class NewPassphrase(UserAuthorizedAction):
|
||||
async def interact(self):
|
||||
# prompt them
|
||||
from glob import settings
|
||||
from pincodes import pa
|
||||
|
||||
showit = False
|
||||
title = "Passphrase"
|
||||
bypass_tmp = True
|
||||
escape = "x2"
|
||||
while 1:
|
||||
if showit:
|
||||
ch = await ux_show_story('''Given:\n\n%s\n\nShould we switch to that wallet now?
|
||||
msg = ('BIP-39 passphrase (%d chars long) has been provided over '
|
||||
'USB connection. Should we switch to that wallet now?\n\n')
|
||||
if pa.tmp_value and settings.get("words", True):
|
||||
escape += "1"
|
||||
msg += "Press (1) to add passphrase to currently active temporary seed. "
|
||||
|
||||
OK to continue, X to cancel.''' % self._pw, title="Passphrase")
|
||||
else:
|
||||
ch = await ux_show_story('''BIP-39 passphrase (%d chars long) has been provided over USB connection. Should we switch to that wallet now?
|
||||
if settings.master_get("words", True):
|
||||
escape += "y"
|
||||
msg += "Press OK to add passphrase to master seed. "
|
||||
|
||||
Press (2) to view the provided passphrase.\n\nOK to continue, X to cancel.''' % len(self._pw), title="Passphrase", escape='2')
|
||||
msg += ('Press (2) to view the provided passphrase.\n\n'
|
||||
'X to cancel.')
|
||||
|
||||
ch = await ux_show_story(msg=msg % len(self._pw), title=title,
|
||||
escape=escape, strict_escape=True)
|
||||
if ch == '2':
|
||||
showit = True
|
||||
await ux_show_story('Provided:\n\n%s\n\n' % self._pw, title=title)
|
||||
continue
|
||||
break
|
||||
else:
|
||||
if ch == '1':
|
||||
bypass_tmp = False
|
||||
|
||||
break
|
||||
|
||||
try:
|
||||
if ch != 'y':
|
||||
if ch not in 'y1':
|
||||
# they don't want to!
|
||||
self.refused = True
|
||||
await ux_dramatic_pause("Refused.", 1)
|
||||
@ -1217,11 +1240,11 @@ Press (2) to view the provided passphrase.\n\nOK to continue, X to cancel.''' %
|
||||
from seed import set_bip39_passphrase
|
||||
|
||||
# full screen message shown: "Working..."
|
||||
set_bip39_passphrase(self._pw)
|
||||
await set_bip39_passphrase(self._pw, bypass_tmp=bypass_tmp,
|
||||
summarize_ux=False)
|
||||
|
||||
self.result = settings.get('xpub')
|
||||
|
||||
|
||||
except BaseException as exc:
|
||||
self.failed = "Exception"
|
||||
sys.print_exception(exc)
|
||||
@ -1230,8 +1253,9 @@ Press (2) to view the provided passphrase.\n\nOK to continue, X to cancel.''' %
|
||||
|
||||
if self.result:
|
||||
new_xfp = settings.get('xfp')
|
||||
await ux_show_story('''Above is the master key fingerprint of the current wallet.''',
|
||||
title="[%s]" % xfp2str(new_xfp))
|
||||
await ux_show_story('Above is the master key fingerprint '
|
||||
'of the current wallet.',
|
||||
title="[%s]" % xfp2str(new_xfp))
|
||||
|
||||
|
||||
def start_bip39_passphrase(pw):
|
||||
@ -1266,13 +1290,13 @@ class ShowAddressBase(UserAuthorizedAction):
|
||||
msg += '\n\nCompare this payment address to the one shown on your other, less-trusted, software.'
|
||||
if NFC:
|
||||
msg += ' Press (3) to share via NFC.'
|
||||
if has_fatram:
|
||||
msg += ' Press (4) to view QR Code.'
|
||||
|
||||
msg += ' Press (4) to view QR Code.'
|
||||
|
||||
while 1:
|
||||
ch = await ux_show_story(msg, title=self.title, escape='34')
|
||||
|
||||
if ch == '4' and has_fatram:
|
||||
if ch == '4':
|
||||
await show_qr_code(self.address, (self.addr_fmt & AFC_BECH32))
|
||||
continue
|
||||
if ch == '3' and NFC:
|
||||
@ -1313,8 +1337,7 @@ class ShowP2SHAddress(ShowAddressBase):
|
||||
|
||||
# calculate all the pubkeys involved.
|
||||
self.subpath_help = ms.validate_script(witdeem_script, xfp_paths=xfp_paths)
|
||||
|
||||
self.address = ms.chain.p2sh_address(addr_fmt, witdeem_script)
|
||||
self.address = chains.current_chain().p2sh_address(addr_fmt, witdeem_script)
|
||||
|
||||
def get_msg(self):
|
||||
return '''\
|
||||
@ -1330,6 +1353,43 @@ Paths:
|
||||
{sp}'''.format(addr=self.address, name=self.ms.name,
|
||||
M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help))
|
||||
|
||||
class ShowMiniscriptAddress(ShowAddressBase):
|
||||
|
||||
def setup(self, msc, change, idx):
|
||||
self.msc = msc
|
||||
self.change = change
|
||||
self.idx = idx
|
||||
|
||||
d = self.msc.desc.derive(None, change=change).derive(idx)
|
||||
self.address = chains.current_chain().render_address(d.script_pubkey())
|
||||
self.addr_fmt = self.msc.af
|
||||
|
||||
def get_msg(self):
|
||||
return '''\
|
||||
{addr}
|
||||
|
||||
Wallet:
|
||||
|
||||
{name}
|
||||
|
||||
Index:
|
||||
|
||||
{idx}
|
||||
|
||||
Change:
|
||||
|
||||
{change}'''.format(addr=self.address, name=self.msc.name, idx=self.idx, change=bool(self.change))
|
||||
|
||||
def start_show_miniscript_address(msc, change, index):
|
||||
UserAuthorizedAction.check_busy(ShowAddressBase)
|
||||
UserAuthorizedAction.active_request = ShowMiniscriptAddress(msc, change, index)
|
||||
|
||||
# kill any menu stack, and put our thing at the top
|
||||
abort_and_goto(UserAuthorizedAction.active_request)
|
||||
|
||||
# provide the value back to attached desktop
|
||||
return UserAuthorizedAction.active_request.address
|
||||
|
||||
def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script):
|
||||
# Show P2SH address to user, also returns it.
|
||||
# - first need to find appropriate multisig wallet associated
|
||||
@ -1388,26 +1448,48 @@ def usb_show_address(addr_format, subpath):
|
||||
return active_request.address
|
||||
|
||||
|
||||
class NewEnrollRequest(UserAuthorizedAction):
|
||||
def __init__(self, ms, auto_export=False):
|
||||
class MiniscriptDeleteRequest(UserAuthorizedAction):
|
||||
def __init__(self, msc):
|
||||
super().__init__()
|
||||
self.wallet = ms
|
||||
self.auto_export = auto_export
|
||||
|
||||
# self.result ... will be re-serialized xpub
|
||||
self.wallet = msc
|
||||
|
||||
async def interact(self):
|
||||
from multisig import MultisigOutOfSpace
|
||||
from miniscript import miniscript_delete
|
||||
await miniscript_delete(self.wallet)
|
||||
self.done()
|
||||
|
||||
|
||||
def maybe_delete_miniscript(msc):
|
||||
UserAuthorizedAction.cleanup()
|
||||
UserAuthorizedAction.active_request = MiniscriptDeleteRequest(msc)
|
||||
|
||||
# kill any menu stack, and put our thing at the top
|
||||
abort_and_goto(UserAuthorizedAction.active_request)
|
||||
|
||||
|
||||
class NewMiniscriptEnrollRequest(UserAuthorizedAction):
|
||||
def __init__(self, msc, auto_export=False, bsms_index=None):
|
||||
super().__init__()
|
||||
self.wallet = msc
|
||||
self.auto_export = auto_export
|
||||
self.bsms_index = bsms_index
|
||||
|
||||
async def interact(self):
|
||||
from wallet_base import WalletOutOfSpace
|
||||
|
||||
ms = self.wallet
|
||||
try:
|
||||
ch = await ms.confirm_import()
|
||||
|
||||
if ch == 'y':
|
||||
if self.bsms_index is not None:
|
||||
# remove signer round 2 from settings after multisig import is approved by user
|
||||
from bsms import BSMSSettings
|
||||
BSMSSettings.signer_delete(self.bsms_index)
|
||||
if self.auto_export:
|
||||
# save cosigner details now too
|
||||
await ms.export_wallet_file('created on',
|
||||
"\n\nImport that file onto the other Coldcards involved with this multisig wallet.")
|
||||
"\n\nImport that file onto the other Coldcards involved with this multisig wallet.")
|
||||
await ms.export_electrum()
|
||||
|
||||
else:
|
||||
@ -1415,18 +1497,32 @@ class NewEnrollRequest(UserAuthorizedAction):
|
||||
self.refused = True
|
||||
await ux_dramatic_pause("Refused.", 2)
|
||||
|
||||
except MultisigOutOfSpace:
|
||||
except WalletOutOfSpace:
|
||||
return await self.failure('No space left')
|
||||
except BaseException as exc:
|
||||
self.failed = "Exception"
|
||||
sys.print_exception(exc)
|
||||
finally:
|
||||
UserAuthorizedAction.cleanup() # because no results to store
|
||||
self.pop_menu()
|
||||
UserAuthorizedAction.cleanup() # because no results to store
|
||||
if self.bsms_index is not None:
|
||||
# bsms special case, get him back to multisig menu
|
||||
from ux import the_ux, restore_menu
|
||||
from multisig import MultisigMenu
|
||||
while 1:
|
||||
top = the_ux.top_of_stack()
|
||||
if not top: break
|
||||
if not isinstance(top, MultisigMenu):
|
||||
the_ux.pop()
|
||||
continue
|
||||
break
|
||||
restore_menu()
|
||||
else:
|
||||
self.pop_menu()
|
||||
|
||||
def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
|
||||
# Offer to import (enroll) a new multisig wallet. Allow reject by user.
|
||||
def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, miniscript=False):
|
||||
# Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user.
|
||||
from multisig import MultisigWallet
|
||||
from miniscript import MiniScriptWallet
|
||||
|
||||
UserAuthorizedAction.cleanup()
|
||||
|
||||
@ -1434,11 +1530,29 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
|
||||
with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd:
|
||||
config = fd.read(sf_len).decode()
|
||||
|
||||
try:
|
||||
j_conf = ujson.loads(config)
|
||||
assert "desc" in j_conf, "'desc' key required"
|
||||
config = j_conf["desc"]
|
||||
assert isinstance(config, str), "'desc' value not a str"
|
||||
assert config, "'desc' empty"
|
||||
|
||||
if "name" in j_conf:
|
||||
# name from json has preference over filenames and desc checksum
|
||||
name = j_conf["name"]
|
||||
assert isinstance(name, str), "'name' value not a str"
|
||||
assert len(name) >= 2, "'name' too short"
|
||||
assert len(name) <= 40, "'name' too long (max 40)"
|
||||
except ValueError: pass
|
||||
|
||||
# this call will raise on parsing errors, so let them rise up
|
||||
# and be shown on screen/over usb
|
||||
ms = MultisigWallet.from_file(config, name=name)
|
||||
if miniscript:
|
||||
msc = MiniScriptWallet.from_file(config, name=name)
|
||||
else:
|
||||
msc = MultisigWallet.from_file(config, name=name)
|
||||
|
||||
UserAuthorizedAction.active_request = NewEnrollRequest(ms)
|
||||
UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index)
|
||||
|
||||
if ux_reset:
|
||||
# for USB case, and import from PSBT
|
||||
@ -1494,26 +1608,15 @@ Binary checksum and signature will be further verified before any changes are ma
|
||||
# - reboot to start process
|
||||
from glob import dis
|
||||
dis.fullscreen('Upgrading...', percent=1)
|
||||
|
||||
if not has_psram:
|
||||
from sflash import SF
|
||||
import callgate
|
||||
SF.write(self.length, self.hdr)
|
||||
|
||||
callgate.show_logout(2)
|
||||
else:
|
||||
# Mk4 copies from PSRAM to flash inside bootrom, we have
|
||||
# nothing to do here except start that process.
|
||||
from pincodes import pa
|
||||
pa.firmware_upgrade(self.psram_offset, self.length)
|
||||
# not reached, unless issue?
|
||||
raise RuntimeError("bootrom fail")
|
||||
# Mk4 copies from PSRAM to flash inside bootrom, we have
|
||||
# nothing to do here except start that process.
|
||||
from pincodes import pa
|
||||
pa.firmware_upgrade(self.psram_offset, self.length)
|
||||
# not reached, unless issue?
|
||||
raise RuntimeError("bootrom fail")
|
||||
else:
|
||||
# they don't want to!
|
||||
self.refused = True
|
||||
if not has_psram:
|
||||
from sflash import SF
|
||||
SF.block_erase(0) # just in case, but not required
|
||||
await ux_dramatic_pause("Refused.", 2)
|
||||
|
||||
except BaseException as exc:
|
||||
|
||||
@ -5,13 +5,13 @@
|
||||
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 imported, xfp2str
|
||||
from utils import pad_raw_secret
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause
|
||||
import version, ujson
|
||||
from uio import StringIO
|
||||
import seed
|
||||
from glob import settings
|
||||
from pincodes import pa, AE_SECRET_LEN
|
||||
from pincodes import pa
|
||||
|
||||
# we make passwords with this number of words
|
||||
num_pw_words = const(12)
|
||||
@ -19,12 +19,12 @@ num_pw_words = const(12)
|
||||
# max size we expect for a backup data file (encrypted or cleartext)
|
||||
MAX_BACKUP_FILE_SIZE = const(10000) # bytes
|
||||
|
||||
def render_backup_contents():
|
||||
def render_backup_contents(bypass_tmp=False):
|
||||
# simple text format:
|
||||
# key = value
|
||||
# or #comments
|
||||
# but value is JSON
|
||||
|
||||
current_tmp = None
|
||||
rv = StringIO()
|
||||
|
||||
def COMMENT(val=None):
|
||||
@ -42,7 +42,7 @@ def render_backup_contents():
|
||||
|
||||
COMMENT('Private key details: ' + chain.name)
|
||||
|
||||
with stash.SensitiveValues(bypass_pw=True) as sv:
|
||||
with stash.SensitiveValues(bypass_tmp=bypass_tmp) as sv:
|
||||
if sv.deltamode:
|
||||
# die rather than give up our secrets
|
||||
import callgate
|
||||
@ -66,21 +66,22 @@ def render_backup_contents():
|
||||
ADD('long_secret', b2a_hex(pa.ls_fetch()))
|
||||
|
||||
# Duress wallets (somewhat optional, since derived)
|
||||
if version.mk_num <= 3:
|
||||
if pa.has_duress_pin():
|
||||
COMMENT('Duress Wallet (informational)')
|
||||
dpk, p = sv.duress_root()
|
||||
COMMENT('path = %s' % p)
|
||||
ADD('duress_xprv', chain.serialize_private(dpk))
|
||||
ADD('duress_xpub', chain.serialize_public(dpk))
|
||||
else:
|
||||
from trick_pins import tp
|
||||
for label, path, pairs in tp.backup_duress_wallets(sv):
|
||||
COMMENT()
|
||||
COMMENT(label + ' (informational)')
|
||||
COMMENT(path)
|
||||
for k,v in pairs:
|
||||
ADD(k, v)
|
||||
from trick_pins import tp
|
||||
for label, path, pairs in tp.backup_duress_wallets(sv):
|
||||
COMMENT()
|
||||
COMMENT(label + ' (informational)')
|
||||
COMMENT(path)
|
||||
for k,v in pairs:
|
||||
ADD(k, v)
|
||||
|
||||
if bypass_tmp and pa.tmp_value:
|
||||
current_tmp = pa.tmp_value[:]
|
||||
pa.tmp_value = None
|
||||
# we also need correct settings from main seed
|
||||
nv = stash.SecretStash.encode(seed_phrase=sv.raw)
|
||||
settings.set_key(nv)
|
||||
settings.load()
|
||||
stash.blank_object(nv)
|
||||
|
||||
COMMENT('Firmware version (informational)')
|
||||
date, vers, timestamp = version.get_mpy_version()[0:3]
|
||||
@ -97,17 +98,57 @@ def render_backup_contents():
|
||||
if k == 'xpub': continue # redundant, and wrong if bip39pw
|
||||
if k == 'xfp': continue # redundant, and wrong if bip39pw
|
||||
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 == 'seedvault' and not v: continue
|
||||
if k == 'seeds' and not v: continue
|
||||
ADD('setting.' + k, v)
|
||||
|
||||
if version.has_fatram:
|
||||
import hsm
|
||||
if hsm.hsm_policy_available():
|
||||
ADD('hsm_policy', hsm.capture_backup())
|
||||
import hsm
|
||||
if hsm.hsm_policy_available():
|
||||
ADD('hsm_policy', hsm.capture_backup())
|
||||
|
||||
rv.write('\n# EOF\n')
|
||||
|
||||
if bypass_tmp and current_tmp:
|
||||
# go back to tmp secret and its settings
|
||||
stash.SensitiveValues.clear_cache()
|
||||
pa.tmp_value = current_tmp
|
||||
settings.set_key()
|
||||
settings.load()
|
||||
|
||||
return rv.getvalue()
|
||||
|
||||
def extract_raw_secret(chain, vals):
|
||||
# step1: the private key
|
||||
# - prefer raw_secret over other values
|
||||
# - TODO: fail back to other values
|
||||
assert 'raw_secret' in vals
|
||||
rs = vals.pop('raw_secret')
|
||||
|
||||
raw = pad_raw_secret(rs)
|
||||
|
||||
# check we can decode this right (might be different firmare)
|
||||
opmode, bits, node = stash.SecretStash.decode(raw)
|
||||
assert node
|
||||
|
||||
# verify against xprv value (if we have it)
|
||||
if 'xprv' in vals:
|
||||
check_xprv = chain.serialize_private(node)
|
||||
assert check_xprv == vals['xprv'], 'xprv mismatch'
|
||||
|
||||
return raw
|
||||
|
||||
def extract_long_secret(vals):
|
||||
ls = None
|
||||
if ('long_secret' in vals) and version.has_608:
|
||||
try:
|
||||
ls = a2b_hex(vals.pop('long_secret'))
|
||||
except Exception as exc:
|
||||
sys.print_exception(exc)
|
||||
# but keep going.
|
||||
return ls
|
||||
|
||||
def restore_from_dict_ll(vals):
|
||||
# Restore from a dict of values. Already JSON decoded.
|
||||
# Need a Reboot on success, return string on failure
|
||||
@ -115,42 +156,14 @@ def restore_from_dict_ll(vals):
|
||||
from glob import dis
|
||||
|
||||
#print("Restoring from: %r" % vals)
|
||||
chain = chains.get_chain(vals.get('chain', 'BTC'))
|
||||
|
||||
# step1: the private key
|
||||
# - prefer raw_secret over other values
|
||||
# - TODO: fail back to other values
|
||||
try:
|
||||
chain = chains.get_chain(vals.get('chain', 'BTC'))
|
||||
|
||||
assert 'raw_secret' in vals
|
||||
raw = bytearray(AE_SECRET_LEN)
|
||||
rs = vals.pop('raw_secret')
|
||||
if len(rs) % 2:
|
||||
rs += '0'
|
||||
x = a2b_hex(rs)
|
||||
raw[0:len(x)] = x
|
||||
|
||||
# check we can decode this right (might be different firmare)
|
||||
opmode, bits, node = stash.SecretStash.decode(raw)
|
||||
assert node
|
||||
|
||||
# verify against xprv value (if we have it)
|
||||
if 'xprv' in vals:
|
||||
check_xprv = chain.serialize_private(node)
|
||||
assert check_xprv == vals['xprv'], 'xprv mismatch'
|
||||
|
||||
raw = extract_raw_secret(chain, vals)
|
||||
except Exception as e:
|
||||
return ('Unable to decode raw_secret and '
|
||||
'restore the seed value!\n\n\n'+str(e))
|
||||
|
||||
ls = None
|
||||
if ('long_secret' in vals) and version.has_608:
|
||||
try:
|
||||
ls = a2b_hex(vals.pop('long_secret'))
|
||||
except Exception as exc:
|
||||
sys.print_exception(exc)
|
||||
# but keep going.
|
||||
|
||||
dis.fullscreen("Saving...")
|
||||
dis.progress_bar_show(.25)
|
||||
|
||||
@ -161,9 +174,8 @@ def restore_from_dict_ll(vals):
|
||||
# force the right chain
|
||||
pa.new_main_secret(raw, chain) # updates xfp/xpub
|
||||
|
||||
|
||||
# NOTE: don't fail after this point... they can muddle thru w/ just right seed
|
||||
|
||||
ls = extract_long_secret(vals)
|
||||
if ls is not None:
|
||||
try:
|
||||
pa.ls_change(ls)
|
||||
@ -171,34 +183,69 @@ def restore_from_dict_ll(vals):
|
||||
sys.print_exception(exc)
|
||||
# but keep going
|
||||
|
||||
# restore settings from backup file
|
||||
# if sd2fa is encountered during backup restore - purge it
|
||||
settings.remove_key("sd2fa")
|
||||
|
||||
for idx, k in enumerate(vals):
|
||||
dis.progress_bar_show(idx / len(vals))
|
||||
if not k.startswith('setting.'):
|
||||
# restore settings from backup file
|
||||
vals_len = len(vals)
|
||||
for idx, key in enumerate(vals):
|
||||
dis.progress_bar_show(idx / vals_len)
|
||||
if not key[:8] == "setting.":
|
||||
continue
|
||||
|
||||
if k == 'xfp' or k == 'xpub': continue
|
||||
k = key[8:]
|
||||
|
||||
if k == 'sd2fa':
|
||||
# do NOT restore sd2fa as SD card can be lost or damaged
|
||||
# new version of firmware 5.1.3+ will not back sd2fa
|
||||
# old backups need this to function properly
|
||||
continue
|
||||
|
||||
if k == 'tp':
|
||||
# restore trick pins, which may involve many ops
|
||||
if version.mk_num >= 4:
|
||||
from trick_pins import tp
|
||||
try:
|
||||
tp.restore_backup(vals[k])
|
||||
except Exception as exc:
|
||||
sys.print_exception(exc)
|
||||
from trick_pins import tp
|
||||
try:
|
||||
tp.restore_backup(vals[key])
|
||||
except Exception as exc:
|
||||
sys.print_exception(exc)
|
||||
|
||||
# continue as `tp.restore_backup` handles
|
||||
# saving into settings
|
||||
continue
|
||||
|
||||
settings.set(k[8:], vals[k])
|
||||
settings.set(k, vals[key])
|
||||
|
||||
# write out
|
||||
settings.save()
|
||||
|
||||
if version.has_fatram and ('hsm_policy' in vals):
|
||||
if 'hsm_policy' in vals:
|
||||
import hsm
|
||||
hsm.restore_backup(vals['hsm_policy'])
|
||||
|
||||
async def restore_tmp_from_dict_ll(vals):
|
||||
from glob import dis
|
||||
|
||||
chain = chains.get_chain(vals.get('chain', 'BTC'))
|
||||
try:
|
||||
raw = extract_raw_secret(chain, vals)
|
||||
except Exception as e:
|
||||
return ('Unable to decode raw_secret and '
|
||||
'restore the seed value!\n\n\n' + str(e))
|
||||
|
||||
dis.fullscreen("Applying...")
|
||||
from seed import set_ephemeral_seed
|
||||
from actions import goto_top_menu
|
||||
|
||||
await set_ephemeral_seed(raw, chain, meta="Coldcard Backup")
|
||||
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)
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
async def restore_from_dict(vals):
|
||||
# Restore from a dict of values. Already JSON decoded (ie. dict object).
|
||||
@ -216,11 +263,26 @@ async def restore_from_dict(vals):
|
||||
|
||||
|
||||
async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
|
||||
from stash import bip39_passphrase
|
||||
|
||||
words = None
|
||||
skip_quiz = False
|
||||
bypass_tmp = False
|
||||
|
||||
if pa.tmp_value:
|
||||
if not await ux_confirm("An ephemeral seed is in effect, so backup will be of that seed."):
|
||||
if bip39_passphrase and pa.tmp_value:
|
||||
# this is a BIP39 password ephemeral wallet
|
||||
msg = ("BIP39 passphrase is in effect. Backup ignores passphrases "
|
||||
"and produces backup of main seed. Press OK to back-up main wallet,"
|
||||
" press (2) to back-up BIP39 passphrase wallet "
|
||||
"(extended private key created via seed + pass)")
|
||||
ch = await ux_show_story(msg, escape="2")
|
||||
if ch == "x": return
|
||||
if ch == "y":
|
||||
bypass_tmp = True
|
||||
|
||||
elif pa.tmp_value:
|
||||
if not await ux_confirm("A temporary seed is in effect, "
|
||||
"so backup will be of that seed."):
|
||||
return
|
||||
|
||||
stored_words = settings.get('bkpw', None)
|
||||
@ -277,16 +339,18 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
|
||||
settings.remove_key('bkpw')
|
||||
settings.save()
|
||||
|
||||
return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash)
|
||||
return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash,
|
||||
bypass_tmp=bypass_tmp)
|
||||
|
||||
async def write_complete_backup(words, fname_pattern, write_sflash=False, allow_copies=True):
|
||||
async def write_complete_backup(words, fname_pattern, write_sflash=False,
|
||||
allow_copies=True, bypass_tmp=False):
|
||||
# Just do the writing
|
||||
from glob import dis
|
||||
from files import CardSlot, CardMissingError
|
||||
from files import CardSlot
|
||||
|
||||
# Show progress:
|
||||
dis.fullscreen('Encrypting...' if words else 'Generating...')
|
||||
body = render_backup_contents().encode()
|
||||
body = render_backup_contents(bypass_tmp=bypass_tmp).encode()
|
||||
|
||||
gc.collect()
|
||||
|
||||
@ -364,11 +428,11 @@ async def write_complete_backup(words, fname_pattern, write_sflash=False, allow_
|
||||
while 1:
|
||||
msg = '''Backup file written:\n\n%s\n\n\
|
||||
To view or restore the file, you must have the full password.\n\n\
|
||||
Insert another SD card and press 2 to make another copy.''' % (nice)
|
||||
Insert another SD card and press 2 to make another copy.''' % nice
|
||||
|
||||
ch = await ux_show_story(msg, escape='2')
|
||||
|
||||
if ch == 'y': return
|
||||
if ch in 'xy': return
|
||||
if ch == '2': break
|
||||
|
||||
else:
|
||||
@ -419,14 +483,15 @@ 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):
|
||||
async def restore_complete(fname_or_fd, temporary=False):
|
||||
from ux import the_ux
|
||||
|
||||
async def done(words):
|
||||
# remove all pw-picking from menu stack
|
||||
seed.WordNestMenu.pop_all()
|
||||
|
||||
prob = await restore_complete_doit(fname_or_fd, words)
|
||||
prob = await restore_complete_doit(fname_or_fd, words,
|
||||
temporary=temporary)
|
||||
|
||||
if prob:
|
||||
await ux_show_story(prob, title='FAILED')
|
||||
@ -436,7 +501,7 @@ async def restore_complete(fname_or_fd):
|
||||
|
||||
the_ux.push(m)
|
||||
|
||||
async def restore_complete_doit(fname_or_fd, words, file_cleanup=None):
|
||||
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
|
||||
# - some errors will be shown, None return in that case
|
||||
# - no return if successful (due to reboot)
|
||||
@ -507,7 +572,10 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None):
|
||||
# but keep going!
|
||||
|
||||
# this leads to reboot if it works, else errors shown, etc.
|
||||
return await restore_from_dict(vals)
|
||||
if temporary:
|
||||
return await restore_tmp_from_dict_ll(vals)
|
||||
else:
|
||||
return await restore_from_dict(vals)
|
||||
|
||||
async def clone_start(*a):
|
||||
# Begins cloning process, on target device.
|
||||
@ -525,7 +593,6 @@ file with an ephemeral public key will be written.''')
|
||||
try:
|
||||
with CardSlot() as card:
|
||||
fname, nice = card.pick_filename('ccbk-start.json', overwrite=True)
|
||||
|
||||
with card.open(fname, 'wb') as fd:
|
||||
fd.write(ujson.dumps(dict(pubkey=b2a_hex(my_pubkey))))
|
||||
|
||||
@ -636,7 +703,7 @@ async def clone_write_data(*a):
|
||||
|
||||
fname = b2a_hex(my_pubkey).decode() + '-ccbk.7z'
|
||||
|
||||
await write_complete_backup(words, fname, allow_copies=False)
|
||||
await write_complete_backup(words, fname, allow_copies=False, bypass_tmp=True)
|
||||
|
||||
await ux_show_story("Done.\n\nTake this MicroSD card back to other Coldcard and continue from there.")
|
||||
|
||||
|
||||
1084
shared/bsms.py
Normal file
1084
shared/bsms.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,9 +5,10 @@
|
||||
import ngu
|
||||
from uhashlib import sha256
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from public_constants import AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH
|
||||
from public_constants import AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH, AF_P2TR
|
||||
from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT
|
||||
from serializations import hash160, ser_compact_size, disassemble
|
||||
from public_constants import TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK
|
||||
from serializations import hash160, ser_compact_size, disassemble, ser_string
|
||||
from ucollections import namedtuple
|
||||
from opcodes import OP_RETURN, OP_1, OP_16
|
||||
|
||||
@ -25,6 +26,28 @@ Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint'))
|
||||
# - from <https://github.com/Bit-Wasp/bitcoin-php/issues/576>
|
||||
# - also electrum source: electrum/lib/constants.py
|
||||
|
||||
|
||||
def taptweak(internal_key, tweak=None):
|
||||
# BIP 341 states: "If the spending conditions do not require a script path,
|
||||
# the output key should commit to an unspendable script path instead of having no script path.
|
||||
# This can be achieved by computing the output key point as:
|
||||
# Q = P + int(hashTapTweak(bytes(P)))G."
|
||||
actual_tweak = internal_key if tweak is None else internal_key + tweak
|
||||
tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", actual_tweak)
|
||||
xo_pubkey = ngu.secp256k1.xonly_pubkey(internal_key)
|
||||
xo_pubkey_tweaked = xo_pubkey.tweak_add(tweak)
|
||||
return xo_pubkey_tweaked.to_bytes()
|
||||
|
||||
def tapscript_serialize(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT):
|
||||
# leaf version is only 7 msb
|
||||
lv = leaf_version % TAPROOT_LEAF_MASK
|
||||
return bytes([lv]) + ser_string(script)
|
||||
|
||||
def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT):
|
||||
return ngu.secp256k1.tagged_sha256(b"TapLeaf",
|
||||
tapscript_serialize(script, leaf_version))
|
||||
|
||||
|
||||
class ChainsBase:
|
||||
|
||||
curve = 'secp256k1'
|
||||
@ -108,24 +131,30 @@ class ChainsBase:
|
||||
# - renders a pubkey to an address
|
||||
# - works only with single-key addresses
|
||||
assert not addr_fmt & AFC_SCRIPT
|
||||
|
||||
keyhash = ngu.hash.hash160(pubkey)
|
||||
if addr_fmt == AF_CLASSIC:
|
||||
script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
|
||||
elif addr_fmt == AF_P2WPKH_P2SH:
|
||||
redeem_script = b'\x00\x14' + keyhash
|
||||
scripthash = ngu.hash.hash160(redeem_script)
|
||||
script = b'\xA9\x14' + scripthash + b'\x87'
|
||||
elif addr_fmt == AF_P2WPKH:
|
||||
script = b'\x00\x14' + keyhash
|
||||
if addr_fmt == AF_P2TR:
|
||||
assert len(pubkey) == 32 # internal
|
||||
script = b'\x51\x20' + taptweak(pubkey)
|
||||
else:
|
||||
raise ValueError('bad address template: %s' % addr_fmt)
|
||||
keyhash = ngu.hash.hash160(pubkey)
|
||||
if addr_fmt == AF_CLASSIC:
|
||||
script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
|
||||
elif addr_fmt == AF_P2WPKH_P2SH:
|
||||
redeem_script = b'\x00\x14' + keyhash
|
||||
scripthash = ngu.hash.hash160(redeem_script)
|
||||
script = b'\xA9\x14' + scripthash + b'\x87'
|
||||
elif addr_fmt == AF_P2WPKH:
|
||||
script = b'\x00\x14' + keyhash
|
||||
else:
|
||||
raise ValueError('bad address template: %s' % addr_fmt)
|
||||
|
||||
return cls.render_address(script)
|
||||
|
||||
@classmethod
|
||||
def address(cls, node, addr_fmt):
|
||||
# return a human-readable, properly formatted address
|
||||
if addr_fmt == AF_P2TR:
|
||||
xo_pk = node.pubkey()[1:]
|
||||
return ngu.codecs.segwit_encode(cls.bech32_hrp, 1, taptweak(xo_pk))
|
||||
|
||||
if addr_fmt == AF_CLASSIC:
|
||||
# olde fashioned P2PKH
|
||||
@ -268,6 +297,7 @@ class BitcoinMain(ChainsBase):
|
||||
AF_P2WPKH: Slip132Version(0x04b24746, 0x04b2430c, 'z'),
|
||||
AF_P2WSH_P2SH: Slip132Version(0x0295b43f, 0x0295b005, 'Y'),
|
||||
AF_P2WSH: Slip132Version(0x02aa7ed3, 0x02aa7a99, 'Z'),
|
||||
AF_P2TR: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'),
|
||||
}
|
||||
|
||||
bech32_hrp = 'bc'
|
||||
@ -289,6 +319,7 @@ class BitcoinTestnet(BitcoinMain):
|
||||
AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'),
|
||||
AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'),
|
||||
AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'),
|
||||
AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'),
|
||||
}
|
||||
|
||||
bech32_hrp = 'tb'
|
||||
@ -311,6 +342,7 @@ class BitcoinRegtest(BitcoinMain):
|
||||
AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'),
|
||||
AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'),
|
||||
AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'),
|
||||
AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'),
|
||||
}
|
||||
|
||||
bech32_hrp = 'bcrt'
|
||||
@ -324,6 +356,8 @@ class BitcoinRegtest(BitcoinMain):
|
||||
|
||||
def get_chain(short_name):
|
||||
# lookup object from name: 'BTC' or 'XTN'
|
||||
if short_name is None:
|
||||
return BitcoinMain
|
||||
if short_name == 'BTC':
|
||||
return BitcoinMain
|
||||
elif short_name == 'XTN':
|
||||
@ -343,6 +377,13 @@ def current_chain():
|
||||
|
||||
return get_chain(chain)
|
||||
|
||||
def current_key_chain():
|
||||
c = current_chain()
|
||||
if c == BitcoinRegtest:
|
||||
# regtest has same extended keys as testnet
|
||||
c = BitcoinTestnet
|
||||
return c
|
||||
|
||||
# Overbuilt: will only be testnet and mainchain.
|
||||
AllChains = [BitcoinMain, BitcoinTestnet, BitcoinRegtest]
|
||||
|
||||
@ -368,9 +409,10 @@ CommonDerivations = [
|
||||
( 'BIP-44 / Electrum', "m/44'/{coin_type}'/{account}'/{change}/{idx}", AF_CLASSIC ),
|
||||
( 'BIP-49 (P2WPKH-nested-in-P2SH)', "m/49'/{coin_type}'/{account}'/{change}/{idx}",
|
||||
AF_P2WPKH_P2SH ), # generates 3xxx/2xxx p2sh-looking addresses
|
||||
|
||||
( 'BIP-84 (Native Segwit P2WPKH)', "m/84'/{coin_type}'/{account}'/{change}/{idx}",
|
||||
AF_P2WPKH ), # generates bc1 bech32 addresses
|
||||
AF_P2WPKH ), # generates bc1q bech32 addresses
|
||||
('BIP-86 (Taproot Segwit P2TR)', "m/86'/{coin_type}'/{account}'/{change}/{idx}",
|
||||
AF_P2TR), # generates bc1p bech32m addresses
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ def value_resolution_chooser():
|
||||
def scramble_keypad_chooser():
|
||||
# rngk = randomize keypad for PIN entry
|
||||
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
which = s.get('rngk', 0)
|
||||
del s
|
||||
|
||||
@ -75,7 +75,7 @@ def scramble_keypad_chooser():
|
||||
|
||||
def set(idx, text):
|
||||
# save it, but "outside" of login PIN
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
s.set('rngk', idx)
|
||||
s.save()
|
||||
del s
|
||||
@ -85,7 +85,7 @@ def scramble_keypad_chooser():
|
||||
def kill_key_chooser():
|
||||
# kbtn = single keypress after anti-phishing words will wipe seed
|
||||
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
which = s.get('kbtn', -1)
|
||||
del s
|
||||
which = int(which) + 1
|
||||
@ -94,7 +94,7 @@ def kill_key_chooser():
|
||||
|
||||
def set(idx, text):
|
||||
# save it, but "outside" of login PIN
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
if idx == 0:
|
||||
s.remove_key('kbtn')
|
||||
else:
|
||||
|
||||
@ -114,13 +114,14 @@ def check_file_headers(f):
|
||||
raise ValueError("Second header too big")
|
||||
|
||||
# capture this spot
|
||||
# TODO 'data_start' unused
|
||||
data_start = f.tell() # expect 0x20
|
||||
|
||||
try:
|
||||
f.seek(sh.offset, 1)
|
||||
th = f.read(sh.size)
|
||||
if len(th) != sh.size:
|
||||
raise IndexError("Truncated file? %s" % e.message)
|
||||
raise IndexError("Truncated file?")
|
||||
|
||||
# Look for properties about compression. this could be
|
||||
# faked-out but good enough for now
|
||||
|
||||
@ -2,28 +2,28 @@
|
||||
#
|
||||
# countdowns.py - various details and chooser menus for setting/showing countdown times
|
||||
#
|
||||
from glob import settings
|
||||
from ucollections import OrderedDict
|
||||
from nvstore import SettingsObject
|
||||
from menu import MenuItem
|
||||
from ux import ux_show_story, ux_dramatic_pause
|
||||
|
||||
# Login countdown length, stored in minutes
|
||||
#
|
||||
lgto_map = {
|
||||
0: 'Disabled',
|
||||
5: ' 5 minutes',
|
||||
15: '15 minutes',
|
||||
30: '30 minutes',
|
||||
60: ' 1 hour',
|
||||
2*60: ' 2 hours',
|
||||
4*60: ' 4 hours',
|
||||
8*60: ' 8 hours',
|
||||
12*60: '12 hours',
|
||||
24*60: '24 hours',
|
||||
48*60: '48 hours',
|
||||
3*24*60: ' 3 days',
|
||||
7*24*60: ' 1 week',
|
||||
28*24*60: '28 days later' }
|
||||
lgto_map = OrderedDict([
|
||||
(0, 'Disabled'),
|
||||
(5, ' 5 minutes'),
|
||||
(15, '15 minutes'),
|
||||
(30, '30 minutes'),
|
||||
(60, ' 1 hour'),
|
||||
(2*60, ' 2 hours'),
|
||||
(4*60, ' 4 hours'),
|
||||
(8*60, ' 8 hours'),
|
||||
(12*60, '12 hours'),
|
||||
(24*60, '24 hours'),
|
||||
(48*60, '48 hours'),
|
||||
(3*24*60, ' 3 days'),
|
||||
(7*24*60, ' 1 week'),
|
||||
(28*24*60, '28 days later'),
|
||||
])
|
||||
|
||||
lgto_va = list(lgto_map.keys())
|
||||
lgto_ch = list(lgto_map.values())
|
||||
|
||||
@ -33,7 +33,7 @@ def real_countdown_chooser(tag, offset, def_to):
|
||||
ch = lgto_ch[offset:]
|
||||
va = lgto_va[offset:]
|
||||
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
timeout = s.get(tag, def_to) # in minutes
|
||||
try:
|
||||
which = va.index(timeout)
|
||||
@ -42,7 +42,7 @@ def real_countdown_chooser(tag, offset, def_to):
|
||||
|
||||
def set_it(idx, text):
|
||||
# save on key0, not normal settings
|
||||
s = SettingsObject()
|
||||
s = SettingsObject.prelogin()
|
||||
s.set(tag, va[idx])
|
||||
s.save()
|
||||
del s
|
||||
@ -51,81 +51,5 @@ def real_countdown_chooser(tag, offset, def_to):
|
||||
|
||||
def countdown_chooser():
|
||||
return real_countdown_chooser('lgto', 0, 0)
|
||||
def cd_countdown_chooser():
|
||||
return real_countdown_chooser('cd_lgto', 1, 60)
|
||||
|
||||
# Mk3 only
|
||||
async def set_countdown_pin(_1, _2, menu_item):
|
||||
# Accept a new PIN to be used to enable this feature
|
||||
from login import LoginUX
|
||||
|
||||
lll = LoginUX()
|
||||
lll.reset()
|
||||
lll.subtitle = "Countdown PIN"
|
||||
|
||||
pin = await lll.get_new_pin(None, allow_clear=True) # a string
|
||||
|
||||
s = SettingsObject()
|
||||
|
||||
from pincodes import pa
|
||||
if pin == pa.pin.decode():
|
||||
# can't compare to others like duress/brickme but will override them
|
||||
await ux_show_story("Must be a unique PIN value!")
|
||||
return
|
||||
elif not pin:
|
||||
# X on first screen does this (better than CLEAR_PIN thing)
|
||||
s.remove_key('cd_pin')
|
||||
msg = 'PIN Cleared.'
|
||||
menu_item.label = "Enable Feature"
|
||||
else:
|
||||
s.set('cd_pin', pin)
|
||||
msg = 'PIN Set.'
|
||||
menu_item.label = "PIN is Set!"
|
||||
|
||||
s.save()
|
||||
|
||||
await ux_dramatic_pause(msg, 3)
|
||||
|
||||
# Mk3 only
|
||||
def set_countdown_pin_mode():
|
||||
# cd_mode = various harm levels
|
||||
s = SettingsObject()
|
||||
which = s.get('cd_mode', 0) # default is brick
|
||||
del s
|
||||
|
||||
ch = ['Brick', 'Final PIN', 'Test Mode']
|
||||
|
||||
def set(idx, text):
|
||||
# save it, but "outside" of login PIN
|
||||
s = SettingsObject()
|
||||
s.set('cd_mode', idx)
|
||||
s.save()
|
||||
del s
|
||||
|
||||
return which, ch, set
|
||||
|
||||
# Mk3 only
|
||||
async def countdown_pin_submenu(*a):
|
||||
# Background and settings for duress-countdown pin
|
||||
s = SettingsObject()
|
||||
pin_set = bool(s.get('cd_pin', 0))
|
||||
|
||||
if not pin_set:
|
||||
ok = await ux_show_story('''\
|
||||
This special PIN will immediately and silently brick the Coldcard, \
|
||||
but as it does that, it shows a normal-looking countdown timer for login. \
|
||||
At the end of the countdown, the Coldcard crashes with a vague error. \
|
||||
|
||||
Instead of complete brick, you may select a test mode (no harm done) or \
|
||||
to consume all but the final PIN attempt.\
|
||||
''')
|
||||
if not ok: return
|
||||
|
||||
|
||||
return [
|
||||
MenuItem('PIN is Set!' if pin_set else 'Enable Feature', f=set_countdown_pin),
|
||||
MenuItem('Countdown Time', chooser=cd_countdown_chooser),
|
||||
MenuItem('Brick Mode', chooser=set_countdown_pin_mode),
|
||||
]
|
||||
|
||||
# EOF
|
||||
|
||||
519
shared/desc_utils.py
Normal file
519
shared/desc_utils.py
Normal file
@ -0,0 +1,519 @@
|
||||
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# Copyright (c) 2020 Stepan Snigirev MIT License embit/arguments.py
|
||||
#
|
||||
import ngu, chains
|
||||
from io import BytesIO
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_CLASSIC, AF_P2TR
|
||||
from binascii import unhexlify as a2b_hex
|
||||
from binascii import hexlify as b2a_hex
|
||||
from utils import keypath_to_str, str_to_keypath, swab32, xfp2str
|
||||
from serializations import ser_compact_size
|
||||
|
||||
|
||||
WILDCARD = "*"
|
||||
PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
|
||||
|
||||
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
|
||||
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def polymod(c, val):
|
||||
c0 = c >> 35
|
||||
c = ((c & 0x7ffffffff) << 5) ^ val
|
||||
if (c0 & 1):
|
||||
c ^= 0xf5dee51989
|
||||
if (c0 & 2):
|
||||
c ^= 0xa9fdca3312
|
||||
if (c0 & 4):
|
||||
c ^= 0x1bab10e32d
|
||||
if (c0 & 8):
|
||||
c ^= 0x3706b1677a
|
||||
if (c0 & 16):
|
||||
c ^= 0x644d626ffd
|
||||
|
||||
return c
|
||||
|
||||
def descriptor_checksum(desc):
|
||||
c = 1
|
||||
cls = 0
|
||||
clscount = 0
|
||||
for ch in desc:
|
||||
pos = INPUT_CHARSET.find(ch)
|
||||
if pos == -1:
|
||||
raise ValueError(ch)
|
||||
|
||||
c = polymod(c, pos & 31)
|
||||
cls = cls * 3 + (pos >> 5)
|
||||
clscount += 1
|
||||
if clscount == 3:
|
||||
c = polymod(c, cls)
|
||||
cls = 0
|
||||
clscount = 0
|
||||
|
||||
if clscount > 0:
|
||||
c = polymod(c, cls)
|
||||
for j in range(0, 8):
|
||||
c = polymod(c, 0)
|
||||
c ^= 1
|
||||
|
||||
rv = ''
|
||||
for j in range(0, 8):
|
||||
rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
|
||||
|
||||
return rv
|
||||
|
||||
def append_checksum(desc):
|
||||
return desc + "#" + descriptor_checksum(desc)
|
||||
|
||||
|
||||
def parse_desc_str(string):
|
||||
"""Remove comments, empty lines and strip line. Produce single line string"""
|
||||
res = ""
|
||||
for l in string.split("\n"):
|
||||
strip_l = l.strip()
|
||||
if not strip_l:
|
||||
continue
|
||||
if strip_l.startswith("#"):
|
||||
continue
|
||||
res += strip_l
|
||||
return res
|
||||
|
||||
|
||||
def multisig_descriptor_template(xpub, path, xfp, addr_fmt):
|
||||
key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub)
|
||||
if addr_fmt == AF_P2WSH_P2SH:
|
||||
descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))"
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
descriptor_template = "wsh(sortedmulti(M,%s,...))"
|
||||
elif addr_fmt == AF_P2SH:
|
||||
descriptor_template = "sh(sortedmulti(M,%s,...))"
|
||||
elif addr_fmt == AF_P2TR:
|
||||
# provably unspendable BIP-0341
|
||||
descriptor_template = "tr(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))"
|
||||
else:
|
||||
return None
|
||||
descriptor_template = descriptor_template % key_exp
|
||||
return descriptor_template
|
||||
|
||||
|
||||
def read_until(s, chars=b",)(#"):
|
||||
# TODO potential infinite loop
|
||||
# what is the longest possible element? (proly some raw( but that is unsupported)
|
||||
#
|
||||
res = b""
|
||||
chunk = b""
|
||||
char = None
|
||||
while True:
|
||||
chunk = s.read(1)
|
||||
if len(chunk) == 0:
|
||||
return res, None
|
||||
if chunk in chars:
|
||||
return res, chunk
|
||||
res += chunk
|
||||
return res, None
|
||||
|
||||
|
||||
class KeyOriginInfo:
|
||||
def __init__(self, fingerprint: bytes, derivation: list):
|
||||
self.fingerprint = fingerprint
|
||||
self.derivation = derivation
|
||||
self.cc_fp = swab32(int(b2a_hex(self.fingerprint).decode(), 16))
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.psbt_derivation() == other.psbt_derivation()
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(self.psbt_derivation()))
|
||||
|
||||
def str_derivation(self):
|
||||
return keypath_to_str(self.derivation, prefix='m/', skip=0)
|
||||
|
||||
def psbt_derivation(self):
|
||||
res = [self.cc_fp]
|
||||
for i in self.derivation:
|
||||
res.append(i)
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s: str):
|
||||
arr = s.split("/")
|
||||
xfp = a2b_hex(arr[0])
|
||||
assert len(xfp) == 4
|
||||
arr[0] = "m"
|
||||
path = "/".join(arr)
|
||||
derivation = str_to_keypath(xfp, path)[1:] # ignoring xfp here, already stored
|
||||
return cls(xfp, derivation)
|
||||
|
||||
def __str__(self):
|
||||
return "%s/%s" % (b2a_hex(self.fingerprint).decode(),
|
||||
keypath_to_str(self.derivation, prefix='', skip=0).replace("'", "h"))
|
||||
|
||||
|
||||
class KeyDerivationInfo:
|
||||
|
||||
def __init__(self, indexes=None):
|
||||
self.indexes = indexes
|
||||
if self.indexes is None:
|
||||
self.indexes = [[0, 1], WILDCARD]
|
||||
self.multi_path_index = 0
|
||||
else:
|
||||
self.multi_path_index = None
|
||||
|
||||
@property
|
||||
def is_int_ext(self):
|
||||
if self.multi_path_index is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_external(self):
|
||||
if self.is_int_ext:
|
||||
return True
|
||||
elif self.indexes[-2] % 2 == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def branches(self):
|
||||
if self.is_int_ext:
|
||||
return self.indexes[self.multi_path_index]
|
||||
else:
|
||||
return [self.indexes[-2]]
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s):
|
||||
fail_msg = "Cannot use hardened sub derivation path"
|
||||
if not s:
|
||||
return cls()
|
||||
res = []
|
||||
mp = 0
|
||||
mpi = None
|
||||
for idx, i in enumerate(s.split("/")):
|
||||
start_i = i.find("<")
|
||||
if start_i != -1:
|
||||
end_i = s.find(">")
|
||||
assert end_i
|
||||
inner = s[start_i+1:end_i]
|
||||
assert ";" in inner
|
||||
inner_split = inner.split(";")
|
||||
assert len(inner_split) == 2, "wrong multipath"
|
||||
res.append([int(i) for i in inner_split])
|
||||
mp += 1
|
||||
mpi = idx
|
||||
else:
|
||||
if i == WILDCARD:
|
||||
res.append(WILDCARD)
|
||||
else:
|
||||
assert "'" not in i, fail_msg
|
||||
assert "h" not in i, fail_msg
|
||||
res.append(int(i))
|
||||
|
||||
# only one <x;y> allowed in subderivation
|
||||
assert mp <= 1, "too many multipaths (%d)" % mp
|
||||
|
||||
if res == [0, WILDCARD]:
|
||||
obj = cls()
|
||||
else:
|
||||
assert len(res) == 2, "Key derivation too long"
|
||||
assert res[-1] == WILDCARD, "All keys must be ranged"
|
||||
obj = cls(res)
|
||||
obj.multi_path_index = mpi
|
||||
return obj
|
||||
|
||||
def to_string(self, external=True, internal=True):
|
||||
res = []
|
||||
for i in self.indexes:
|
||||
if isinstance(i, list):
|
||||
if internal is True and external is False:
|
||||
i = str(i[1])
|
||||
elif internal is False and external is True:
|
||||
i = str(i[0])
|
||||
else:
|
||||
i = "<%d;%d>" % (i[0], i[1])
|
||||
else:
|
||||
i = str(i)
|
||||
res.append(i)
|
||||
return "/".join(res)
|
||||
|
||||
def to_int_list(self, branch_idx, idx):
|
||||
assert branch_idx in self.indexes[0]
|
||||
return [branch_idx, idx]
|
||||
|
||||
|
||||
class Key:
|
||||
def __init__(self, node, origin, derivation=None, taproot=False, chain_type=None):
|
||||
self.origin = origin
|
||||
self.node = node
|
||||
self.derivation = derivation
|
||||
self.taproot = taproot
|
||||
self.chain_type = chain_type
|
||||
if not isinstance(self.node, bytes):
|
||||
assert self.origin, "Key origin info is required"
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.origin.psbt_derivation() == other.origin.psbt_derivation() \
|
||||
and self.derivation.indexes == other.derivation.indexes
|
||||
|
||||
def __hash__(self):
|
||||
orig = tuple(self.origin.psbt_derivation())
|
||||
der = self.derivation.indexes.copy()
|
||||
if self.derivation.multi_path_index is not None:
|
||||
der[self.derivation.multi_path_index] = tuple(der[self.derivation.multi_path_index])
|
||||
der = tuple(der)
|
||||
return hash(orig+der)
|
||||
|
||||
def __len__(self):
|
||||
return 34 - int(self.taproot) # <33:sec> or <32:xonly>
|
||||
|
||||
@property
|
||||
def fingerprint(self):
|
||||
return self.origin.fingerprint
|
||||
|
||||
def serialize(self):
|
||||
return self.key_bytes()
|
||||
|
||||
def compile(self):
|
||||
d = self.serialize()
|
||||
return ser_compact_size(len(d)) + d
|
||||
|
||||
@classmethod
|
||||
def parse(cls, s):
|
||||
first = s.read(1)
|
||||
origin = None
|
||||
if first == b"[":
|
||||
prefix, char = read_until(s, b"]")
|
||||
if char != b"]":
|
||||
raise ValueError("Invalid key - missing ] in key origin info")
|
||||
origin = KeyOriginInfo.from_string(prefix.decode())
|
||||
else:
|
||||
s.seek(-1, 1)
|
||||
k, char = read_until(s, b",)/")
|
||||
der = b""
|
||||
if char == b"/":
|
||||
der, char = read_until(s, b"<,)")
|
||||
if char == b"<":
|
||||
der += b"<"
|
||||
branch, char = read_until(s, b">")
|
||||
if char is None:
|
||||
raise ValueError("Failed reading the key, missing >")
|
||||
der += branch + b">"
|
||||
rest, char = read_until(s, b",)")
|
||||
der += rest
|
||||
if char is not None:
|
||||
s.seek(-1, 1)
|
||||
# parse key
|
||||
node, chain_type = cls.parse_key(k)
|
||||
der = KeyDerivationInfo.from_string(der.decode())
|
||||
return cls(node, origin, der, chain_type=chain_type)
|
||||
|
||||
@classmethod
|
||||
def parse_key(cls, key_str):
|
||||
chain_type = None
|
||||
if key_str[1:4].lower() == b"pub":
|
||||
# extended key
|
||||
# or xpub or tpub as we use descriptors (SLIP-132 NOT allowed)
|
||||
hint = key_str[0:1].lower()
|
||||
if hint == b"x":
|
||||
chain_type = "BTC"
|
||||
else:
|
||||
assert hint == b"t", "no slip"
|
||||
chain_type = "XTN"
|
||||
node = ngu.hdnode.HDNode()
|
||||
node.deserialize(key_str)
|
||||
else:
|
||||
# only unspendable keys can be bare pubkeys - for now
|
||||
# TODO
|
||||
# if b"unspend(" in key_str:
|
||||
# node = ngu.hdnode.HDNode()
|
||||
# chain_code = key_str.replace(b"unspend(", b"").replace(b")", b"")
|
||||
# node.chaincode = a2b_hex(chain_code)
|
||||
# node.pubkey = a2b_hex("02" + PROVABLY_UNSPENDABLE)
|
||||
H = a2b_hex(PROVABLY_UNSPENDABLE)
|
||||
if b"r=" in key_str:
|
||||
_, r = key_str.split(b"=")
|
||||
if r == b"@":
|
||||
# pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG
|
||||
kp = ngu.secp256k1.keypair()
|
||||
else:
|
||||
# H + rG where r is provided from user
|
||||
r = a2b_hex(r)
|
||||
assert len(r) == 32, "r != 32"
|
||||
kp = ngu.secp256k1.keypair(r)
|
||||
|
||||
H_xo = ngu.secp256k1.xonly_pubkey(H)
|
||||
|
||||
node = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()).to_bytes()
|
||||
|
||||
elif a2b_hex(key_str) == H:
|
||||
node = H
|
||||
else:
|
||||
node = a2b_hex(key_str)
|
||||
|
||||
assert len(node) == 32, "invalid pk %d %s" % (len(node), node)
|
||||
|
||||
return node, chain_type
|
||||
|
||||
def derive(self, idx=None, change=False):
|
||||
if isinstance(self.node, bytes):
|
||||
return self
|
||||
if isinstance(idx, list):
|
||||
for i in idx:
|
||||
mp_i = self.derivation.multi_path_index or 0
|
||||
if i in self.derivation.indexes[mp_i]:
|
||||
idx = i
|
||||
break
|
||||
else:
|
||||
assert False
|
||||
|
||||
elif idx is None:
|
||||
# derive according to key subderivation if any
|
||||
if self.derivation is None:
|
||||
idx = 1 if change else 0
|
||||
else:
|
||||
if self.derivation.multi_path_index is not None:
|
||||
ext, inter = self.derivation.indexes[self.derivation.multi_path_index]
|
||||
idx = inter if change else ext
|
||||
|
||||
new_node = self.node.copy()
|
||||
new_node.derive(idx, False)
|
||||
if self.origin:
|
||||
origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx])
|
||||
else:
|
||||
origin = KeyOriginInfo(self.node.my_fp(), [idx])
|
||||
# empty derivation
|
||||
derivation = None
|
||||
return type(self)(new_node, origin, derivation, taproot=self.taproot)
|
||||
|
||||
@classmethod
|
||||
def read_from(cls, s, taproot=False):
|
||||
return cls.parse(s)
|
||||
|
||||
@classmethod
|
||||
def from_cc_data(cls, xfp, deriv, xpub):
|
||||
koi = KeyOriginInfo.from_string("%s/%s" % (xfp2str(xfp), deriv.replace("m/", "")))
|
||||
node = ngu.hdnode.HDNode()
|
||||
node.deserialize(xpub)
|
||||
return cls(node, koi, KeyDerivationInfo())
|
||||
|
||||
def to_cc_data(self):
|
||||
ch = chains.current_chain()
|
||||
return (self.origin.cc_fp,
|
||||
self.origin.str_derivation(),
|
||||
ch.serialize_public(self.node, AF_CLASSIC))
|
||||
|
||||
@property
|
||||
def is_provably_unspendable(self):
|
||||
if isinstance(self.node, bytes):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def prefix(self):
|
||||
if self.origin:
|
||||
return "[%s]" % self.origin
|
||||
return ""
|
||||
|
||||
def key_bytes(self):
|
||||
kb = self.node
|
||||
if not isinstance(kb, bytes):
|
||||
kb = self.node.pubkey()
|
||||
if self.taproot:
|
||||
if len(kb) == 33:
|
||||
kb = kb[1:]
|
||||
assert len(kb) == 32
|
||||
return kb
|
||||
|
||||
def extended_public_key(self):
|
||||
return chains.current_chain().serialize_public(self.node)
|
||||
|
||||
def to_string(self, external=True, internal=True, subderiv=True):
|
||||
key = self.prefix
|
||||
if isinstance(self.node, ngu.hdnode.HDNode):
|
||||
key += self.extended_public_key()
|
||||
if self.derivation and subderiv:
|
||||
key += "/" + self.derivation.to_string(external, internal)
|
||||
else:
|
||||
key += b2a_hex(self.node).decode()
|
||||
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s):
|
||||
s = BytesIO(s.encode())
|
||||
return cls.parse(s)
|
||||
|
||||
|
||||
def fill_policy(policy, keys, external=True, internal=True):
|
||||
keys_len = len(keys)
|
||||
for i in range(keys_len - 1, -1, -1):
|
||||
k = keys[i]
|
||||
ph = "@%d" % i
|
||||
ph_len = len(ph)
|
||||
while True:
|
||||
subderiv = True
|
||||
ix = policy.find(ph)
|
||||
if ix == -1:
|
||||
break
|
||||
if policy[ix+ph_len] == "/":
|
||||
# subderivation is part of the policy
|
||||
subderiv = False
|
||||
x = ix + ph_len
|
||||
substr = policy[x:x+26] # 26 is longest possible subderivation allowed "/<2147483647;2147483646>/*"
|
||||
mp_start = substr.find("<")
|
||||
assert mp_start != -1
|
||||
mp_end = substr.find(">")
|
||||
mp = substr[mp_start:mp_end + 1]
|
||||
_ext, _int = mp[1:-1].split(";")
|
||||
if external and not internal:
|
||||
sub = _ext
|
||||
elif internal and not external:
|
||||
sub = _int
|
||||
else:
|
||||
sub = None
|
||||
if sub is not None:
|
||||
policy = policy[:x + mp_start] + sub + policy[x + mp_end + 1:]
|
||||
|
||||
if not isinstance(k, str):
|
||||
k_str = k.to_string(external, internal, subderiv=subderiv)
|
||||
else:
|
||||
k_str = k
|
||||
if not subderiv:
|
||||
k_str = "/".join(k_str.split("/")[:-2])
|
||||
mp_start = k_str.find("<")
|
||||
if mp_start != -1:
|
||||
mp_end = k_str.find(">")
|
||||
mp = k_str[mp_start:mp_end+1]
|
||||
ext, int = mp[1:-1].split(";")
|
||||
if external and not internal:
|
||||
k_str = k_str.replace(mp, ext)
|
||||
if internal and not external:
|
||||
k_str = k_str.replace(mp, int)
|
||||
|
||||
x = policy[ix:ix + ph_len]
|
||||
assert x == ph
|
||||
policy = policy[:ix] + k_str + policy[ix + ph_len:]
|
||||
return policy
|
||||
|
||||
|
||||
def taproot_tree_helper(scripts):
|
||||
from miniscript import Miniscript
|
||||
|
||||
if isinstance(scripts, Miniscript):
|
||||
script = scripts.compile()
|
||||
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 = [(version, script, control + right_h) for version, script, control in left]
|
||||
right = [(version, script, control + left_h) for version, script, control in right]
|
||||
if right_h < left_h:
|
||||
right_h, left_h = left_h, right_h
|
||||
h = ngu.secp256k1.tagged_sha256(b"TapBranch", left_h + right_h)
|
||||
return left + right, h
|
||||
@ -1,356 +1,619 @@
|
||||
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# descriptor.py - Bitcoin Core's descriptors and their specialized checksums.
|
||||
# Copyright (c) 2020 Stepan Snigirev MIT License embit/descriptor.py
|
||||
#
|
||||
# Based on: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
|
||||
#
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH
|
||||
import ngu, chains
|
||||
from io import BytesIO
|
||||
from collections import OrderedDict
|
||||
from binascii import hexlify as b2a_hex
|
||||
from utils import cleanup_deriv_path, check_xpub, xfp2str
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
|
||||
from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, MAX_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
|
||||
from miniscript import Miniscript
|
||||
|
||||
MULTI_FMT_TO_SCRIPT = {
|
||||
AF_P2SH: "sh(%s)",
|
||||
AF_P2WSH_P2SH: "sh(wsh(%s))",
|
||||
AF_P2WSH: "wsh(%s)",
|
||||
None: "wsh(%s)",
|
||||
# hack for tests
|
||||
"p2sh": "sh(%s)",
|
||||
"p2sh-p2wsh": "sh(wsh(%s))",
|
||||
"p2wsh-p2sh": "sh(wsh(%s))",
|
||||
"p2wsh": "wsh(%s)",
|
||||
}
|
||||
|
||||
SINGLE_FMT_TO_SCRIPT = {
|
||||
AF_P2WPKH: "wpkh(%s)",
|
||||
AF_CLASSIC: "pkh(%s)",
|
||||
AF_P2WPKH_P2SH: "sh(wpkh(%s))",
|
||||
None: "wpkh(%s)",
|
||||
"p2pkh": "pkh(%s)",
|
||||
"p2wpkh": "wpkh(%s)",
|
||||
"p2sh-p2wpkh": "sh(wpkh(%s))",
|
||||
"p2wpkh-p2sh": "sh(wpkh(%s))",
|
||||
}
|
||||
|
||||
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
|
||||
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
try:
|
||||
from utils import xfp2str, str2xfp
|
||||
except ModuleNotFoundError:
|
||||
import struct
|
||||
from binascii import unhexlify as a2b_hex
|
||||
from binascii import hexlify as b2a_hex
|
||||
# assuming not micro python
|
||||
def xfp2str(xfp):
|
||||
# Standardized way to show an xpub's fingerprint... it's a 4-byte string
|
||||
# and not really an integer. Used to show as '0x%08x' but that's wrong endian.
|
||||
return b2a_hex(struct.pack('<I', xfp)).decode().upper()
|
||||
|
||||
def str2xfp(txt):
|
||||
# Inverse of xfp2str
|
||||
return struct.unpack('<I', a2b_hex(txt))[0]
|
||||
class DescriptorException(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class WrongCheckSumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def polymod(c, val):
|
||||
c0 = c >> 35
|
||||
c = ((c & 0x7ffffffff) << 5) ^ val
|
||||
if (c0 & 1):
|
||||
c ^= 0xf5dee51989
|
||||
if (c0 & 2):
|
||||
c ^= 0xa9fdca3312
|
||||
if (c0 & 4):
|
||||
c ^= 0x1bab10e32d
|
||||
if (c0 & 8):
|
||||
c ^= 0x3706b1677a
|
||||
if (c0 & 16):
|
||||
c ^= 0x644d626ffd
|
||||
class Tapscript:
|
||||
def __init__(self, tree=None, keys=None, policy=None):
|
||||
self.tree = tree
|
||||
self.keys = keys
|
||||
self.policy = policy
|
||||
self._merkle_root = None
|
||||
|
||||
return c
|
||||
@staticmethod
|
||||
def iter_leaves(tree):
|
||||
if isinstance(tree, Miniscript):
|
||||
yield tree
|
||||
else:
|
||||
assert isinstance(tree, list)
|
||||
for lv in tree:
|
||||
yield from Tapscript.iter_leaves(lv)
|
||||
|
||||
def descriptor_checksum(desc):
|
||||
c = 1
|
||||
cls = 0
|
||||
clscount = 0
|
||||
for ch in desc:
|
||||
pos = INPUT_CHARSET.find(ch)
|
||||
if pos == -1:
|
||||
raise ValueError(ch)
|
||||
@property
|
||||
def merkle_root(self):
|
||||
if not self._merkle_root:
|
||||
self.process_tree()
|
||||
return self._merkle_root
|
||||
|
||||
c = polymod(c, pos & 31)
|
||||
cls = cls * 3 + (pos >> 5)
|
||||
clscount += 1
|
||||
if clscount == 3:
|
||||
c = polymod(c, cls)
|
||||
cls = 0
|
||||
clscount = 0
|
||||
@staticmethod
|
||||
def _derive(tree, idx, key_map, change=False):
|
||||
if isinstance(tree, Miniscript):
|
||||
return 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)]
|
||||
|
||||
if clscount > 0:
|
||||
c = polymod(c, cls)
|
||||
for j in range(0, 8):
|
||||
c = polymod(c, 0)
|
||||
c ^= 1
|
||||
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()))
|
||||
|
||||
rv = ''
|
||||
for j in range(0, 8):
|
||||
rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
|
||||
def process_tree(self):
|
||||
info, mr = taproot_tree_helper(self.tree)
|
||||
self._merkle_root = mr
|
||||
return info, mr
|
||||
|
||||
return rv
|
||||
@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
|
||||
|
||||
def append_checksum(desc):
|
||||
return desc + "#" + descriptor_checksum(desc)
|
||||
depth += 1
|
||||
continue
|
||||
elif p1 == b"}":
|
||||
depth -= 1
|
||||
if depth == 1:
|
||||
tapscript.append(itmp)
|
||||
itmp = None
|
||||
|
||||
if depth <= 2:
|
||||
itmp_p = None
|
||||
continue
|
||||
|
||||
def parse_desc_str(string):
|
||||
"""Remove comments, empty lines and strip line. Produce single line string"""
|
||||
res = ""
|
||||
for l in string.split("\n"):
|
||||
strip_l = l.strip()
|
||||
if not strip_l:
|
||||
continue
|
||||
if strip_l.startswith("#"):
|
||||
continue
|
||||
res += strip_l
|
||||
return res
|
||||
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
|
||||
|
||||
def multisig_descriptor_template(xpub, path, xfp, addr_fmt):
|
||||
key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub)
|
||||
if addr_fmt == AF_P2WSH_P2SH:
|
||||
descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))"
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
descriptor_template = "wsh(sortedmulti(M,%s,...))"
|
||||
elif addr_fmt == AF_P2SH:
|
||||
descriptor_template = "sh(sortedmulti(M,%s,...))"
|
||||
else:
|
||||
return None
|
||||
descriptor_template = descriptor_template % key_exp
|
||||
return descriptor_template
|
||||
def parse_policy(self):
|
||||
self.policy, self.keys = self._parse_policy(self.tree, [])
|
||||
orig_keys = OrderedDict()
|
||||
for k in self.keys:
|
||||
if k.origin not in orig_keys:
|
||||
orig_keys[k.origin] = []
|
||||
orig_keys[k.origin].append(k)
|
||||
for i, k_lst in enumerate(orig_keys.values()):
|
||||
subderiv = True if len(k_lst) == 1 else False
|
||||
self.policy = self.policy.replace(k_lst[0].to_string(subderiv=subderiv), chr(64) + str(i))
|
||||
|
||||
@staticmethod
|
||||
def _parse_policy(tree, all_keys):
|
||||
if isinstance(tree, Miniscript):
|
||||
keys, leaf_str = tree.keys, 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)
|
||||
|
||||
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()
|
||||
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 + "}"
|
||||
|
||||
def to_string(self, external=True, internal=True):
|
||||
return fill_policy(self.policy, self.keys, external, internal)
|
||||
|
||||
|
||||
class Descriptor:
|
||||
__slots__ = (
|
||||
"keys",
|
||||
"addr_fmt",
|
||||
)
|
||||
def __init__(self, miniscript=None, sh=False, wsh=True, key=None, wpkh=True,
|
||||
taproot=False, tapscript=None):
|
||||
if key is None and miniscript is None:
|
||||
raise DescriptorException("Provide either miniscript or a key")
|
||||
|
||||
def __init__(self, keys, addr_fmt):
|
||||
self.keys = keys
|
||||
self.addr_fmt = addr_fmt
|
||||
self.sh = sh
|
||||
self.wsh = wsh
|
||||
self.key = key
|
||||
self.miniscript = miniscript
|
||||
self.wpkh = wpkh
|
||||
self.taproot = taproot
|
||||
self.tapscript = tapscript
|
||||
|
||||
if taproot:
|
||||
if self.key:
|
||||
self.key.taproot = True
|
||||
for k in self.keys:
|
||||
k.taproot = taproot
|
||||
|
||||
def legacy_ms_compat(self):
|
||||
if not (self.is_sortedmulti and self.addr_fmt in (AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH)):
|
||||
raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. "
|
||||
"MUST be sortedmulti.")
|
||||
|
||||
def validate(self):
|
||||
from glob import settings
|
||||
if self.miniscript:
|
||||
if self.is_basic_multisig:
|
||||
assert len(self.keys) <= MAX_SIGNERS
|
||||
else:
|
||||
assert len(self.keys) <= 20
|
||||
self.miniscript.verify()
|
||||
if self.miniscript.type != "B":
|
||||
raise DescriptorException("Top level miniscript should be 'B'")
|
||||
|
||||
has_mine = 0
|
||||
my_xfp = settings.get('xfp')
|
||||
to_check = self.keys.copy()
|
||||
if self.tapscript:
|
||||
assert len(self.keys) <= MAX_TR_SIGNERS
|
||||
assert self.key # internal key (would fail during parse)
|
||||
if not isinstance(self.key.node, bytes):
|
||||
to_check += [self.key]
|
||||
else:
|
||||
assert self.key is None and self.miniscript, "not miniscript"
|
||||
|
||||
c = chains.current_key_chain().ctype
|
||||
for k in to_check:
|
||||
assert k.chain_type == c, "wrong chain"
|
||||
xfp = k.origin.cc_fp
|
||||
deriv = k.origin.str_derivation()
|
||||
xpub = k.extended_public_key()
|
||||
deriv = cleanup_deriv_path(deriv)
|
||||
is_mine, _ = check_xpub(xfp, xpub, deriv, c, my_xfp, False)
|
||||
if is_mine:
|
||||
has_mine += 1
|
||||
|
||||
assert has_mine != 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper()
|
||||
|
||||
def storage_policy(self):
|
||||
if self.tapscript:
|
||||
return self.tapscript.policy
|
||||
|
||||
s = self.miniscript.to_string()
|
||||
orig_keys = OrderedDict()
|
||||
for k in self.keys:
|
||||
if k.origin not in orig_keys:
|
||||
orig_keys[k.origin] = []
|
||||
orig_keys[k.origin].append(k)
|
||||
for i, k_lst in enumerate(orig_keys.values()):
|
||||
subderiv = True if len(k_lst) == 1 else False
|
||||
s = s.replace(k_lst[0].to_string(subderiv=subderiv), chr(64) + str(i))
|
||||
return s
|
||||
|
||||
def ux_policy(self):
|
||||
if self.tapscript:
|
||||
return "Taproot tree keys:\n\n" + self.tapscript.policy
|
||||
|
||||
return self.storage_policy()
|
||||
|
||||
@property
|
||||
def script_len(self):
|
||||
if self.taproot:
|
||||
return 34 # OP_1 <32:xonly>
|
||||
if self.miniscript:
|
||||
return len(self.miniscript)
|
||||
if self.wpkh:
|
||||
return 22 # 00 <20:pkh>
|
||||
return 25 # OP_DUP OP_HASH160 <20:pkh> OP_EQUALVERIFY OP_CHECKSIG
|
||||
|
||||
def xfp_paths(self):
|
||||
keys = self.keys
|
||||
if self.taproot and self.key.origin:
|
||||
# ignore provably unspendable
|
||||
keys += [self.key]
|
||||
|
||||
return [
|
||||
key.origin.psbt_derivation()
|
||||
for key in keys
|
||||
if key.origin
|
||||
]
|
||||
|
||||
@property
|
||||
def is_wrapped(self):
|
||||
return self.sh and self.is_segwit
|
||||
|
||||
@property
|
||||
def is_legacy(self):
|
||||
return not (self.is_segwit or self.is_taproot)
|
||||
|
||||
@property
|
||||
def is_segwit(self):
|
||||
return (self.wsh and self.miniscript) or (self.wpkh and self.key) or self.taproot
|
||||
|
||||
@property
|
||||
def is_pkh(self):
|
||||
return self.key is not None and not self.taproot
|
||||
|
||||
@property
|
||||
def is_taproot(self):
|
||||
return self.taproot
|
||||
|
||||
@property
|
||||
def is_basic_multisig(self):
|
||||
return self.miniscript and self.miniscript.NAME in ["multi", "sortedmulti"]
|
||||
|
||||
@property
|
||||
def is_sortedmulti(self):
|
||||
return self.is_basic_multisig and self.miniscript.NAME == "sortedmulti"
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
if self.tapscript:
|
||||
return self.tapscript.keys
|
||||
elif self.key:
|
||||
return [self.key]
|
||||
return self.miniscript.keys
|
||||
|
||||
@property
|
||||
def addr_fmt(self):
|
||||
if self.sh and not self.wsh:
|
||||
af = AF_P2SH
|
||||
elif self.wsh and not self.sh:
|
||||
af = AF_P2WSH
|
||||
elif self.sh and self.wsh:
|
||||
af = AF_P2WSH_P2SH
|
||||
elif self.taproot:
|
||||
af = AF_P2TR
|
||||
elif self.sh and self.wpkh:
|
||||
af = AF_P2WPKH_P2SH
|
||||
elif self.wpkh and not self.sh:
|
||||
af = AF_P2WPKH
|
||||
else:
|
||||
af = AF_CLASSIC
|
||||
return af
|
||||
|
||||
def set_from_addr_fmt(self, addr_fmt):
|
||||
self.taproot = False
|
||||
self.wsh = False
|
||||
self.wpkh = False
|
||||
self.sh = False
|
||||
if addr_fmt == AF_P2TR:
|
||||
self.taproot = True
|
||||
assert self.key
|
||||
elif addr_fmt == AF_P2WPKH:
|
||||
self.wpkh = True
|
||||
self.miniscript = None
|
||||
assert self.key
|
||||
elif addr_fmt == AF_P2WPKH_P2SH:
|
||||
self.wpkh = True
|
||||
self.sh = True
|
||||
self.miniscript = None
|
||||
assert self.key
|
||||
elif addr_fmt == AF_P2SH:
|
||||
self.sh = True
|
||||
assert self.miniscript
|
||||
assert not self.key
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
self.wsh = True
|
||||
assert self.miniscript
|
||||
assert not self.key
|
||||
elif addr_fmt == AF_P2WSH_P2SH:
|
||||
self.wsh = True
|
||||
self.sh = True
|
||||
assert self.miniscript
|
||||
assert not self.key
|
||||
else:
|
||||
# AF_CLASSIC
|
||||
assert self.key
|
||||
assert not self.miniscript
|
||||
|
||||
def scriptpubkey_type(self):
|
||||
if self.is_taproot:
|
||||
return "p2tr"
|
||||
if self.sh:
|
||||
return "p2sh"
|
||||
if self.is_pkh:
|
||||
if self.is_legacy:
|
||||
return "p2pkh"
|
||||
if self.is_segwit:
|
||||
return "p2wpkh"
|
||||
else:
|
||||
return "p2wsh"
|
||||
|
||||
def derive(self, idx=None, change=False):
|
||||
if self.taproot:
|
||||
return type(self)(
|
||||
None,
|
||||
self.sh,
|
||||
self.wsh,
|
||||
self.key.derive(idx, change=change),
|
||||
self.wpkh,
|
||||
self.taproot,
|
||||
tapscript=self.tapscript.derive(idx, change=change),
|
||||
)
|
||||
if self.miniscript:
|
||||
return type(self)(
|
||||
self.miniscript.derive(idx, change=change),
|
||||
self.sh,
|
||||
self.wsh,
|
||||
None,
|
||||
self.wpkh,
|
||||
self.taproot,
|
||||
tapscript=None,
|
||||
)
|
||||
else:
|
||||
return type(self)(
|
||||
None, self.sh, self.wsh,
|
||||
self.key.derive(idx, change=change),
|
||||
self.wpkh, self.taproot, tapscript=None
|
||||
)
|
||||
|
||||
def witness_script(self):
|
||||
if self.wsh and self.miniscript is not None:
|
||||
return self.miniscript.compile()
|
||||
|
||||
def redeem_script(self):
|
||||
if not self.sh:
|
||||
return None
|
||||
if self.miniscript:
|
||||
if self.wsh:
|
||||
return b"\x00\x20" + ngu.hash.sha256s(self.miniscript.compile())
|
||||
else:
|
||||
return self.miniscript.compile()
|
||||
|
||||
else:
|
||||
return b"\x00\x14" + ngu.hash.hash160(self.key.node.pubkey())
|
||||
|
||||
def script_pubkey(self):
|
||||
if self.taproot:
|
||||
tweak = None
|
||||
if self.tapscript:
|
||||
tweak = self.tapscript.merkle_root
|
||||
output_pubkey = chains.taptweak(self.key.serialize(), tweak)
|
||||
return b"\x51\x20" + output_pubkey
|
||||
if self.sh:
|
||||
return b"\xa9\x14" + ngu.hash.hash160(self.redeem_script()) + b"\x87"
|
||||
if self.wsh:
|
||||
return b"\x00\x20" + ngu.hash.sha256s(self.witness_script())
|
||||
if self.miniscript:
|
||||
return self.miniscript.compile()
|
||||
if self.wpkh:
|
||||
return b"\x00\x14" + ngu.hash.hash160(self.key.serialize())
|
||||
return b"\x76\xa9\x14" + ngu.hash.hash160(self.key.serialize()) + b"\x88\xac"
|
||||
|
||||
@classmethod
|
||||
def is_descriptor(cls, desc_str):
|
||||
"""Quick method to guess whether this is a descriptor"""
|
||||
try:
|
||||
temp = parse_desc_str(desc_str)
|
||||
except:
|
||||
return False
|
||||
|
||||
for prefix in ("pk(", "pkh(", "wpkh(", "tr(", "addr(", "raw(", "rawtr(", "combo(",
|
||||
"sh(", "wsh(", "multi(", "sortedmulti(", "multi_a(", "sortedmulti_a("):
|
||||
if temp.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def checksum_check(desc_w_checksum: str):
|
||||
def checksum_check(desc_w_checksum, csum_required=False):
|
||||
try:
|
||||
desc, checksum = desc_w_checksum.split("#")
|
||||
except ValueError:
|
||||
raise ValueError("Missing descriptor checksum")
|
||||
if csum_required:
|
||||
raise ValueError("Missing descriptor checksum")
|
||||
return desc_w_checksum, None
|
||||
calc_checksum = descriptor_checksum(desc)
|
||||
if calc_checksum != checksum:
|
||||
raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
|
||||
return desc, checksum
|
||||
|
||||
@staticmethod
|
||||
def parse_key_orig_info(key: str):
|
||||
# key origin info is required for our MultisigWallet
|
||||
close_index = key.find("]")
|
||||
if key[0] != "[" or close_index == -1:
|
||||
raise ValueError("Key origin info is required for %s" % (key))
|
||||
key_orig_info = key[1:close_index] # remove brackets
|
||||
key = key[close_index + 1:]
|
||||
assert "/" in key_orig_info, "Malformed key derivation info"
|
||||
return key_orig_info, key
|
||||
@classmethod
|
||||
def from_string(cls, desc, checksum=False):
|
||||
desc = parse_desc_str(desc)
|
||||
desc, cs = cls.checksum_check(desc)
|
||||
s = BytesIO(desc.encode())
|
||||
res = cls.read_from(s)
|
||||
left = s.read()
|
||||
if len(left) > 0:
|
||||
raise ValueError("Unexpected characters after descriptor: %r" % left)
|
||||
if checksum:
|
||||
if cs is None:
|
||||
_, cs = res.to_string().split("#")
|
||||
return res, cs
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def parse_key_derivation_info(key: str):
|
||||
invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed"
|
||||
slash_split = key.split("/")
|
||||
assert len(slash_split) > 1, invalid_subderiv_msg
|
||||
if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]):
|
||||
assert slash_split[-1] == "*", invalid_subderiv_msg
|
||||
assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg
|
||||
assert len(slash_split[1:]) == 2, invalid_subderiv_msg
|
||||
return slash_split[0]
|
||||
else:
|
||||
raise ValueError("Cannot use hardened sub derivation path")
|
||||
|
||||
def checksum(self):
|
||||
return descriptor_checksum(self._serialize())
|
||||
|
||||
def serialize_keys(self, internal=False, int_ext=False):
|
||||
result = []
|
||||
for xfp, deriv, xpub in self.keys:
|
||||
if deriv[0] == "m":
|
||||
# get rid of 'm'
|
||||
deriv = deriv[1:]
|
||||
elif deriv[0] != "/":
|
||||
# input "84'/0'/0'" would lack slash separtor with xfp
|
||||
deriv = "/" + deriv
|
||||
if not isinstance(xfp, str):
|
||||
xfp = xfp2str(xfp)
|
||||
koi = xfp + deriv
|
||||
# normalize xpub to use h for hardened instead of '
|
||||
key_str = "[%s]%s" % (koi.lower(), xpub)
|
||||
if int_ext:
|
||||
key_str = key_str + "/" + "<0;1>" + "/" + "*"
|
||||
@classmethod
|
||||
def read_from(cls, s, taproot=False):
|
||||
start = s.read(7)
|
||||
sh = False
|
||||
wsh = False
|
||||
wpkh = False
|
||||
is_miniscript = True
|
||||
internal_key = None
|
||||
tapscript = None
|
||||
if start.startswith(b"tr("):
|
||||
is_miniscript = False # miniscript vs. tapscript (that can contain miniscripts in tree)
|
||||
taproot = True
|
||||
s.seek(-4, 1)
|
||||
internal_key = Key.parse(s) # internal key is a must
|
||||
internal_key.taproot = True
|
||||
sep = s.read(1)
|
||||
if sep == b")":
|
||||
s.seek(-1, 1)
|
||||
else:
|
||||
key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"])
|
||||
result.append(key_str.replace("'", "h"))
|
||||
return result
|
||||
|
||||
def _serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize without checksum"""
|
||||
assert len(self.keys) == 1, "Multiple keys for single signature script"
|
||||
desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt]
|
||||
inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0]
|
||||
return desc_base % (inner)
|
||||
|
||||
def serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize with checksum"""
|
||||
return append_checksum(self._serialize(internal=internal, int_ext=int_ext))
|
||||
|
||||
@classmethod
|
||||
def parse(cls, desc_w_checksum: str) -> "Descriptor":
|
||||
# remove garbage
|
||||
desc_w_checksum = parse_desc_str(desc_w_checksum)
|
||||
# check correct checksum
|
||||
desc, checksum = cls.checksum_check(desc_w_checksum)
|
||||
# legacy
|
||||
if desc.startswith("pkh("):
|
||||
addr_fmt = AF_CLASSIC
|
||||
tmp_desc = desc.replace("pkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
# native segwit
|
||||
elif desc.startswith("wpkh("):
|
||||
addr_fmt = AF_P2WPKH
|
||||
tmp_desc = desc.replace("wpkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
# wrapped segwit
|
||||
elif desc.startswith("sh(wpkh("):
|
||||
addr_fmt = AF_P2WPKH_P2SH
|
||||
tmp_desc = desc.replace("sh(wpkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
assert sep == b","
|
||||
tapscript = Tapscript.read_from(s)
|
||||
elif start.startswith(b"sh(wsh("):
|
||||
sh = True
|
||||
wsh = True
|
||||
elif start.startswith(b"wsh("):
|
||||
sh = False
|
||||
wsh = True
|
||||
s.seek(-3, 1)
|
||||
elif start.startswith(b"sh(wpkh"):
|
||||
is_miniscript = False
|
||||
sh = True
|
||||
wpkh = True
|
||||
assert s.read(1) == b"("
|
||||
elif start.startswith(b"wpkh("):
|
||||
is_miniscript = False
|
||||
wpkh = True
|
||||
s.seek(-2, 1)
|
||||
elif start.startswith(b"pkh("):
|
||||
is_miniscript = False
|
||||
s.seek(-3, 1)
|
||||
elif start.startswith(b"sh("):
|
||||
sh = True
|
||||
wsh = False
|
||||
s.seek(-4, 1)
|
||||
else:
|
||||
raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.")
|
||||
raise ValueError("Invalid descriptor")
|
||||
|
||||
koi, key = cls.parse_key_orig_info(tmp_desc)
|
||||
if key[0:4] not in ["tpub", "xpub"]:
|
||||
raise ValueError("Only extended public keys are supported")
|
||||
if is_miniscript:
|
||||
miniscript = Miniscript.read_from(s)
|
||||
miniscript.is_sane(taproot=False)
|
||||
key = internal_key
|
||||
nbrackets = int(sh) + int(wsh)
|
||||
elif taproot:
|
||||
miniscript = None
|
||||
key = internal_key
|
||||
nbrackets = 1
|
||||
else:
|
||||
miniscript = None
|
||||
key = Key.parse(s)
|
||||
nbrackets = 1 + int(sh)
|
||||
|
||||
xpub = cls.parse_key_derivation_info(key)
|
||||
xfp = str2xfp(koi[:8])
|
||||
origin_deriv = "m" + koi[8:]
|
||||
end = s.read(nbrackets)
|
||||
if end != b")" * nbrackets:
|
||||
raise ValueError("Invalid descriptor")
|
||||
o = cls(miniscript, sh=sh, wsh=wsh, key=key, wpkh=wpkh,
|
||||
taproot=taproot, tapscript=tapscript)
|
||||
o.validate()
|
||||
return o
|
||||
|
||||
return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt)
|
||||
def to_string(self, external=True, internal=True, checksum=True):
|
||||
if self.taproot:
|
||||
desc = "tr(%s" % self.key.to_string(external, internal)
|
||||
if self.tapscript:
|
||||
desc += ","
|
||||
tree = self.tapscript.to_string(external, internal)
|
||||
desc += tree
|
||||
|
||||
@classmethod
|
||||
def is_descriptor(cls, desc_str):
|
||||
"""Method to guess whether this can be a descriptor"""
|
||||
try:
|
||||
temp = parse_desc_str(desc_str)
|
||||
desc, checksum = temp.split("#")
|
||||
assert desc[-1] == ")"
|
||||
desc = desc + ")"
|
||||
return append_checksum(desc)
|
||||
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
if self.miniscript is not None:
|
||||
res = self.miniscript.to_string(external, internal)
|
||||
if self.wsh:
|
||||
res = "wsh(%s)" % res
|
||||
else:
|
||||
if self.wpkh:
|
||||
res = "wpkh(%s)" % self.key.to_string(external, internal)
|
||||
else:
|
||||
res = "pkh(%s)" % self.key.to_string(external, internal)
|
||||
if self.sh:
|
||||
res = "sh(%s)" % res
|
||||
|
||||
def bitcoin_core_serialize(self, external_label=None):
|
||||
if checksum:
|
||||
res = append_checksum(res)
|
||||
return res
|
||||
|
||||
def bitcoin_core_serialize(self):
|
||||
# this will become legacy one day
|
||||
# instead use <0;1> descriptor format
|
||||
res = []
|
||||
for internal in [False, True]:
|
||||
for external, internal in [(True, False), (False, True)]:
|
||||
desc_obj = {
|
||||
"desc": self.serialize(internal=internal),
|
||||
"desc": self.to_string(external, internal),
|
||||
"active": True,
|
||||
"timestamp": "now",
|
||||
"internal": internal,
|
||||
"range": [0, 100],
|
||||
}
|
||||
if internal is False and external_label:
|
||||
desc_obj["label"] = external_label
|
||||
res.append(desc_obj)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class MultisigDescriptor(Descriptor):
|
||||
# only supprt with key derivation info
|
||||
# only xpubs
|
||||
# can be extended when needed
|
||||
__slots__ = (
|
||||
"M",
|
||||
"N",
|
||||
"keys",
|
||||
"addr_fmt",
|
||||
)
|
||||
|
||||
def __init__(self, M, N, keys, addr_fmt):
|
||||
self.M = M
|
||||
self.N = N
|
||||
super().__init__(keys, addr_fmt)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, desc_w_checksum: str) -> "MultisigDescriptor":
|
||||
# remove garbage
|
||||
desc_w_checksum = parse_desc_str(desc_w_checksum)
|
||||
# check correct checksum
|
||||
desc, checksum = cls.checksum_check(desc_w_checksum)
|
||||
# legacy
|
||||
if desc.startswith("sh(sortedmulti("):
|
||||
addr_fmt = AF_P2SH
|
||||
tmp_desc = desc.replace("sh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
# native segwit
|
||||
elif desc.startswith("wsh(sortedmulti("):
|
||||
addr_fmt = AF_P2WSH
|
||||
tmp_desc = desc.replace("wsh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
# wrapped segwit
|
||||
elif desc.startswith("sh(wsh(sortedmulti("):
|
||||
addr_fmt = AF_P2WSH_P2SH
|
||||
tmp_desc = desc.replace("sh(wsh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")))")
|
||||
|
||||
else:
|
||||
raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. All have to be sortedmulti.")
|
||||
|
||||
splitted = tmp_desc.split(",")
|
||||
M, keys = int(splitted[0]), splitted[1:]
|
||||
N = int(len(keys))
|
||||
if M > N:
|
||||
raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N))
|
||||
|
||||
res_keys = []
|
||||
for key in keys:
|
||||
koi, key = cls.parse_key_orig_info(key)
|
||||
if key[0:4] not in ["tpub", "xpub"]:
|
||||
raise ValueError("Only extended public keys are supported")
|
||||
|
||||
xpub = cls.parse_key_derivation_info(key)
|
||||
xfp = str2xfp(koi[:8])
|
||||
origin_deriv = "m" + koi[8:]
|
||||
res_keys.append((xfp, origin_deriv, xpub))
|
||||
|
||||
return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt)
|
||||
|
||||
def _serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize without checksum"""
|
||||
desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt]
|
||||
desc_base = desc_base % ("sortedmulti(%s)")
|
||||
assert len(self.keys) == self.N
|
||||
inner = str(self.M) + "," + ",".join(
|
||||
self.serialize_keys(internal=internal, int_ext=int_ext))
|
||||
|
||||
return desc_base % (inner)
|
||||
|
||||
def pretty_serialize(self) -> str:
|
||||
def pretty_serialize(self):
|
||||
# TODO not enabled
|
||||
"""Serialize in pretty and human-readable format"""
|
||||
inner_ident = 1
|
||||
res = "# Coldcard descriptor export\n"
|
||||
res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n"
|
||||
if self.addr_fmt == AF_P2SH:
|
||||
@ -365,23 +628,36 @@ class MultisigDescriptor(Descriptor):
|
||||
elif self.addr_fmt == AF_P2WSH_P2SH:
|
||||
res += "# wrapped segwit - p2sh-p2wsh\n"
|
||||
res += "sh(wsh(sortedmulti(\n%s\n)))"
|
||||
|
||||
elif self.addr_fmt == AF_P2TR:
|
||||
inner_ident = 2
|
||||
res += "# taproot multisig - p2tr\n"
|
||||
res += "tr(\n"
|
||||
if isinstance(self.internal_key, str):
|
||||
res += "\t" + "# internal key (provably unspendable)\n"
|
||||
res += "\t" + self.internal_key + ",\n"
|
||||
res += "\t" + "sortedmulti_a(\n%s\n))"
|
||||
else:
|
||||
ik_ser = self.serialize_keys(keys=[self.internal_key])[0]
|
||||
res += "\t" + "# internal key\n"
|
||||
res += "\t" + ik_ser + ",\n"
|
||||
res += "\t" + "sortedmulti_a(\n%s\n))"
|
||||
else:
|
||||
raise ValueError("Malformed descriptor")
|
||||
|
||||
assert len(self.keys) == self.N
|
||||
inner = "\t" + "# %d of %d (%s)\n" % (
|
||||
inner = ("\t" * inner_ident) + "# %d of %d (%s)\n" % (
|
||||
self.M, self.N,
|
||||
"requires all participants to sign" if self.M == self.N else "threshold")
|
||||
inner += "\t" + str(self.M) + ",\n"
|
||||
inner += ("\t" * inner_ident) + str(self.M) + ",\n"
|
||||
ser_keys = self.serialize_keys()
|
||||
for i, key_str in enumerate(ser_keys, start=1):
|
||||
if i == self.N:
|
||||
inner += "\t" + key_str
|
||||
inner += ("\t" * inner_ident) + key_str
|
||||
else:
|
||||
inner += "\t" + key_str + ",\n"
|
||||
inner += ("\t" * inner_ident) + key_str + ",\n"
|
||||
|
||||
checksum = self.serialize().split("#")[1]
|
||||
|
||||
return (res % inner) + "#" + checksum
|
||||
|
||||
# EOF
|
||||
|
||||
@ -2,18 +2,17 @@
|
||||
#
|
||||
# display.py - OLED rendering
|
||||
#
|
||||
import machine, ssd1306, uzlib, ckcc, utime
|
||||
import machine, uzlib, ckcc, utime
|
||||
from ssd1306 import SSD1306_SPI
|
||||
from version import is_devmode
|
||||
from version import is_devmode, is_edge
|
||||
import framebuf
|
||||
import uasyncio
|
||||
from uasyncio import sleep_ms
|
||||
from graphics import Graphics
|
||||
from sram2 import display2_buf
|
||||
|
||||
# we support 4 fonts
|
||||
from zevvpeep import FontSmall, FontLarge, FontTiny
|
||||
FontFixed = object() # ugly 8x8 PET font
|
||||
display2_buf = bytearray(1024)
|
||||
|
||||
|
||||
class Display:
|
||||
|
||||
@ -134,10 +133,16 @@ class Display:
|
||||
self.dis.fill_rect(128-2, pos, 1, 8, 1)
|
||||
|
||||
if is_devmode and not ckcc.is_simulator():
|
||||
self.dis.fill_rect(128-6, 20, 5, 21, 1)
|
||||
self.text(-2, 21, 'D', font=FontTiny, invert=1)
|
||||
self.text(-2, 28, 'E', font=FontTiny, invert=1)
|
||||
self.text(-2, 35, 'V', font=FontTiny, invert=1)
|
||||
self.dis.fill_rect(128-5, 20, 5, 21, 1)
|
||||
self.text(-1, 21, 'D', font=FontTiny, invert=1)
|
||||
self.text(-1, 28, 'E', font=FontTiny, invert=1)
|
||||
self.text(-1, 35, 'V', font=FontTiny, invert=1)
|
||||
elif is_edge:
|
||||
self.dis.fill_rect(128-5, 19, 5, 26, 1)
|
||||
self.text(-1, 20, 'E', font=FontTiny, invert=1)
|
||||
self.text(-1, 27, 'D', font=FontTiny, invert=1)
|
||||
self.text(-1, 33, 'G', font=FontTiny, invert=1)
|
||||
self.text(-1, 39, 'E', font=FontTiny, invert=1)
|
||||
|
||||
def fullscreen(self, msg, percent=None, line2=None):
|
||||
# show a simple message "fullscreen".
|
||||
|
||||
@ -12,12 +12,13 @@ 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
|
||||
from utils import chunk_writer, xfp2str
|
||||
|
||||
|
||||
BIP85_PWD_LEN = 21
|
||||
|
||||
def drv_entro_start(*a):
|
||||
async def drv_entro_start(*a):
|
||||
from pincodes import pa
|
||||
|
||||
# UX entry
|
||||
ch = await ux_show_story('''\
|
||||
@ -36,8 +37,14 @@ so the other wallet is effectively segregated from the Coldcard and yet \
|
||||
still backed-up.''')
|
||||
if ch != 'y': return
|
||||
|
||||
if stash.bip39_passphrase:
|
||||
if not await ux_confirm('''You have a BIP-39 passphrase set right now and so that will become wrapped into the new secret.'''):
|
||||
if pa.tmp_value:
|
||||
if stash.bip39_passphrase:
|
||||
msg = ('You have a BIP-39 passphrase set right now '
|
||||
'and so it will be wrapped into the new secret.')
|
||||
else:
|
||||
msg = 'You have a temporary seed active - deriving from temporary.'
|
||||
|
||||
if not await ux_confirm(msg):
|
||||
return
|
||||
|
||||
choices = [ '12 words', '18 words', '24 words', 'WIF (privkey)',
|
||||
@ -109,7 +116,6 @@ async def drv_entro_step2(_1, picked, _2):
|
||||
from glob import dis
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
|
||||
the_ux.pop()
|
||||
msg = "Index Number?"
|
||||
if picked == 7:
|
||||
# Passwords
|
||||
@ -189,7 +195,7 @@ async def drv_entro_step2(_1, picked, _2):
|
||||
prompt += ', (2) to switch to derived secret'
|
||||
elif s_mode == 'pw':
|
||||
prompt += ', (2) to type password over USB'
|
||||
if (qr is not None) and version.has_fatram:
|
||||
if qr is not None:
|
||||
prompt += ', (3) to view as QR code'
|
||||
if glob.NFC:
|
||||
prompt += ', (4) to share via NFC'
|
||||
@ -227,7 +233,7 @@ async def drv_entro_step2(_1, picked, _2):
|
||||
story = "Filename is:\n\n%s" % out_fn
|
||||
story += "\n\nSignature filename is:\n\n%s" % sig_nice
|
||||
await ux_show_story(story, title='Saved')
|
||||
elif ch == '3' and version.has_fatram:
|
||||
elif ch == '3':
|
||||
from ux import show_qr_code
|
||||
await show_qr_code(qr, qr_alnum)
|
||||
continue
|
||||
@ -247,12 +253,16 @@ async def drv_entro_step2(_1, picked, _2):
|
||||
stash.blank_object(msg)
|
||||
|
||||
if ch == '2' and (encoded is not None):
|
||||
from glob import dis
|
||||
from pincodes import pa
|
||||
|
||||
# switch over to new secret!
|
||||
dis.fullscreen("Applying...")
|
||||
await seed.set_ephemeral_seed(encoded)
|
||||
from actions import goto_top_menu
|
||||
from glob import settings
|
||||
xfp_str = xfp2str(settings.get("xfp", 0))
|
||||
await seed.set_ephemeral_seed(
|
||||
encoded,
|
||||
meta='BIP85 Derived from [%s], index=%d' % (xfp_str, index)
|
||||
)
|
||||
goto_top_menu()
|
||||
|
||||
if encoded is not None:
|
||||
stash.blank_object(encoded)
|
||||
|
||||
@ -8,8 +8,8 @@ from ucollections import OrderedDict
|
||||
from utils import xfp2str, swab32, export_prompt_builder, chunk_writer
|
||||
from ux import ux_show_story
|
||||
from glob import settings
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2TR, AF_P2SH
|
||||
from auth import write_sig_file
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
|
||||
|
||||
|
||||
def generate_public_contents():
|
||||
@ -84,7 +84,7 @@ be needed for different systems.
|
||||
|
||||
node = sv.derive_path(hard_sub, register=False)
|
||||
yield ("%s => %s\n" % (hard_sub, chain.serialize_public(node)))
|
||||
if show_slip132 and addr_fmt != AF_CLASSIC and (addr_fmt in chain.slip132):
|
||||
if show_slip132 and addr_fmt not in (AF_CLASSIC, AF_P2TR) and (addr_fmt in chain.slip132):
|
||||
yield ("%s => %s ##SLIP-132##\n" % (
|
||||
hard_sub, chain.serialize_public(node, addr_fmt)))
|
||||
|
||||
@ -102,7 +102,8 @@ be needed for different systems.
|
||||
yield ('\n\n')
|
||||
|
||||
from multisig import MultisigWallet
|
||||
if MultisigWallet.exists():
|
||||
exists, exists_other_chain = MultisigWallet.exists()
|
||||
if exists:
|
||||
yield '\n# Your Multisig Wallets\n\n'
|
||||
|
||||
for ms in MultisigWallet.get_all():
|
||||
@ -143,8 +144,10 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt):
|
||||
with open(fname, 'wb') as fd:
|
||||
chunk_writer(fd, body)
|
||||
|
||||
h = ngu.hash.sha256s(body.encode())
|
||||
sig_nice = write_sig_file([(h, fname)], derive, addr_fmt)
|
||||
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()
|
||||
@ -153,8 +156,9 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt):
|
||||
await ux_show_story('Failed to write!\n\n\n'+str(e))
|
||||
return
|
||||
|
||||
msg = '%s file written:\n\n%s\n\n%s signature file written:\n\n%s' % (title, nice, title,
|
||||
sig_nice)
|
||||
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'):
|
||||
@ -177,10 +181,11 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx
|
||||
|
||||
# make the data
|
||||
examples = []
|
||||
imp_multi, imp_desc = generate_bitcoin_core_wallet(account_num, examples)
|
||||
imp_multi, imp_desc, imp_desc_tr = generate_bitcoin_core_wallet(account_num, examples)
|
||||
|
||||
imp_multi = ujson.dumps(imp_multi)
|
||||
imp_desc = ujson.dumps(imp_desc)
|
||||
imp_desc_tr = ujson.dumps(imp_desc_tr)
|
||||
|
||||
body = '''\
|
||||
# Bitcoin Core Wallet Import File
|
||||
@ -196,7 +201,11 @@ Wallet operates on blockchain: {nb}
|
||||
The following command can be entered after opening Window -> Console
|
||||
in Bitcoin Core, or using bitcoin-cli:
|
||||
|
||||
importdescriptors '{imp_desc}'
|
||||
p2wpkh:
|
||||
importdescriptors '{imp_desc}'
|
||||
|
||||
p2tr:
|
||||
importdescriptors '{imp_desc_tr}'
|
||||
|
||||
> **NOTE** If your UTXO was created before generating `importdescriptors` command, you should adjust the value of `timestamp` before executing command in bitcoin core.
|
||||
By default it is set to `now` meaning do not rescan the blockchain. If approximate time of UTXO creation is known - adjust `timestamp` from `now` to UNIX epoch time.
|
||||
@ -211,7 +220,8 @@ importmulti '{imp_multi}'
|
||||
|
||||
## Resulting Addresses (first 3)
|
||||
|
||||
'''.format(imp_multi=imp_multi, imp_desc=imp_desc, xfp=xfp, nb=chains.current_chain().name)
|
||||
'''.format(imp_multi=imp_multi, imp_desc=imp_desc, imp_desc_tr=imp_desc_tr,
|
||||
xfp=xfp, nb=chains.current_chain().name)
|
||||
|
||||
body += '\n'.join('%s => %s' % t for t in examples)
|
||||
|
||||
@ -224,42 +234,57 @@ importmulti '{imp_multi}'
|
||||
def generate_bitcoin_core_wallet(account_num, example_addrs):
|
||||
# Generate the data for an RPC command to import keys into Bitcoin Core
|
||||
# - yields dicts for json purposes
|
||||
from descriptor import Descriptor
|
||||
from descriptor import Descriptor, Key
|
||||
|
||||
chain = chains.current_chain()
|
||||
|
||||
derive = "84'/{coin_type}'/{account}'".format(account=account_num, coin_type=chain.b44_cointype)
|
||||
derive_v0 = "84'/{coin_type}'/{account}'".format(account=account_num, coin_type=chain.b44_cointype)
|
||||
derive_v1 = "86'/{coin_type}'/{account}'".format(account=account_num, coin_type=chain.b44_cointype)
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
prefix = sv.derive_path(derive)
|
||||
xpub = chain.serialize_public(prefix)
|
||||
prefix = sv.derive_path(derive_v0)
|
||||
xpub_v0 = chain.serialize_public(prefix)
|
||||
|
||||
for i in range(3):
|
||||
sp = '0/%d' % i
|
||||
node = sv.derive_path(sp, master=prefix)
|
||||
a = chain.address(node, AF_P2WPKH)
|
||||
example_addrs.append( ('m/%s/%s' % (derive, sp), a) )
|
||||
example_addrs.append(('m/%s/%s' % (derive_v0, sp), a))
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
prefix = sv.derive_path(derive_v1)
|
||||
xpub_v1 = chain.serialize_public(prefix)
|
||||
|
||||
for i in range(3):
|
||||
sp = '0/%d' % i
|
||||
node = sv.derive_path(sp, master=prefix)
|
||||
a = chain.address(node, AF_P2TR)
|
||||
example_addrs.append(('m/%s/%s' % (derive_v1, sp), a))
|
||||
|
||||
xfp = settings.get('xfp')
|
||||
txt_xfp = xfp2str(xfp).lower()
|
||||
_, vers, _ = version.get_mpy_version()
|
||||
key0 = Key.from_cc_data(xfp, derive_v0, xpub_v0)
|
||||
desc_v0 = Descriptor(key=key0)
|
||||
desc_v0.set_from_addr_fmt(AF_P2WPKH)
|
||||
|
||||
desc_obj = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=AF_P2WPKH)
|
||||
key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1)
|
||||
desc_v1 = Descriptor(key=key1)
|
||||
desc_v1.set_from_addr_fmt(AF_P2TR)
|
||||
# for importmulti
|
||||
imm_list = [
|
||||
{
|
||||
'desc': desc_obj.serialize(internal=internal),
|
||||
'desc': desc_v0.to_string(external, internal),
|
||||
'range': [0, 1000],
|
||||
'timestamp': 'now',
|
||||
'internal': internal,
|
||||
'keypool': True,
|
||||
'watchonly': True
|
||||
}
|
||||
for internal in [False, True]
|
||||
for external, internal in [(True, False), (False, True)]
|
||||
]
|
||||
# for importdescriptors
|
||||
imd_list = desc_obj.bitcoin_core_serialize(external_label="Coldcard %s" % txt_xfp)
|
||||
return imm_list, imd_list
|
||||
imd_list = desc_v0.bitcoin_core_serialize()
|
||||
imd_list_v1 = desc_v1.bitcoin_core_serialize()
|
||||
return imm_list, imd_list, imd_list_v1
|
||||
|
||||
def generate_wasabi_wallet():
|
||||
# Generate the data for a JSON file which Wasabi can open directly as a new wallet.
|
||||
@ -323,7 +348,8 @@ def generate_unchained_export(account_num=0):
|
||||
|
||||
def generate_generic_export(account_num=0):
|
||||
# Generate data that other programers will use to import Coldcard (single-signer)
|
||||
from descriptor import Descriptor, multisig_descriptor_template
|
||||
from descriptor import Descriptor, Key
|
||||
from desc_utils import multisig_descriptor_template
|
||||
|
||||
chain = chains.current_chain()
|
||||
master_xfp = settings.get("xfp")
|
||||
@ -340,8 +366,10 @@ def generate_generic_export(account_num=0):
|
||||
( 'bip44', "m/44'/{ct}'/{acc}'", AF_CLASSIC, 'p2pkh', False ),
|
||||
( 'bip49', "m/49'/{ct}'/{acc}'", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False ), # was "p2wpkh-p2sh"
|
||||
( 'bip84', "m/84'/{ct}'/{acc}'", AF_P2WPKH, 'p2wpkh', False ),
|
||||
( 'bip86', "m/86'/{ct}'/{acc}'", AF_P2TR, 'p2tr', False ),
|
||||
( 'bip48_1', "m/48'/{ct}'/{acc}'/1'", AF_P2WSH_P2SH, 'p2sh-p2wsh', True ),
|
||||
( 'bip48_2', "m/48'/{ct}'/{acc}'/2'", AF_P2WSH, 'p2wsh', True ),
|
||||
( 'bip48_3', "m/48'/{ct}'/{acc}'/3'", AF_P2TR, 'p2tr', True ),
|
||||
( 'bip45', "m/45'", AF_P2SH, 'p2sh', True ),
|
||||
]:
|
||||
if fmt == AF_P2SH and account_num:
|
||||
@ -351,11 +379,14 @@ def generate_generic_export(account_num=0):
|
||||
node = sv.derive_path(dd)
|
||||
xfp = xfp2str(swab32(node.my_fp()))
|
||||
xp = chain.serialize_public(node, AF_CLASSIC)
|
||||
zp = chain.serialize_public(node, fmt) if fmt != AF_CLASSIC else None
|
||||
zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None
|
||||
if is_ms:
|
||||
desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt)
|
||||
else:
|
||||
desc = Descriptor(keys=[(master_xfp, dd, xp)], addr_fmt=fmt).serialize(int_ext=True)
|
||||
key = Key.from_cc_data(master_xfp, dd, xp)
|
||||
desc_obj = Descriptor(key=key)
|
||||
desc_obj.set_from_addr_fmt(fmt)
|
||||
desc = desc_obj.to_string()
|
||||
|
||||
rv[name] = OrderedDict(name=atype,
|
||||
xfp=xfp,
|
||||
@ -472,7 +503,7 @@ async def make_json_wallet(label, func, fname_pattern='new-wallet.json'):
|
||||
|
||||
async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int_ext=True,
|
||||
fname_pattern="descriptor.txt"):
|
||||
from descriptor import Descriptor
|
||||
from descriptor import Descriptor, Key
|
||||
from glob import dis
|
||||
|
||||
dis.fullscreen('Generating...')
|
||||
@ -487,28 +518,33 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int
|
||||
mode = 84
|
||||
elif addr_type == AF_P2WPKH_P2SH:
|
||||
mode = 49
|
||||
elif addr_type == AF_P2TR:
|
||||
mode = 86
|
||||
else:
|
||||
raise ValueError(addr_type)
|
||||
|
||||
derive = "m/{mode}'/{coin_type}'/{account}'".format(mode=mode,
|
||||
account=account_num, coin_type=chain.b44_cointype)
|
||||
account=account_num,
|
||||
coin_type=chain.b44_cointype)
|
||||
dis.progress_bar_show(0.2)
|
||||
with stash.SensitiveValues() as sv:
|
||||
dis.progress_bar_show(0.3)
|
||||
xpub = chain.serialize_public(sv.derive_path(derive))
|
||||
|
||||
dis.progress_bar_show(0.7)
|
||||
desc = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=addr_type)
|
||||
key = Key.from_cc_data(xfp, derive, xpub)
|
||||
desc = Descriptor(key=key)
|
||||
desc.set_from_addr_fmt(addr_type)
|
||||
dis.progress_bar_show(0.8)
|
||||
if int_ext:
|
||||
# with <0;1> notation
|
||||
body = desc.serialize(int_ext=True)
|
||||
body = desc.to_string()
|
||||
else:
|
||||
# external descriptor
|
||||
# internal descriptor
|
||||
body = "%s\n%s" % (
|
||||
desc.serialize(internal=False),
|
||||
desc.serialize(internal=True),
|
||||
desc.to_string(internal=False),
|
||||
desc.to_string(external=False),
|
||||
)
|
||||
|
||||
dis.progress_bar_show(1)
|
||||
|
||||
@ -56,7 +56,6 @@ def wipe_flash_filesystem():
|
||||
# erase and re-format the flash filesystem (/flash/**)
|
||||
import ckcc, pyb
|
||||
from glob import dis
|
||||
from version import mk_num
|
||||
|
||||
dis.fullscreen('Erasing...')
|
||||
os.umount('/flash')
|
||||
@ -82,15 +81,9 @@ def wipe_flash_filesystem():
|
||||
# rebuild and mount /flash
|
||||
dis.fullscreen('Rebuilding...')
|
||||
|
||||
if mk_num == 4:
|
||||
# no need to erase, we just put new FS on top
|
||||
import mk4
|
||||
mk4.make_flash_fs()
|
||||
else:
|
||||
ckcc.wipe_fs()
|
||||
|
||||
# remount it
|
||||
os.mount(fl, '/flash')
|
||||
# no need to erase, we just put new FS on top
|
||||
import mk4
|
||||
mk4.make_flash_fs()
|
||||
|
||||
# re-store current settings
|
||||
from glob import settings
|
||||
|
||||
178
shared/flow.py
178
shared/flow.py
@ -3,75 +3,32 @@
|
||||
# flow.py - Menu structure
|
||||
#
|
||||
from menu import MenuItem, ToggleMenuItem
|
||||
import version
|
||||
from glob import settings
|
||||
|
||||
from actions import *
|
||||
from choosers import *
|
||||
from mk4 import dev_enable_repl
|
||||
from multisig import make_multisig_menu, import_multisig_nfc
|
||||
from seed import make_ephemeral_seed_menu
|
||||
from miniscript import make_miniscript_menu
|
||||
from seed import make_ephemeral_seed_menu, make_seed_vault_menu
|
||||
from address_explorer import address_explore
|
||||
from users import make_users_menu
|
||||
from drv_entro import drv_entro_start, password_entry
|
||||
from backups import clone_start, clone_write_data
|
||||
from xor_seed import xor_split_start, xor_restore_start
|
||||
from countdowns import countdown_pin_submenu, countdown_chooser
|
||||
from countdowns import countdown_chooser
|
||||
from hsm import hsm_policy_available
|
||||
from paper import make_paper_wallet
|
||||
from trick_pins import TrickPinMenu
|
||||
|
||||
# Optional feature: HSM
|
||||
if version.has_fatram:
|
||||
from hsm import hsm_policy_available
|
||||
else:
|
||||
hsm_policy_available = lambda: False
|
||||
|
||||
# Optional feature: Paper Wallets
|
||||
try:
|
||||
from paper import make_paper_wallet
|
||||
except:
|
||||
make_paper_wallet = None
|
||||
|
||||
if version.mk_num >= 4:
|
||||
from trick_pins import TrickPinMenu
|
||||
trick_pin_menu = TrickPinMenu.make_menu
|
||||
else:
|
||||
trick_pin_menu = None
|
||||
trick_pin_menu = TrickPinMenu.make_menu
|
||||
|
||||
#
|
||||
# NOTE: "Always In Title Case"
|
||||
#
|
||||
# - try to keep harmless things as first item: so double-tap of OK does no harm
|
||||
|
||||
# Mk3 and earlier: see Trick Pins for Mk4
|
||||
PinChangesMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Change Main PIN', f=pin_changer, arg='main'),
|
||||
MenuItem('Second Wallet', f=pin_changer, arg='secondary',
|
||||
predicate=lambda: not version.has_608),
|
||||
MenuItem('Duress PIN', f=pin_changer, arg='duress'),
|
||||
MenuItem('Brick Me PIN', f=pin_changer, arg='brickme'),
|
||||
MenuItem('Countdown PIN', menu=countdown_pin_submenu, predicate=lambda: version.has_608),
|
||||
MenuItem('Login Now', f=login_now, arg=1),
|
||||
]
|
||||
|
||||
# Not reachable on Mark3 hardware
|
||||
if not version.has_608:
|
||||
SecondaryPinChangesMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Second Wallet', f=pin_changer, arg='secondary'),
|
||||
MenuItem('Duress PIN', f=pin_changer, arg='duress'),
|
||||
MenuItem('Countdown PIN', menu=countdown_pin_submenu),
|
||||
MenuItem('Login Now', f=login_now, arg=1),
|
||||
]
|
||||
|
||||
async def which_pin_menu(_1,_2, item):
|
||||
assert version.mk_num < 4
|
||||
if version.has_608:
|
||||
# mk3
|
||||
return PinChangesMenu
|
||||
else:
|
||||
# mk2 only
|
||||
from pincodes import pa
|
||||
return PinChangesMenu if not pa.is_secondary else SecondaryPinChangesMenu
|
||||
|
||||
#
|
||||
# Predicates
|
||||
#
|
||||
@ -87,9 +44,18 @@ def nfc_enabled():
|
||||
def vdisk_enabled():
|
||||
return bool(settings.get('vidsk', 0))
|
||||
|
||||
def is_not_tmp():
|
||||
from pincodes import pa
|
||||
return not bool(pa.tmp_value)
|
||||
|
||||
def se2_and_real_secret():
|
||||
from pincodes import pa
|
||||
return version.has_se2 and (not pa.is_secret_blank()) and (not pa.tmp_value)
|
||||
return (not pa.is_secret_blank()) and (not pa.tmp_value)
|
||||
|
||||
def bip39_passphrase_active():
|
||||
import stash
|
||||
return settings.get('words', True) \
|
||||
or (settings.master_get('words', True) and stash.bip39_passphrase)
|
||||
|
||||
|
||||
HWTogglesMenu = [
|
||||
@ -97,7 +63,7 @@ HWTogglesMenu = [
|
||||
on_change=change_usb_disable, story='''\
|
||||
Blocks any data over USB port. Useful when your plan is air-gap usage.'''),
|
||||
ToggleMenuItem('Virtual Disk', 'vidsk', ['Default Off', 'Enable', 'Enable & Auto'],
|
||||
predicate=lambda: version.has_psram, on_change=change_virtdisk_enable,
|
||||
on_change=change_virtdisk_enable,
|
||||
story='''Coldcard can emulate a virtual disk drive (4MB) where new PSBT files \
|
||||
can be saved. Signed PSBT files (transactions) will also be saved here. \n\
|
||||
In "auto" mode, selects PSBT as soon as written.'''),
|
||||
@ -108,15 +74,14 @@ with the Coldcard.''',
|
||||
predicate=lambda: version.has_nfc),
|
||||
]
|
||||
|
||||
# all pre-login values
|
||||
# Mostly pre-login values here.
|
||||
LoginPrefsMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Change Main PIN', f=pin_changer, arg='main'),
|
||||
MenuItem('PIN Options', predicate=lambda: not version.has_se2, menu=which_pin_menu),
|
||||
MenuItem('Trick PINs', predicate=lambda: version.has_se2, menu=trick_pin_menu),
|
||||
MenuItem('Change Main PIN', f=main_pin_changer),
|
||||
MenuItem('Trick PINs', menu=trick_pin_menu),
|
||||
MenuItem('Set Nickname', f=pick_nickname),
|
||||
MenuItem('Scramble Keypad', f=pick_scramble),
|
||||
MenuItem('Kill Key', f=pick_killkey, predicate=lambda: version.has_se2),
|
||||
MenuItem('Kill Key', f=pick_killkey),
|
||||
MenuItem('Login Countdown', chooser=countdown_chooser),
|
||||
MenuItem('MicroSD 2FA', menu=microsd_2fa, predicate=se2_and_real_secret),
|
||||
MenuItem('Test Login Now', f=login_now, arg=1),
|
||||
@ -127,6 +92,7 @@ SettingsMenu = [
|
||||
MenuItem('Login Settings', menu=LoginPrefsMenu),
|
||||
MenuItem('Hardware On/Off', menu=HWTogglesMenu),
|
||||
MenuItem('Multisig Wallets', menu=make_multisig_menu),
|
||||
MenuItem('Miniscript', menu=make_miniscript_menu),
|
||||
MenuItem('Display Units', chooser=value_resolution_chooser),
|
||||
MenuItem('Max Network Fee', chooser=max_fee_chooser),
|
||||
MenuItem('Idle Timeout', chooser=idle_timeout_chooser),
|
||||
@ -153,6 +119,7 @@ XpubExportMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Segwit (BIP-84)", f=export_xpub, arg=84),
|
||||
MenuItem("Classic (BIP-44)", f=export_xpub, arg=44),
|
||||
MenuItem("Taproot/P2TR(86)", f=export_xpub, arg=86),
|
||||
MenuItem("P2WPKH/P2SH (49)", f=export_xpub, arg=49),
|
||||
MenuItem("Master XPUB", f=export_xpub, arg=0),
|
||||
MenuItem("Current XFP", f=export_xpub, arg=-1),
|
||||
@ -161,10 +128,11 @@ XpubExportMenu = [
|
||||
WalletExportMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Bitcoin Core", f=bitcoin_core_skeleton),
|
||||
MenuItem("Sparrow Wallet", f=named_generic_skeleton, arg="Sparrow"),
|
||||
MenuItem("Electrum Wallet", f=electrum_skeleton),
|
||||
MenuItem("Wasabi Wallet", f=wasabi_skeleton),
|
||||
MenuItem("Unchained Capital", f=unchained_capital_export),
|
||||
MenuItem("Lily Wallet", f=lily_skeleton),
|
||||
MenuItem("Unchained", f=unchained_capital_export),
|
||||
MenuItem("Lily Wallet", f=named_generic_skeleton, arg="Lily"),
|
||||
MenuItem("Samourai Postmix", f=samourai_post_mix_descriptor_export),
|
||||
MenuItem("Samourai Premix", f=samourai_pre_mix_descriptor_export),
|
||||
# MenuItem("Samourai BadBank", f=samourai_bad_bank_descriptor_export), # not released yet
|
||||
@ -181,11 +149,11 @@ FileMgmtMenu = [
|
||||
MenuItem("Backup System", predicate=has_secrets, f=backup_everything),
|
||||
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), #dup elsewhere
|
||||
MenuItem('Sign Text File', predicate=has_secrets, f=sign_message_on_sd),
|
||||
#MenuItem('Upgrade Firmware', f=microsd_upgrade),
|
||||
MenuItem('Clone Coldcard', predicate=has_secrets, f=clone_write_data),
|
||||
MenuItem('Batch Sign PSBT', predicate=has_secrets, f=batch_sign),
|
||||
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),
|
||||
MenuItem('Clone Coldcard', predicate=has_secrets, f=clone_write_data),
|
||||
MenuItem('Format SD Card', f=wipe_sd_card),
|
||||
MenuItem('Format RAM Disk', predicate=vdisk_enabled, f=wipe_vdisk),
|
||||
]
|
||||
@ -193,37 +161,24 @@ FileMgmtMenu = [
|
||||
UpgradeMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Show Version', f=show_version),
|
||||
MenuItem('From MicroSD', f=microsd_upgrade), # mk4: misnomer, could be vdisk too
|
||||
MenuItem('From VirtDisk', predicate=vdisk_enabled, f=microsd_upgrade),
|
||||
MenuItem('From MicroSD', f=microsd_upgrade, arg=False),
|
||||
MenuItem('From VirtDisk', predicate=vdisk_enabled, f=microsd_upgrade, arg=True), # force_vdisk=True
|
||||
MenuItem('Bless Firmware', f=bless_flash),
|
||||
]
|
||||
|
||||
if version.mk_num < 4:
|
||||
DevelopersMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Normal USB Mode", f=dev_enable_protocol),
|
||||
MenuItem("Enable USB REPL", f=dev_enable_vcp),
|
||||
MenuItem("Enable USB Disk", f=dev_enable_disk),
|
||||
MenuItem("Wipe Patch Area", f=wipe_filesystem), # needs better label
|
||||
MenuItem('Warm Reset', f=reset_self),
|
||||
MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
|
||||
]
|
||||
else:
|
||||
# Mk4 and later
|
||||
from mk4 import dev_enable_repl
|
||||
DevelopersMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Serial REPL", f=dev_enable_repl),
|
||||
MenuItem("Wipe LFS", f=wipe_filesystem), # kills settings, HSM stuff
|
||||
MenuItem('Warm Reset', f=reset_self),
|
||||
MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
|
||||
]
|
||||
DevelopersMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Serial REPL", f=dev_enable_repl),
|
||||
MenuItem("Wipe LFS", f=wipe_filesystem), # kills settings, HSM stuff
|
||||
MenuItem('Warm Reset', f=reset_self),
|
||||
MenuItem("Restore Txt Bkup", f=restore_everything_cleartext),
|
||||
]
|
||||
|
||||
AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh)
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Ephemeral Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem('Upgrade Firmware', menu=UpgradeMenu),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem('Upgrade Firmware', menu=UpgradeMenu, predicate=is_not_tmp),
|
||||
MenuItem('Paper Wallets', f=make_paper_wallet, predicate=lambda: make_paper_wallet),
|
||||
MenuItem('Perform Selftest', f=start_selftest),
|
||||
MenuItem('Secure Logout', f=logout_now),
|
||||
@ -232,8 +187,8 @@ AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh)
|
||||
AdvancedPinnedVirginMenu = [ # Has PIN but no secrets yet
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Ephemeral Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem("Upgrade Firmware", menu=UpgradeMenu),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
|
||||
MenuItem("File Management", menu=FileMgmtMenu),
|
||||
MenuItem('Paper Wallets', f=make_paper_wallet, predicate=lambda: make_paper_wallet),
|
||||
MenuItem('Perform Selftest', f=start_selftest),
|
||||
@ -260,7 +215,9 @@ SeedFunctionsMenu = [
|
||||
MenuItem('View Seed Words', f=view_seed_words), # text is a little wrong sometimes, rare
|
||||
MenuItem('Seed XOR', menu=SeedXORMenu),
|
||||
MenuItem("Destroy Seed", f=clear_seed),
|
||||
MenuItem('Lock Down Seed', f=convert_bip39_to_bip32),
|
||||
MenuItem('Lock Down Seed', f=convert_ephemeral_to_master),
|
||||
MenuItem('Export SeedQR', f=export_seedqr,
|
||||
predicate=lambda: settings.get('words', True)),
|
||||
]
|
||||
|
||||
DangerZoneMenu = [
|
||||
@ -268,6 +225,14 @@ DangerZoneMenu = [
|
||||
MenuItem("Debug Functions", menu=DebugFunctionsMenu), # actually harmless
|
||||
MenuItem("Seed Functions", menu=SeedFunctionsMenu),
|
||||
MenuItem("I Am Developer.", menu=maybe_dev_menu),
|
||||
ToggleMenuItem('Seed Vault', 'seedvault', ['Default Off', 'Enable'],
|
||||
on_change=change_seed_vault,
|
||||
story=("Enable Seed Vault? Adds prompt to store temporary seeds "
|
||||
"into Seed Vault, where they can easily be reused later.\n\n"
|
||||
"WARNING: Seed Vault is encrypted (AES-256-CTR) by your seed,"
|
||||
" but not held directly inside secure elements. Backups are required"
|
||||
" after any change to vault! Recommended for experiments or temporary use."),
|
||||
predicate=has_secrets),
|
||||
MenuItem('Perform Selftest', f=start_selftest), # little harmful
|
||||
MenuItem("Set High-Water", f=set_highwater),
|
||||
MenuItem('Wipe HSM Policy', f=wipe_hsm_policy, predicate=hsm_policy_available),
|
||||
@ -284,7 +249,7 @@ Keep blocked unless you intend to sign special transactions.'''),
|
||||
story="Testnet must only be used by developers because \
|
||||
correctly- crafted transactions signed on Testnet could be broadcast on Mainnet."),
|
||||
MenuItem('Settings Space', f=show_settings_space),
|
||||
MenuItem('MCU Key Slots', predicate=lambda: version.has_se2, f=show_mcu_keys_left),
|
||||
MenuItem('MCU Key Slots', f=show_mcu_keys_left),
|
||||
]
|
||||
|
||||
BackupStuffMenu = [
|
||||
@ -307,17 +272,16 @@ AdvancedNormalMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("Backup", menu=BackupStuffMenu),
|
||||
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), # also inside FileMgmt
|
||||
MenuItem("Upgrade Firmware", menu=UpgradeMenu),
|
||||
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
|
||||
MenuItem("File Management", menu=FileMgmtMenu),
|
||||
MenuItem('Derive Seed B85', f=drv_entro_start),
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Ephemeral Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem('Paper Wallets', f=make_paper_wallet, predicate=lambda: 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=lambda: version.has_fatram),
|
||||
MenuItem('User Management', menu=make_users_menu, predicate=lambda: version.has_fatram),
|
||||
By default these commands are disabled."),
|
||||
MenuItem('User Management', menu=make_users_menu),
|
||||
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu),
|
||||
MenuItem("Danger Zone", menu=DangerZoneMenu),
|
||||
]
|
||||
@ -332,10 +296,9 @@ VirginSystem = [
|
||||
]
|
||||
|
||||
ImportWallet = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("24 Words", menu=start_seed_import, arg=24),
|
||||
MenuItem("18 Words", menu=start_seed_import, arg=18),
|
||||
MenuItem("12 Words", menu=start_seed_import, arg=12),
|
||||
MenuItem("18 Words", menu=start_seed_import, arg=18),
|
||||
MenuItem("24 Words", menu=start_seed_import, arg=24),
|
||||
MenuItem("Restore Backup", f=restore_everything),
|
||||
MenuItem("Clone Coldcard", menu=clone_start),
|
||||
MenuItem("Import XPRV", f=import_xprv, arg=False), # ephemeral=False
|
||||
@ -343,13 +306,11 @@ ImportWallet = [
|
||||
MenuItem("Seed XOR", f=xor_restore_start),
|
||||
]
|
||||
|
||||
|
||||
NewSeedMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("24 Word (default)", f=pick_new_seed, arg=24),
|
||||
MenuItem("12 Word", f=pick_new_seed, arg=12),
|
||||
MenuItem("24 Word Dice Roll", f=new_from_dice, arg=24),
|
||||
MenuItem("12 Words", f=pick_new_seed, arg=12),
|
||||
MenuItem("24 Words", f=pick_new_seed, arg=24),
|
||||
MenuItem("12 Word Dice Roll", f=new_from_dice, arg=12),
|
||||
MenuItem("24 Word Dice Roll", f=new_from_dice, arg=24),
|
||||
]
|
||||
|
||||
# has PIN, but no secret seed yet
|
||||
@ -362,21 +323,22 @@ EmptyWallet = [
|
||||
MenuItem('Settings', menu=SettingsMenu),
|
||||
]
|
||||
|
||||
|
||||
# In operation, normal system, after a good PIN received.
|
||||
NormalSystem = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Ready To Sign', f=ready2sign),
|
||||
MenuItem('Passphrase', f=start_b39_pw, predicate=lambda: settings.get('words', True)),
|
||||
MenuItem('Passphrase', f=start_b39_pw, predicate=bip39_passphrase_active),
|
||||
MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available),
|
||||
MenuItem("Address Explorer", f=address_explore),
|
||||
MenuItem('Type Passwords', f=password_entry, predicate=lambda: settings.get("emu", False) and has_secrets()),
|
||||
MenuItem('Secure Logout', f=logout_now),
|
||||
MenuItem('Type Passwords', f=password_entry,
|
||||
predicate=lambda: settings.get("emu", False) and has_secrets()),
|
||||
MenuItem('Seed Vault', menu=make_seed_vault_menu,
|
||||
predicate=lambda: settings.master_get('seedvault') and has_secrets()),
|
||||
MenuItem('Advanced/Tools', menu=AdvancedNormalMenu),
|
||||
MenuItem('Settings', menu=SettingsMenu),
|
||||
MenuItem('Secure Logout', f=logout_now),
|
||||
]
|
||||
|
||||
|
||||
# Shown until unit is put into a numbered bag
|
||||
FactoryMenu = [
|
||||
MenuItem('Bag Me Now'), # nice to have NOP at top of menu
|
||||
|
||||
@ -2,57 +2,35 @@
|
||||
#
|
||||
# ftux.py - First Time User Experience! A new ride at the waterpark.
|
||||
#
|
||||
import version
|
||||
import version, ckcc
|
||||
from glob import settings
|
||||
from ux import ux_show_story, the_ux, ux_dramatic_pause
|
||||
from actions import change_nfc_enable, change_virtdisk_enable, change_usb_disable
|
||||
|
||||
COMMON = '''\
|
||||
\n
|
||||
You can change this later under Settings > Hardware On/Off.'''
|
||||
from ux import ux_show_story, the_ux
|
||||
from actions import change_usb_disable
|
||||
|
||||
class FirstTimeUX:
|
||||
async def interact(self):
|
||||
# Help them enable the good stuff.
|
||||
# - they might have already enabled things
|
||||
# - some features not on mk3
|
||||
# Force USB to be disabled by default, but also warn/tell user
|
||||
# how to enable it, plus NFC and VirtDisk (already disabled by default)
|
||||
if settings.get('du', None) is None:
|
||||
|
||||
if version.has_nfc and not settings.get('nfc', 0):
|
||||
msg = '''Enable NFC/Tap?\n\n\
|
||||
Lets you Tap your mobile phone on the COLDCARD and \
|
||||
transfer data easily via NFC.''' + COMMON
|
||||
ch = await ux_show_story(msg)
|
||||
if ch == 'y':
|
||||
settings.set('nfc', 1)
|
||||
await change_nfc_enable(1)
|
||||
await ux_dramatic_pause('Enabled.', 1)
|
||||
|
||||
# Disabled for now, because limited audience and
|
||||
# extra barrier to "just getting started"
|
||||
if 0: # version.has_psram and not settings.get('vidsk', 0):
|
||||
msg = '''Enable USB Drive?\n\n\
|
||||
Connect your COLDCARD directly as a USB flash drive \
|
||||
to your phone or desktop. You will be able to drag-n-drop or \
|
||||
save PSBT files like other drives/volumes.''' + COMMON
|
||||
ch = await ux_show_story(msg)
|
||||
if ch == 'y':
|
||||
# put them into full-auto mode: 2
|
||||
settings.set('vidsk', 2)
|
||||
await change_virtdisk_enable(2)
|
||||
await ux_dramatic_pause('Enabled.', 1)
|
||||
|
||||
if not settings.get('vidsk', 0) and not settings.get('du', 0):
|
||||
msg = '''Disable USB port?\n\n\
|
||||
If you intend to operate in Air-Gap mode, where this COLDCARD \
|
||||
is never connected to anything but power, then this will disable the USB port.''' + COMMON
|
||||
ch = await ux_show_story(msg)
|
||||
|
||||
if ch == 'y':
|
||||
settings.set('du', 1)
|
||||
if not ckcc.is_simulator():
|
||||
settings.set('du', 1) # disable USB
|
||||
await change_usb_disable(1)
|
||||
await ux_dramatic_pause('Disabled.', 1)
|
||||
|
||||
# done
|
||||
#settings.set('nfc', 0) # default already
|
||||
#settings.set('vidsk', 0) # same as default
|
||||
|
||||
await ux_show_story('''
|
||||
Your COLDCARD has been configured for \
|
||||
best security practices:
|
||||
|
||||
- USB disabled
|
||||
- NFC disabled
|
||||
- VDisk disabled
|
||||
|
||||
You can change these under Settings > Hardware On/Off.''', title="Welcome!")
|
||||
|
||||
# done, clear UX
|
||||
the_ux.pop()
|
||||
|
||||
# EOF
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
# history.py - store some history about past transactions and/or outputs they involved
|
||||
#
|
||||
import gc, chains
|
||||
from utils import B2A
|
||||
from uhashlib import sha256
|
||||
from ustruct import pack, unpack
|
||||
from exceptions import IncorrectUTXOAmount
|
||||
@ -125,8 +124,6 @@ class OutptValueCache:
|
||||
|
||||
# memory management: can't store very much, so trim as needed
|
||||
depth = HISTORY_SAVED
|
||||
if settings.capacity > 0.8:
|
||||
depth //= 2
|
||||
|
||||
# also limit in-memory use
|
||||
cls.load_cache()
|
||||
|
||||
@ -4,16 +4,15 @@
|
||||
#
|
||||
# Unattended signing of transactions and messages, subject to a set of rules.
|
||||
#
|
||||
import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version
|
||||
from sffile import SFFile
|
||||
import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu
|
||||
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
|
||||
from pincodes import AE_LONG_SECRET_LEN
|
||||
from stash import blank_object
|
||||
from users import Users, MAX_NUMBER_USERS, calc_local_pincode
|
||||
from public_constants import MAX_USERNAME_LEN
|
||||
from multisig import MultisigWallet
|
||||
from miniscript import MiniScriptWallet
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
from uhashlib import sha256
|
||||
from ucollections import OrderedDict
|
||||
from files import CardSlot, CardMissingError
|
||||
@ -88,13 +87,13 @@ def pop_list(j, fld_name, cleanup_fcn=None):
|
||||
else:
|
||||
return []
|
||||
|
||||
def pop_deriv_list(j, fld_name, extra_val=None):
|
||||
def pop_deriv_list(j, fld_name, extra_vals=None):
|
||||
# expect a list of derivation paths, but also 'any' meaning accept all
|
||||
# - maybe also 'p2sh' as special value
|
||||
# - also, path can have n
|
||||
def cu(s):
|
||||
if s.lower() == 'any': return s.lower()
|
||||
if extra_val and s.lower() == extra_val: return s.lower()
|
||||
if extra_vals and s.lower() in extra_vals:
|
||||
return s.lower()
|
||||
try:
|
||||
return cleanup_deriv_path(s, allow_star=True)
|
||||
except:
|
||||
@ -195,7 +194,7 @@ class ApprovalRule:
|
||||
# - users: list of authorized users
|
||||
# - min_users: how many of those are needed to approve
|
||||
# - local_conf: local user must also confirm w/ code
|
||||
# - wallet: which multisig wallet to restrict to, or '1' for single signer only
|
||||
# - wallet: which multisig/miniscript wallet to restrict to, or '1' for single signer only
|
||||
# - min_pct_self_transfer: minimum percentage of own input value that must go back to self
|
||||
# - patterns: list of transaction patterns to check for. Valid values:
|
||||
# * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal
|
||||
@ -212,6 +211,7 @@ class ApprovalRule:
|
||||
return u
|
||||
|
||||
self.index = idx+1
|
||||
self.ms_type = "multisig"
|
||||
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)
|
||||
@ -238,8 +238,11 @@ class ApprovalRule:
|
||||
|
||||
# if specified, 'wallet' must be an existing multisig wallet's name
|
||||
if self.wallet and self.wallet != '1':
|
||||
names = [ms.name for ms in MultisigWallet.get_all()]
|
||||
assert self.wallet in names, "unknown MS wallet: "+self.wallet
|
||||
ms_names = [ms.name for ms in MultisigWallet.get_all()]
|
||||
msc_names = [msc.name for msc in MiniScriptWallet.get_all()]
|
||||
assert self.wallet in (ms_names+msc_names), "unknown wallet: "+self.wallet
|
||||
if self.wallet in msc_names:
|
||||
self.ms_type = "miniscript"
|
||||
|
||||
# patterns must be valid
|
||||
for p in self.patterns:
|
||||
@ -283,9 +286,9 @@ class ApprovalRule:
|
||||
rv = 'Any amount'
|
||||
|
||||
if self.wallet == '1':
|
||||
rv += ' (non multisig)'
|
||||
rv += ' (singlesig only)'
|
||||
elif self.wallet:
|
||||
rv += ' from multisig wallet "%s"' % self.wallet
|
||||
rv += ' from %s wallet "%s"' % (self.ms_type, self.wallet)
|
||||
|
||||
if self.users:
|
||||
rv += ' may be authorized by '
|
||||
@ -328,7 +331,9 @@ class ApprovalRule:
|
||||
# rule limited to one wallet
|
||||
if psbt.active_multisig:
|
||||
# if multisig signing, might need to match specific wallet name
|
||||
assert self.wallet == psbt.active_multisig.name, 'wrong wallet'
|
||||
assert self.wallet == psbt.active_multisig.name, 'wrong multisig wallet'
|
||||
elif psbt.active_miniscript:
|
||||
assert self.wallet == psbt.active_miniscript.name, 'wrong miniscript wallet'
|
||||
else:
|
||||
# non multisig, but does this rule apply to all wallets or single-singers
|
||||
assert self.wallet == '1', 'not multisig'
|
||||
@ -430,7 +435,7 @@ class AuditLogger:
|
||||
|
||||
self.card = CardSlot().__enter__()
|
||||
|
||||
d = self.card.get_sd_root() + '/' + self.dirname
|
||||
d = self.card.get_sd_root() + '/' + self.dirname
|
||||
|
||||
# mkdir if needed
|
||||
try: uos.stat(d)
|
||||
@ -504,9 +509,9 @@ class HSMPolicy:
|
||||
self.warnings_ok = pop_bool(j, 'warnings_ok')
|
||||
|
||||
# a list of paths we can accept for signing
|
||||
self.msg_paths = pop_deriv_list(j, 'msg_paths')
|
||||
self.share_xpubs = pop_deriv_list(j, 'share_xpubs')
|
||||
self.share_addrs = pop_deriv_list(j, 'share_addrs', 'p2sh')
|
||||
self.msg_paths = pop_deriv_list(j, 'msg_paths', ['any'])
|
||||
self.share_xpubs = pop_deriv_list(j, 'share_xpubs', ['any'])
|
||||
self.share_addrs = pop_deriv_list(j, 'share_addrs', ['p2sh', 'any', 'msas'])
|
||||
|
||||
# free text shown at top
|
||||
self.notes = pop_string(j, 'notes', 1, 80)
|
||||
@ -667,7 +672,6 @@ class HSMPolicy:
|
||||
def activate(self, new_file):
|
||||
# user approved the HSM activation, so apply it.
|
||||
from glob import dis
|
||||
from pincodes import pa
|
||||
|
||||
import glob
|
||||
assert not glob.hsm_active
|
||||
@ -682,12 +686,6 @@ class HSMPolicy:
|
||||
with open(POLICY_FNAME, 'w+t') as f:
|
||||
ujson.dump(self.save(), f)
|
||||
|
||||
if version.mk_num <= 3:
|
||||
# that changes the flash, so need to update
|
||||
# the hash stored in SE (Mk3 and earlier)
|
||||
pa.greenlight_firmware()
|
||||
dis.show()
|
||||
|
||||
if self.set_sl:
|
||||
self.save_storage_locker()
|
||||
|
||||
@ -821,12 +819,15 @@ class HSMPolicy:
|
||||
|
||||
return match_deriv_path(self.share_xpubs, subpath)
|
||||
|
||||
def approve_address_share(self, subpath=None, is_p2sh=False):
|
||||
def approve_address_share(self, subpath=None, is_p2sh=False, miniscript=False):
|
||||
# Are we allowing "show address" requests over USB?
|
||||
|
||||
if not self.share_addrs:
|
||||
return False
|
||||
|
||||
if miniscript:
|
||||
return ('msas' in self.share_addrs)
|
||||
|
||||
if is_p2sh:
|
||||
return ('p2sh' in self.share_addrs)
|
||||
|
||||
@ -1001,7 +1002,8 @@ def hsm_status_report():
|
||||
rv['approval_wait'] = True
|
||||
|
||||
rv['users'] = Users.list()
|
||||
rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()]
|
||||
rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] \
|
||||
+ [msc.name for msc in MiniScriptWallet.get_all()]
|
||||
|
||||
rv['chain'] = settings.get('chain', 'BTC')
|
||||
|
||||
|
||||
@ -39,21 +39,17 @@ glob.dis = dis
|
||||
# slowish imports, some with side-effects
|
||||
import ckcc, uasyncio
|
||||
|
||||
if version.mk_num >= 4:
|
||||
# early setup code needed on Mk4
|
||||
try:
|
||||
import mk4
|
||||
mk4.init0()
|
||||
# early setup code needed on Mk4
|
||||
try:
|
||||
import mk4
|
||||
mk4.init0()
|
||||
|
||||
from psram import PSRAMWrapper
|
||||
glob.PSRAM = PSRAMWrapper()
|
||||
from psram import PSRAMWrapper
|
||||
glob.PSRAM = PSRAMWrapper()
|
||||
|
||||
except BaseException as exc:
|
||||
sys.print_exception(exc)
|
||||
# continue tho
|
||||
else:
|
||||
# Serial Flash memory
|
||||
from sflash import SF
|
||||
except BaseException as exc:
|
||||
sys.print_exception(exc)
|
||||
# continue tho
|
||||
|
||||
# Setup membrane numpad (mark 2+)
|
||||
from mempad import MembraneNumpad
|
||||
@ -62,7 +58,8 @@ glob.numpad = numpad
|
||||
|
||||
# NV settings
|
||||
from nvstore import SettingsObject
|
||||
settings = SettingsObject(glob.dis)
|
||||
settings = SettingsObject()
|
||||
settings.load(glob.dis)
|
||||
glob.settings = settings
|
||||
|
||||
async def more_setup():
|
||||
@ -78,7 +75,9 @@ async def more_setup():
|
||||
try:
|
||||
from pincodes import pa
|
||||
pa.setup(b'') # just to see where we stand.
|
||||
is_blank = pa.is_blank()
|
||||
except RuntimeError as e:
|
||||
is_blank = True
|
||||
print("Problem: %r" % e)
|
||||
|
||||
if version.is_factory_mode:
|
||||
@ -92,8 +91,9 @@ async def more_setup():
|
||||
from actions import start_selftest
|
||||
await start_selftest()
|
||||
|
||||
else:
|
||||
# force them to accept terms (unless marked as already done)
|
||||
elif is_blank:
|
||||
# force them to accept terms (unless marked as already done in settings)
|
||||
# only if no main PIN chosen
|
||||
from actions import accept_terms
|
||||
await accept_terms()
|
||||
|
||||
|
||||
@ -6,12 +6,14 @@ freeze_as_mpy('', [
|
||||
'address_explorer.py',
|
||||
'auth.py',
|
||||
'backups.py',
|
||||
'bsms.py',
|
||||
'callgate.py',
|
||||
'chains.py',
|
||||
'choosers.py',
|
||||
'compat7z.py',
|
||||
'countdowns.py',
|
||||
'descriptor.py',
|
||||
'desc_utils.py',
|
||||
'dev_helper.py',
|
||||
'display.py',
|
||||
'drv_entro.py',
|
||||
@ -19,6 +21,7 @@ freeze_as_mpy('', [
|
||||
'export.py',
|
||||
'files.py',
|
||||
'flow.py',
|
||||
'ftux.py',
|
||||
'glob.py',
|
||||
'history.py',
|
||||
'hsm.py',
|
||||
@ -28,6 +31,7 @@ freeze_as_mpy('', [
|
||||
'main.py',
|
||||
'mempad.py',
|
||||
'menu.py',
|
||||
'miniscript.py',
|
||||
'multisig.py',
|
||||
'numpad.py',
|
||||
'nvstore.py',
|
||||
@ -42,8 +46,8 @@ freeze_as_mpy('', [
|
||||
'seed.py',
|
||||
'selftest.py',
|
||||
'serializations.py',
|
||||
'wallet_base.py',
|
||||
'sffile.py',
|
||||
'sram2.py',
|
||||
'ssd1306.py',
|
||||
'stash.py',
|
||||
'usb.py',
|
||||
@ -52,7 +56,6 @@ freeze_as_mpy('', [
|
||||
'ux.py',
|
||||
'version.py',
|
||||
'xor_seed.py',
|
||||
'ftux.py',
|
||||
], opt=0)
|
||||
|
||||
# Optimize data-like files, since no need to debug them.
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# Mk3 and earlier only files; would not be needed on Mk4 or later
|
||||
freeze_as_mpy('', [
|
||||
'sflash.py',
|
||||
], opt=0)
|
||||
|
||||
@ -166,9 +166,18 @@ class MenuSystem:
|
||||
if not keep_position:
|
||||
self.cursor = 0
|
||||
self.ypos = 0
|
||||
|
||||
self.items = [m for m in menu_items if not getattr(m, 'predicate', None) or m.predicate()]
|
||||
self.count = len(self.items)
|
||||
|
||||
def goto_label(self, label):
|
||||
# pick menu item based on label text
|
||||
for i, m in enumerate(self.items):
|
||||
if m.label.endswith(label):
|
||||
self.goto_idx(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def show(self):
|
||||
#
|
||||
# Redraw the menu.
|
||||
|
||||
1857
shared/miniscript.py
Normal file
1857
shared/miniscript.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -96,7 +96,6 @@ def wipe_if_deltamode():
|
||||
if not pa.is_deltamode():
|
||||
return
|
||||
|
||||
import callgate
|
||||
callgate.fast_wipe()
|
||||
|
||||
# EOF
|
||||
|
||||
@ -3,16 +3,22 @@
|
||||
# multisig.py - support code for multisig signing and p2sh in general.
|
||||
#
|
||||
import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson
|
||||
from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str
|
||||
from utils import str_to_keypath, problem_file_line, export_prompt_builder, parse_extended_key
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, truncate_address
|
||||
from utils import str_to_keypath, problem_file_line, export_prompt_builder, check_xpub
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys, ux_enter_bip32_index
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
from descriptor import MultisigDescriptor, multisig_descriptor_template
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS
|
||||
from descriptor import Descriptor
|
||||
from miniscript import Key, Sortedmulti, Number
|
||||
from desc_utils import multisig_descriptor_template
|
||||
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, AF_P2TR
|
||||
from public_constants import MAX_SIGNERS
|
||||
from menu import MenuSystem, MenuItem
|
||||
from opcodes import OP_CHECKMULTISIG
|
||||
from opcodes import OP_CHECKMULTISIG, OP_CHECKSIG, OP_NUMEQUAL, OP_CHECKSIGADD
|
||||
from exceptions import FatalPSBTIssue
|
||||
from glob import settings
|
||||
from wallet_base import BaseWallet
|
||||
from serializations import disassemble
|
||||
|
||||
|
||||
# PSBT Xpub trust policies
|
||||
@ -21,14 +27,11 @@ TRUST_OFFER = const(1)
|
||||
TRUST_PSBT = const(2)
|
||||
|
||||
|
||||
class MultisigOutOfSpace(RuntimeError):
|
||||
pass
|
||||
|
||||
def disassemble_multisig_mn(redeem_script):
|
||||
# pull out just M and N from script. Simple, faster, no memory.
|
||||
# Pull out just M and N from script. Simple, faster, no memory.
|
||||
|
||||
assert MAX_SIGNERS == 15
|
||||
assert redeem_script[-1] == OP_CHECKMULTISIG, 'need CHECKMULTISIG'
|
||||
if redeem_script[-1] != OP_CHECKMULTISIG:
|
||||
return None, None
|
||||
|
||||
M = redeem_script[0] - 80
|
||||
N = redeem_script[-2] - 80
|
||||
@ -40,8 +43,7 @@ def disassemble_multisig(redeem_script):
|
||||
# - only for multisig scripts, not general purpose
|
||||
# - expect OP_1 (pk1) (pk2) (pk3) OP_3 OP_CHECKMULTISIG for 1 of 3 case
|
||||
# - returns M, N, (list of pubkeys)
|
||||
# - for very unlikely/impossible asserts, dont document reason; otherwise do.
|
||||
from serializations import disassemble
|
||||
# - for very unlikely/impossible asserts, don't document reason; otherwise do.
|
||||
|
||||
M, N = disassemble_multisig_mn(redeem_script)
|
||||
assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range'
|
||||
@ -104,7 +106,8 @@ def make_redeem_script(M, nodes, subkey_idx):
|
||||
|
||||
return b''.join(pubkeys)
|
||||
|
||||
class MultisigWallet:
|
||||
|
||||
class MultisigWallet(BaseWallet):
|
||||
# Capture the info we need to store long-term in order to participate in a
|
||||
# multisig wallet as a co-signer.
|
||||
# - can be saved to nvram
|
||||
@ -113,25 +116,25 @@ class MultisigWallet:
|
||||
# - required during signing to verify change outputs
|
||||
# - can reconstruct any redeem script from this
|
||||
# Challenges:
|
||||
# - can be big, taking big % of 4k storage in nvram
|
||||
# - can be big, taking big % of storage in nvram
|
||||
# - complex object, want to have flexibility going forward
|
||||
FORMAT_NAMES = [
|
||||
(AF_P2SH, 'p2sh'),
|
||||
(AF_P2WSH, 'p2wsh'),
|
||||
(AF_P2WSH_P2SH, 'p2sh-p2wsh'), # preferred
|
||||
(AF_P2TR, 'p2tr'),
|
||||
(AF_P2WSH_P2SH, 'p2wsh-p2sh'), # obsolete (now an alias)
|
||||
]
|
||||
|
||||
# optional: user can short-circuit many checks (system wide, one power-cycle only)
|
||||
disable_checks = False
|
||||
key_name = "multisig"
|
||||
|
||||
def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC'):
|
||||
self.storage_idx = -1
|
||||
|
||||
def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type=None):
|
||||
super().__init__(chain_type=chain_type)
|
||||
self.name = name
|
||||
assert len(m_of_n) == 2
|
||||
self.M, self.N = m_of_n
|
||||
self.chain_type = chain_type or 'BTC'
|
||||
assert len(xpubs[0]) == 3
|
||||
self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str))
|
||||
self.addr_fmt = addr_fmt # address format for wallet
|
||||
@ -150,17 +153,12 @@ class MultisigWallet:
|
||||
return v.upper()
|
||||
return '?'
|
||||
|
||||
@property
|
||||
def chain(self):
|
||||
return chains.get_chain(self.chain_type)
|
||||
|
||||
@classmethod
|
||||
def get_trust_policy(cls):
|
||||
|
||||
which = settings.get('pms', None)
|
||||
|
||||
exists, _ = cls.exists()
|
||||
if which is None:
|
||||
which = TRUST_VERIFY if cls.exists() else TRUST_OFFER
|
||||
which = TRUST_VERIFY if exists else TRUST_OFFER
|
||||
|
||||
return which
|
||||
|
||||
@ -215,16 +213,27 @@ class MultisigWallet:
|
||||
return rv
|
||||
|
||||
@classmethod
|
||||
def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmt=None):
|
||||
def is_correct_chain(cls, o, curr_chain):
|
||||
if "ch" not in o[-1]:
|
||||
# mainnet
|
||||
ch = "BTC"
|
||||
else:
|
||||
ch = o[-1]["ch"]
|
||||
|
||||
if ch == curr_chain.ctype:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def iter_wallets(cls, M=None, N=None, addr_fmt=None):
|
||||
# yield MS wallets we know about, that match at least right M,N if known.
|
||||
# - this is only place we should be searching this list, please!!
|
||||
lst = settings.get('multisig', [])
|
||||
lst = settings.get(cls.key_name, [])
|
||||
c = chains.current_key_chain()
|
||||
|
||||
for idx, rec in enumerate(lst):
|
||||
if idx == not_idx:
|
||||
# ignore one by index
|
||||
if not cls.is_correct_chain(rec, c):
|
||||
continue
|
||||
|
||||
if M or N:
|
||||
# peek at M/N
|
||||
has_m, has_n = tuple(rec[1])
|
||||
@ -319,57 +328,6 @@ class MultisigWallet:
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
# return them all, as a generator
|
||||
return cls.iter_wallets()
|
||||
|
||||
@classmethod
|
||||
def exists(cls):
|
||||
# are there any wallets defined?
|
||||
return bool(settings.get('multisig', False))
|
||||
|
||||
@classmethod
|
||||
def get_by_idx(cls, nth):
|
||||
# instance from index number (used in menu)
|
||||
lst = settings.get('multisig', [])
|
||||
try:
|
||||
obj = lst[nth]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
return cls.deserialize(obj, nth)
|
||||
|
||||
def commit(self):
|
||||
# data to save
|
||||
# - important that this fails immediately when nvram overflows
|
||||
obj = self.serialize()
|
||||
|
||||
v = settings.get('multisig', [])
|
||||
orig = v.copy()
|
||||
if not v or self.storage_idx == -1:
|
||||
# create
|
||||
self.storage_idx = len(v)
|
||||
v.append(obj)
|
||||
else:
|
||||
# update in place
|
||||
v[self.storage_idx] = obj
|
||||
|
||||
settings.set('multisig', v)
|
||||
|
||||
# save now, rather than in background, so we can recover
|
||||
# from out-of-space situation
|
||||
try:
|
||||
settings.save()
|
||||
except:
|
||||
# back out change; no longer sure of NVRAM state
|
||||
try:
|
||||
settings.set('multisig', orig)
|
||||
settings.save()
|
||||
except: pass # give up on recovery
|
||||
|
||||
raise MultisigOutOfSpace
|
||||
|
||||
def has_similar(self):
|
||||
# check if we already have a saved duplicate to this proposed wallet
|
||||
# - return (name_change, diff_items, count_similar) where:
|
||||
@ -421,9 +379,9 @@ class MultisigWallet:
|
||||
else:
|
||||
raise IndexError # consistency bug
|
||||
|
||||
lst = settings.get('multisig', [])
|
||||
lst = settings.get(self.key_name, [])
|
||||
del lst[self.storage_idx]
|
||||
settings.set('multisig', lst)
|
||||
settings.set(self.key_name, lst)
|
||||
settings.save()
|
||||
|
||||
self.storage_idx = -1
|
||||
@ -437,9 +395,9 @@ class MultisigWallet:
|
||||
# Assuming a suffix of /0/0 on the defined prefix's, yield
|
||||
# possible deposit addresses for this wallet. Never show
|
||||
# user the resulting addresses because we cannot be certain
|
||||
# they are valid and could be signed. And yet, dont blank too many
|
||||
# they are valid and could be signed. And yet, don't blank too many
|
||||
# spots or else an attacker could grid out a suitable replacement.
|
||||
ch = self.chain
|
||||
ch = chains.current_chain()
|
||||
|
||||
assert self.addr_fmt, 'no addr fmt known'
|
||||
|
||||
@ -460,6 +418,7 @@ class MultisigWallet:
|
||||
# make the redeem script, convert into address
|
||||
script = make_redeem_script(self.M, nodes, idx)
|
||||
addr = ch.p2sh_address(self.addr_fmt, script)
|
||||
|
||||
addr = addr[0:12] + '___' + addr[12+3:]
|
||||
|
||||
yield idx, [p.format(idx=idx) for p in paths], addr, script
|
||||
@ -467,6 +426,35 @@ class MultisigWallet:
|
||||
idx += 1
|
||||
count -= 1
|
||||
|
||||
def make_addresses_msg(self, msg, start, n, change=0):
|
||||
from glob import dis
|
||||
|
||||
addrs = []
|
||||
|
||||
for (i, paths, addr, script) in self.yield_addresses(start, n, change_idx=change):
|
||||
if i == 0 and self.N <= 4:
|
||||
msg += '\n'.join(paths) + '\n =>\n'
|
||||
else:
|
||||
msg += '.../%d/%d =>\n' % (change, i)
|
||||
|
||||
addrs.append(addr)
|
||||
msg += truncate_address(addr) + '\n\n'
|
||||
dis.progress_bar_show(i / n)
|
||||
|
||||
return msg, addrs
|
||||
|
||||
def generate_address_csv(self, start, n, change):
|
||||
yield '"' + '","'.join(['Index', 'Payment Address',
|
||||
'Redeem Script (%d of %d)' % (self.M, self.N)]
|
||||
+ (['Derivation'] * self.N)) + '"\n'
|
||||
|
||||
for (idx, derivs, addr, script) in self.yield_addresses(start, n, change_idx=change):
|
||||
ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode())
|
||||
ln += '","'.join(derivs)
|
||||
ln += '"\n'
|
||||
|
||||
yield ln
|
||||
|
||||
def validate_script(self, redeem_script, subpaths=None, xfp_paths=None):
|
||||
# Check we can generate all pubkeys in the redeem script, raise on errors.
|
||||
# - working from pubkeys in the script, because duplicate XFP can happen
|
||||
@ -483,7 +471,8 @@ class MultisigWallet:
|
||||
M, N, pubkeys = disassemble_multisig(redeem_script)
|
||||
assert M==self.M and N == self.N, 'wrong M/N in script'
|
||||
|
||||
if self.disable_checks: return ['UNVERIFIED']
|
||||
if self.disable_checks:
|
||||
return ['UNVERIFIED']
|
||||
|
||||
for pk_order, pubkey in enumerate(pubkeys):
|
||||
check_these = []
|
||||
@ -535,7 +524,7 @@ class MultisigWallet:
|
||||
found_pk = node.pubkey()
|
||||
|
||||
# Document path(s) used. Not sure this is useful info to user tho.
|
||||
# - Do not show what we can't verify: we don't really know the hardeneded
|
||||
# - Do not show what we can't verify: we don't really know the hardened
|
||||
# part of the path from fingerprint to here.
|
||||
here = '[%s]' % xfp2str(xfp)
|
||||
if dp != len(path):
|
||||
@ -581,6 +570,7 @@ class MultisigWallet:
|
||||
xpubs = []
|
||||
addr_fmt = AF_P2SH
|
||||
my_xfp = settings.get('xfp')
|
||||
|
||||
for ln in lines:
|
||||
# remove comments
|
||||
comm = ln.find('#')
|
||||
@ -646,9 +636,12 @@ class MultisigWallet:
|
||||
continue
|
||||
|
||||
# deserialize, update list and lots of checks
|
||||
is_mine = cls.check_xpub(xfp, value, deriv, chains.current_chain().ctype, my_xfp, xpubs)
|
||||
is_mine, item = check_xpub(xfp, value, deriv, chains.current_key_chain().ctype,
|
||||
my_xfp, cls.disable_checks)
|
||||
xpubs.append(item)
|
||||
if is_mine:
|
||||
has_mine += 1
|
||||
|
||||
return name, addr_fmt, xpubs, has_mine, M, N
|
||||
|
||||
@classmethod
|
||||
@ -658,20 +651,34 @@ class MultisigWallet:
|
||||
my_xfp = settings.get('xfp')
|
||||
xpubs = []
|
||||
|
||||
desc = MultisigDescriptor.parse(descriptor)
|
||||
for xfp, deriv, xpub in desc.keys:
|
||||
descriptor = Descriptor.from_string(descriptor)
|
||||
descriptor.legacy_ms_compat() # raises
|
||||
addr_fmt = descriptor.addr_fmt
|
||||
|
||||
M, N = descriptor.miniscript.m_n()
|
||||
for key in descriptor.miniscript.keys:
|
||||
assert key.derivation.is_external, "Invalid subderivation path - only 0/* or <0;1>/* allowed"
|
||||
xfp = key.origin.cc_fp
|
||||
deriv = key.origin.str_derivation()
|
||||
xpub = key.extended_public_key()
|
||||
deriv = cleanup_deriv_path(deriv)
|
||||
is_mine = cls.check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, my_xfp, xpubs)
|
||||
is_mine, item = check_xpub(xfp, xpub, deriv, chains.current_key_chain().ctype,
|
||||
my_xfp, cls.disable_checks)
|
||||
xpubs.append(item)
|
||||
if is_mine:
|
||||
has_mine += 1
|
||||
return None, desc.addr_fmt, xpubs, has_mine, desc.M, desc.N
|
||||
|
||||
return None, addr_fmt, xpubs, has_mine, M, N
|
||||
|
||||
def to_descriptor(self):
|
||||
return MultisigDescriptor(
|
||||
M=self.M, N=self.N,
|
||||
keys=self.xpubs,
|
||||
addr_fmt=self.addr_fmt,
|
||||
)
|
||||
keys = [
|
||||
Key.from_cc_data(xfp, deriv, xpub)
|
||||
for xfp, deriv, xpub in self.xpubs
|
||||
]
|
||||
miniscript = Sortedmulti(Number(self.M), *keys)
|
||||
desc = Descriptor(miniscript=miniscript)
|
||||
desc.set_from_addr_fmt(self.addr_fmt)
|
||||
return desc
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config, name=None):
|
||||
@ -692,8 +699,8 @@ class MultisigWallet:
|
||||
# - M of N line (assume N of N if not spec'd)
|
||||
# - xpub: any bip32 serialization we understand, but be consistent
|
||||
#
|
||||
expect_chain = chains.current_chain().ctype
|
||||
if "sortedmulti(" in config or MultisigDescriptor.is_descriptor(config):
|
||||
expect_chain = chains.current_key_chain().ctype
|
||||
if Descriptor.is_descriptor(config):
|
||||
# assume descriptor, classic config should not contain sertedmulti( and check for checksum separator
|
||||
# ignore name
|
||||
_, addr_fmt, xpubs, has_mine, M, N = cls.from_descriptor(config)
|
||||
@ -721,9 +728,7 @@ class MultisigWallet:
|
||||
except:
|
||||
raise AssertionError('name must be ascii, 1..20 long')
|
||||
|
||||
assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range'
|
||||
assert N == len(xpubs), 'wrong # of xpubs, expect %d' % N
|
||||
assert addr_fmt & AFC_SCRIPT, 'script style addr fmt'
|
||||
|
||||
# check we're included... do not insert ourselves, even tho we
|
||||
# have enough info, simply because other signers need to know my xpubkey anyway
|
||||
@ -733,83 +738,6 @@ class MultisigWallet:
|
||||
# done. have all the parts
|
||||
return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, chain_type=expect_chain)
|
||||
|
||||
@classmethod
|
||||
def check_xpub(cls, xfp, xpub, deriv, expect_chain, my_xfp, xpubs):
|
||||
# Shared code: consider an xpub for inclusion into a wallet, if ok, append
|
||||
# to list: xpubs with a tuple: (xfp, deriv, xpub)
|
||||
# return T if it's our own key
|
||||
# - deriv can be None, and in very limited cases can recover derivation path
|
||||
# - could enforce all same depth, and/or all depth >= 1, but
|
||||
# seems like more restrictive than needed, so "m" is allowed
|
||||
|
||||
try:
|
||||
# Note: addr fmt detected here via SLIP-132 isn't useful
|
||||
node, chain, _ = parse_extended_key(xpub)
|
||||
except:
|
||||
raise AssertionError('unable to parse xpub')
|
||||
|
||||
try:
|
||||
assert node.privkey() == None # 'no privkeys plz'
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if expect_chain == "XRT":
|
||||
# HACK but there is no difference extended_keys - just bech32 hrp
|
||||
assert chain.ctype == "XTN"
|
||||
else:
|
||||
assert chain.ctype == expect_chain # 'wrong chain'
|
||||
|
||||
depth = node.depth()
|
||||
|
||||
if depth == 1:
|
||||
if not xfp:
|
||||
# allow a shortcut: zero/omit xfp => use observed parent value
|
||||
xfp = swab32(node.parent_fp())
|
||||
else:
|
||||
# generally cannot check fingerprint values, but if we can, do so.
|
||||
if not cls.disable_checks:
|
||||
assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong'
|
||||
|
||||
assert xfp, 'need fingerprint' # happens if bare xpub given
|
||||
|
||||
# In most cases, we cannot verify the derivation path because it's hardened
|
||||
# and we know none of the private keys involved.
|
||||
if depth == 1:
|
||||
# but derivation is implied at depth==1
|
||||
kn, is_hard = node.child_number()
|
||||
if is_hard: kn |= 0x80000000
|
||||
guess = keypath_to_str([kn], skip=0)
|
||||
|
||||
if deriv:
|
||||
if not cls.disable_checks:
|
||||
assert guess == deriv, '%s != %s' % (guess, deriv)
|
||||
else:
|
||||
deriv = guess # reachable? doubt it
|
||||
|
||||
assert deriv, 'empty deriv' # or force to be 'm'?
|
||||
assert deriv[0] == 'm'
|
||||
|
||||
# path length of derivation given needs to match xpub's depth
|
||||
if not cls.disable_checks:
|
||||
p_len = deriv.count('/')
|
||||
assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % (
|
||||
p_len, depth, xfp2str(xfp))
|
||||
|
||||
if xfp == my_xfp:
|
||||
# its supposed to be my key, so I should be able to generate pubkey
|
||||
# - might indicate collision on xfp value between co-signers,
|
||||
# and that's not supported
|
||||
with stash.SensitiveValues() as sv:
|
||||
chk_node = sv.derive_path(deriv)
|
||||
assert node.pubkey() == chk_node.pubkey(), \
|
||||
"[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:])
|
||||
|
||||
# serialize xpub w/ BIP-32 standard now.
|
||||
# - this has effect of stripping SLIP-132 confusion away
|
||||
xpubs.append((xfp, deriv, chain.serialize_public(node, AF_P2SH)))
|
||||
|
||||
return (xfp == my_xfp)
|
||||
|
||||
def make_fname(self, prefix, suffix='txt'):
|
||||
rv = '%s-%s.%s' % (prefix, self.name, suffix)
|
||||
return rv.replace(' ', '_')
|
||||
@ -909,7 +837,7 @@ class MultisigWallet:
|
||||
await needs_microsd()
|
||||
return
|
||||
except Exception as e:
|
||||
await ux_show_story('Failed to write!\n\n\n'+str(e))
|
||||
await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
return
|
||||
|
||||
def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True):
|
||||
@ -922,9 +850,10 @@ class MultisigWallet:
|
||||
print("importdescriptors '%s'\n" % core_str, file=fp)
|
||||
else:
|
||||
if desc_pretty:
|
||||
desc = desc_obj.pretty_serialize()
|
||||
# TODO pretty serialize
|
||||
desc = desc_obj.to_string(internal=False)
|
||||
else:
|
||||
desc = desc_obj.serialize()
|
||||
desc = desc_obj.to_string(internal=False)
|
||||
print("%s\n" % desc, file=fp)
|
||||
else:
|
||||
if hdr_comment:
|
||||
@ -996,8 +925,9 @@ class MultisigWallet:
|
||||
for k, v in xpubs_list:
|
||||
xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0)
|
||||
xpub = ngu.codecs.b58_encode(v)
|
||||
is_mine = cls.check_xpub(xfp, xpub, keypath_to_str(path, skip=0),
|
||||
expect_chain, my_xfp, xpubs)
|
||||
is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0),
|
||||
expect_chain, my_xfp, cls.disable_checks)
|
||||
xpubs.append(item)
|
||||
if is_mine:
|
||||
has_mine += 1
|
||||
addr_fmt = cls.guess_addr_fmt(path)
|
||||
@ -1005,7 +935,7 @@ class MultisigWallet:
|
||||
assert has_mine == 1 # 'my key not included'
|
||||
|
||||
name = 'PSBT-%d-of-%d' % (M, N)
|
||||
ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH)
|
||||
ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy
|
||||
|
||||
# may just keep just in-memory version, no approval required, if we are
|
||||
# trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet
|
||||
@ -1029,7 +959,9 @@ class MultisigWallet:
|
||||
|
||||
# cleanup and normalize xpub
|
||||
tmp = []
|
||||
self.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), self.chain_type, 0, tmp)
|
||||
is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0),
|
||||
self.chain_type, 0, self.disable_checks)
|
||||
tmp.append(item)
|
||||
(_, deriv, xpub_reserialized) = tmp[0]
|
||||
assert deriv # because given as arg
|
||||
|
||||
@ -1122,7 +1054,7 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=
|
||||
continue
|
||||
|
||||
if ch == 'y' and not is_dup:
|
||||
# save to nvram, may raise MultisigOutOfSpace
|
||||
# save to nvram, may raise WalletOutOfSpace
|
||||
if name_change:
|
||||
name_change.delete()
|
||||
|
||||
@ -1146,13 +1078,15 @@ Addresses:
|
||||
at=self.render_addr_fmt(self.addr_fmt)))
|
||||
|
||||
# concern: the order of keys here is non-deterministic
|
||||
# or order is taken from descriptor order (multi) but we do not support it
|
||||
# or order is determined by BIP (sortedmulti)
|
||||
for idx, (xfp, deriv, xpub) in enumerate(self.xpubs):
|
||||
if idx:
|
||||
msg.write('\n---===---\n\n')
|
||||
|
||||
msg.write('%s:\n %s\n\n%s\n' % (xfp2str(xfp), deriv, xpub))
|
||||
|
||||
if self.addr_fmt != AF_P2SH:
|
||||
if self.addr_fmt not in (AF_P2SH, AF_P2TR):
|
||||
# SLIP-132 format [yz]pubs here when not p2sh mode.
|
||||
# - has same info as proper bitcoin serialization, but looks much different
|
||||
node = self.chain.deserialize_node(xpub, AF_P2SH)
|
||||
@ -1238,9 +1172,11 @@ class MultisigMenu(MenuSystem):
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# Dynamic menu with user-defined names of wallets shown
|
||||
from bsms import make_ms_wallet_bsms_menu
|
||||
|
||||
if not MultisigWallet.exists():
|
||||
rv = [MenuItem('(none setup yet)', f=no_ms_yet)]
|
||||
exists, exists_other_chain = MultisigWallet.exists()
|
||||
if not exists:
|
||||
rv = [MenuItem(MultisigWallet.none_setup_yet(exists_other_chain), f=no_ms_yet)]
|
||||
else:
|
||||
rv = []
|
||||
for ms in MultisigWallet.get_all():
|
||||
@ -1250,6 +1186,7 @@ class MultisigMenu(MenuSystem):
|
||||
rv.append(MenuItem('Import from File', f=import_multisig))
|
||||
rv.append(MenuItem('Import via NFC', f=import_multisig_nfc, predicate=lambda: NFC is not None))
|
||||
rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs))
|
||||
rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu))
|
||||
rv.append(MenuItem('Create Airgapped', f=create_ms_step1))
|
||||
rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu))
|
||||
rv.append(MenuItem('Skip Checks?', f=disable_checks_menu))
|
||||
@ -1279,15 +1216,14 @@ async def make_ms_wallet_menu(menu, label, item):
|
||||
ms = MultisigWallet.get_by_idx(item.arg)
|
||||
if not ms: return
|
||||
|
||||
rv = [
|
||||
return [
|
||||
MenuItem('"%s"' % ms.name, f=ms_wallet_detail, arg=ms),
|
||||
MenuItem('View Details', f=ms_wallet_detail, arg=ms),
|
||||
MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms),
|
||||
MenuItem('Delete', f=ms_wallet_delete, arg=ms),
|
||||
MenuItem('Coldcard Export', f=ms_wallet_ckcc_export, arg=(ms, {})),
|
||||
MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms),
|
||||
MenuItem('Electrum Wallet', f=ms_wallet_electrum_export, arg=ms),
|
||||
]
|
||||
return rv
|
||||
|
||||
async def make_ms_wallet_descriptor_menu(menu, label, item):
|
||||
# descriptor menu
|
||||
@ -1333,7 +1269,7 @@ async def ms_wallet_ckcc_export(menu, label, item):
|
||||
async def ms_wallet_show_descriptor(menu, label, item):
|
||||
ms = item.arg
|
||||
desc = ms.to_descriptor()
|
||||
desc_str = desc.serialize()
|
||||
desc_str = desc.to_string(internal=False)
|
||||
ch = await ux_show_story("Press (1) to export in pretty human readable format.\n\n" + desc_str, escape="1")
|
||||
if ch == "1":
|
||||
await ms.export_wallet_file(descriptor=True, desc_pretty=True)
|
||||
@ -1378,7 +1314,7 @@ async def export_multisig_xpubs(*a):
|
||||
# Consumer for this file is supposed to be ourselves, when we build on-device multisig.
|
||||
# - however some 3rd parties are making use of it as well.
|
||||
#
|
||||
from glob import NFC
|
||||
from glob import NFC, dis
|
||||
|
||||
xfp = xfp2str(settings.get('xfp', 0))
|
||||
chain = chains.current_chain()
|
||||
@ -1397,6 +1333,8 @@ P2SH-P2WSH:
|
||||
m/48'/{coin}'/acct'/1'
|
||||
P2WSH:
|
||||
m/48'/{coin}'/acct'/2'
|
||||
P2TR:
|
||||
m/48'/{coin}'/acct'/3'
|
||||
|
||||
OK to continue. X to abort.'''.format(coin=chain.b44_cointype)
|
||||
|
||||
@ -1414,10 +1352,13 @@ OK to continue. X to abort.'''.format(coin=chain.b44_cointype)
|
||||
force_vdisk = True
|
||||
if ch not in escape: return
|
||||
|
||||
dis.fullscreen('Generating...')
|
||||
|
||||
todo = [
|
||||
( "m/45'", 'p2sh', AF_P2SH), # iff acct_num == 0
|
||||
( "m/48'/{coin}'/{acct_num}'/1'", 'p2sh_p2wsh', AF_P2WSH_P2SH ),
|
||||
( "m/48'/{coin}'/{acct_num}'/2'", 'p2wsh', AF_P2WSH ),
|
||||
( "m/48'/{coin}'/{acct_num}'/3'", 'p2tr', AF_P2TR ),
|
||||
]
|
||||
|
||||
def render(fp):
|
||||
@ -1503,7 +1444,7 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk=
|
||||
# sigh, OS/filesystem variations
|
||||
file_size = var[1] if len(var) == 2 else get_filesize(full_fname)
|
||||
|
||||
if not (0 <= file_size <= 1100):
|
||||
if not (0 <= file_size <= 1500):
|
||||
# out of range size
|
||||
continue
|
||||
|
||||
@ -1521,8 +1462,9 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk=
|
||||
assert deriv == vals[mode+'_deriv'], "wrong derivation: %s != %s"%(
|
||||
deriv, vals[mode+'_deriv'])
|
||||
|
||||
is_mine = MultisigWallet.check_xpub(xfp, ln, deriv,
|
||||
chain.ctype, my_xfp, xpubs)
|
||||
is_mine, item = check_xpub(xfp, ln, deriv, chain.ctype,
|
||||
my_xfp, MultisigWallet.disable_checks)
|
||||
xpubs.append(item)
|
||||
if is_mine:
|
||||
has_mine += 1
|
||||
|
||||
@ -1605,9 +1547,9 @@ Coldcard multisig setup file and an Electrum wallet file will be created automat
|
||||
name = 'CC-%d-of-%d' % (M, N)
|
||||
ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt)
|
||||
|
||||
from auth import NewEnrollRequest, UserAuthorizedAction
|
||||
from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction
|
||||
|
||||
UserAuthorizedAction.active_request = NewEnrollRequest(ms, auto_export=True)
|
||||
UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(ms, auto_export=True)
|
||||
|
||||
# menu item case: add to stack
|
||||
from ux import the_ux
|
||||
@ -1637,14 +1579,14 @@ async def import_multisig_nfc(*a):
|
||||
from glob import NFC
|
||||
# this menu option should not be available if NFC is disabled
|
||||
try:
|
||||
return await NFC.import_multisig_nfc()
|
||||
return await NFC.import_miniscript_nfc(legacy_multisig=True)
|
||||
except Exception as e:
|
||||
await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e))
|
||||
|
||||
async def import_multisig(*a):
|
||||
# pick text file from SD card, import as multisig setup file
|
||||
from actions import file_picker
|
||||
from glob import VD
|
||||
from glob import VD, dis
|
||||
|
||||
force_vdisk = False
|
||||
if VD:
|
||||
@ -1671,8 +1613,9 @@ async def import_multisig(*a):
|
||||
if 'pub' in ln:
|
||||
return True
|
||||
|
||||
fn = await file_picker('Pick multisig wallet file to import (.txt)', suffix='.txt', min_size=100,
|
||||
max_size=20*200, taster=possible, force_vdisk=force_vdisk)
|
||||
fn = await file_picker('Pick multisig wallet file to import (.txt,.json)',
|
||||
suffix=['.txt', '.json'], min_size=100, max_size=350*200,
|
||||
taster=possible, force_vdisk=force_vdisk)
|
||||
if not fn: return
|
||||
|
||||
try:
|
||||
@ -1683,6 +1626,7 @@ async def import_multisig(*a):
|
||||
await needs_microsd()
|
||||
return
|
||||
|
||||
dis.fullscreen('Wait...')
|
||||
from auth import maybe_enroll_xpub
|
||||
try:
|
||||
possible_name = (fn.split('/')[-1].split('.'))[0]
|
||||
|
||||
102
shared/nfc.py
102
shared/nfc.py
@ -7,7 +7,7 @@
|
||||
# - has GPIO signal "??" which is multipurpose on its own pin
|
||||
# - this chip chosen because it can disable RF interaction
|
||||
#
|
||||
import ngu, utime, ngu, ndef
|
||||
import utime, ngu, ndef
|
||||
from uasyncio import sleep_ms
|
||||
from ustruct import pack, unpack
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
@ -535,32 +535,6 @@ class NFCHandler:
|
||||
else:
|
||||
raise ValueError(ctype)
|
||||
|
||||
async def import_multisig_nfc(self, *a):
|
||||
# user is pushing a file downloaded from another CC over NFC
|
||||
# - would need an NFC app in between for the sneakernet step
|
||||
# get some data
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
if len(msg) < 70: continue
|
||||
msg = bytes(msg).decode() # from memory view
|
||||
if 'pub' in msg or 'sortedmulti(' in msg:
|
||||
winner = msg
|
||||
break
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find data expected in NDEF')
|
||||
return
|
||||
|
||||
from auth import maybe_enroll_xpub
|
||||
try:
|
||||
maybe_enroll_xpub(config=winner)
|
||||
except Exception as e:
|
||||
#import sys; sys.print_exception(e)
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
|
||||
async def import_ephemeral_seed_words_nfc(self, *a):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
@ -579,7 +553,7 @@ class NFCHandler:
|
||||
|
||||
try:
|
||||
from seed import set_ephemeral_seed_words
|
||||
await set_ephemeral_seed_words(winner)
|
||||
await set_ephemeral_seed_words(winner, meta='NFC Import')
|
||||
except Exception as e:
|
||||
#import sys; sys.print_exception(e)
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
@ -739,4 +713,76 @@ class NFCHandler:
|
||||
|
||||
return winner
|
||||
|
||||
|
||||
async def read_bsms_token(self):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data:
|
||||
await ux_show_story('Unable to find data expected in NDEF')
|
||||
return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
msg = bytes(msg).decode().strip() # from memory view
|
||||
try:
|
||||
int(msg, 16)
|
||||
winner = msg
|
||||
break
|
||||
except: pass
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find BSMS token in NDEF data')
|
||||
return
|
||||
|
||||
return winner
|
||||
|
||||
async def read_bsms_data(self):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data:
|
||||
await ux_show_story('Unable to find data expected in NDEF')
|
||||
return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
msg = bytes(msg).decode().strip() # from memory view
|
||||
try:
|
||||
if "BSMS" in msg:
|
||||
# unencrypted case
|
||||
winner = msg
|
||||
break
|
||||
elif int(msg[:6], 16):
|
||||
# encrypted hex case
|
||||
winner = msg
|
||||
break
|
||||
else:
|
||||
continue
|
||||
except: pass
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find BSMS data in NDEF data')
|
||||
return
|
||||
|
||||
return winner
|
||||
|
||||
async def import_miniscript_nfc(self, legacy_multisig=False):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
if len(msg) < 70: continue
|
||||
msg = bytes(msg).decode() # from memory view
|
||||
if 'pub' in msg:
|
||||
winner = msg
|
||||
break
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find miniscript descriptor expected in NDEF')
|
||||
return
|
||||
|
||||
from auth import maybe_enroll_xpub
|
||||
try:
|
||||
maybe_enroll_xpub(config=winner, miniscript=not legacy_multisig)
|
||||
except Exception as e:
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
|
||||
# EOF
|
||||
|
||||
@ -12,19 +12,15 @@
|
||||
# - encrypted and stored in SPI flash, in last 128k area
|
||||
# - AES encryption key is derived from actual wallet secret
|
||||
# - if logged out, then use fixed key instead (ie. it's public)
|
||||
# - to support multiple wallets and plausible deniablity, we
|
||||
# will preserve any noise already there, and only replace our own stuff
|
||||
# - you cannot move data between slots because AES-CTR with CTR seed based on slot #
|
||||
# - SHA-256 check on decrypted data
|
||||
# - (Mk4) each slot is a file on /flash/settings
|
||||
#
|
||||
import os, sys, ujson, ustruct, ckcc, gc, ngu, aes256ctr
|
||||
from uio import BytesIO
|
||||
import os, ujson, ustruct, ckcc, gc, ngu, aes256ctr
|
||||
from uhashlib import sha256
|
||||
from random import shuffle, randbelow
|
||||
from random import randbelow
|
||||
from utils import call_later_ms
|
||||
from version import mk_num, is_devmode
|
||||
from glob import PSRAM
|
||||
|
||||
# TODO fs.sync
|
||||
|
||||
@ -36,9 +32,9 @@ from glob import PSRAM
|
||||
# b39skip = (bool) skip discussion about use of BIP-39 passphrase
|
||||
# idle_to = idle timeout period (seconds)
|
||||
# _age = internal verison number for data (see below)
|
||||
# terms_ok = customer has signed-off on the terms of sale
|
||||
# tested = selftest has been completed successfully
|
||||
# multisig = list of defined multisig wallets (complex)
|
||||
# miniscript = list of defined miniscript wallets (complex)
|
||||
# pms = trust/import/distrust xpubs found in PSBT files
|
||||
# axi = index of last selected address in explorer
|
||||
# lgto = (minutes) how long to wait for Login Countdown feature [pre v4.0.2]
|
||||
@ -58,48 +54,61 @@ from glob import PSRAM
|
||||
# sd2fa = (list of strings): track which SD card is needed for login
|
||||
# bkpw = (string): last backup password, so can be re-used easily
|
||||
# sighshchk = (bool) set if sighash checks are disabled
|
||||
# seedvault = (bool) opt-in enable seed vault feature
|
||||
# seeds = list of stored secrets for seedvault feature
|
||||
|
||||
# Stored w/ key=00 for access before login
|
||||
# _skip_pin = hard code a PIN value (dangerous, only for debug)
|
||||
# nick = optional nickname for this coldcard (personalization)
|
||||
# rngk = randomize keypad for PIN entry
|
||||
# delay_left = [REMOVED-obsolete] seconds remaining on login countdown, if defined
|
||||
# lgto = (minutes) how long to wait for Login Countdown feature [in v4.0.2+]
|
||||
# cd_lgto = [<=mk3] minutes to show in countdown (in countdown-to-brick mode)
|
||||
# cd_mode = [<=mk3] set to enable some less-destructive modes
|
||||
# cd_pin = [<=mk3] pin code which enables "countdown to brick" mode
|
||||
# kbtn = (1 char) '1'-'9' that will wipe seed during login process (mk4+)
|
||||
# terms_ok = customer has signed-off on the terms of sale
|
||||
|
||||
# settings linked to seed
|
||||
# LINKED_SETTINGS = ["multisig", "miniscript", "tp", "ovc", "xfp", "xpub", "words"]
|
||||
# settings that does not make sense to copy to temporary secret
|
||||
# LINKED_SETTINGS += ["sd2fa", "usr", "axi", "hsmcmd"]
|
||||
# prelogin settings - do not need to be part of other saved settings
|
||||
# PRELOGIN_SETTINGS = ["_skip_pin", "nick", "rngk", "lgto", "kbtn", "terms_ok"]
|
||||
# keep these settings only if unspecified on the other end
|
||||
KEEP_IF_BLANK_SETTINGS = ["bkpw", "wa", "sighshchk", "emu", "rz",
|
||||
"axskip", "del", "pms", "idle_to", "b39skip"]
|
||||
|
||||
SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words']
|
||||
|
||||
NUM_SLOTS = const(100)
|
||||
SLOTS = range(NUM_SLOTS)
|
||||
MK4_WORKDIR = '/flash/settings/'
|
||||
|
||||
|
||||
if mk_num <= 3:
|
||||
# where in SPI Flash we work (last 128k)
|
||||
SLOTS = range((1024-128)*1024, 1024*1024, 4096)
|
||||
NUM_SLOTS = 32
|
||||
# for mk4: we store binary files on LFS2 filesystem
|
||||
def MK4_FILENAME(slot):
|
||||
return MK4_WORKDIR + ('%03x.aes' % slot)
|
||||
|
||||
from sffile import SFFile
|
||||
from sflash import SF
|
||||
else:
|
||||
# work in LFS2 filesystem instead, but same terminology
|
||||
SLOTS = range(0, 64)
|
||||
NUM_SLOTS = 64
|
||||
|
||||
MK4_WORKDIR = '/flash/settings/'
|
||||
|
||||
# for mk4: we store binary files on LFS2 filesystem
|
||||
def MK4_FILENAME(slot):
|
||||
return MK4_WORKDIR + ('%03x.aes' % slot)
|
||||
|
||||
class SettingsObject:
|
||||
# class vars: track a few values from master seed settings
|
||||
master_sv_data = {}
|
||||
master_nvram_key = None
|
||||
|
||||
def __init__(self, dis=None):
|
||||
def __init__(self, nvram_key=None):
|
||||
# NOTE: constructor no longer loads the values by default (too slow).
|
||||
self.is_dirty = 0
|
||||
self.my_pos = None
|
||||
|
||||
self.nvram_key = b'\0'*32
|
||||
self.capacity = 0
|
||||
self.nvram_key = nvram_key or b'\0'*32
|
||||
self.current = self.default_values()
|
||||
self.overrides = {} # volatile overide values
|
||||
|
||||
self.load(dis)
|
||||
@classmethod
|
||||
def prelogin(cls):
|
||||
# make an instance of the pre-login settings (ie. w/o key)
|
||||
rv = cls()
|
||||
rv.load()
|
||||
return rv
|
||||
|
||||
def get_aes(self, pos):
|
||||
# Build AES object for en/decrypt of specific block.
|
||||
@ -107,12 +116,25 @@ class SettingsObject:
|
||||
ctr = ustruct.pack('<4I', 4, 3, 2, pos)
|
||||
return aes256ctr.new(self.nvram_key, ctr)
|
||||
|
||||
@staticmethod
|
||||
def hash_key(secret):
|
||||
# hash up the secret... without decoding it or similar
|
||||
assert len(secret) >= 32
|
||||
|
||||
s = sha256(secret)
|
||||
|
||||
for round in range(5):
|
||||
s.update('pad')
|
||||
s = sha256(s.digest())
|
||||
|
||||
return s.digest()
|
||||
|
||||
def set_key(self, new_secret=None):
|
||||
# System settings (not secrets) are stored in flash, encrypted with this
|
||||
# key that is derived from main wallet secret. Call this method when the secret
|
||||
# is first loaded, or changes for some reason.
|
||||
from pincodes import pa
|
||||
from stash import blank_object, SensitiveValues
|
||||
from stash import blank_object
|
||||
|
||||
key = None
|
||||
mine = False
|
||||
@ -125,47 +147,28 @@ class SettingsObject:
|
||||
# read secret and use it.
|
||||
new_secret = pa.fetch()
|
||||
mine = True
|
||||
SensitiveValues.cache_secret(new_secret)
|
||||
|
||||
if new_secret:
|
||||
# hash up the secret... without decoding it or similar
|
||||
assert len(new_secret) >= 32
|
||||
|
||||
s = sha256(new_secret)
|
||||
|
||||
for round in range(5):
|
||||
s.update('pad')
|
||||
|
||||
s = sha256(s.digest())
|
||||
|
||||
key = s.digest()
|
||||
key = self.hash_key(new_secret)
|
||||
|
||||
if mine:
|
||||
blank_object(new_secret)
|
||||
|
||||
# for restore from backup case, or when changing (created) the seed
|
||||
# save value for use in self.get_aes()
|
||||
self.nvram_key = key
|
||||
|
||||
def get_capacity(self):
|
||||
# percent space used (0.0=>empty)
|
||||
if mk_num <= 3:
|
||||
return self.capacity
|
||||
|
||||
# could use whole filesystem, so use that as imprecise proxy
|
||||
_, _, blocks, bfree, *_ = os.statvfs(MK4_WORKDIR)
|
||||
|
||||
return (blocks-bfree) / blocks
|
||||
|
||||
|
||||
def _open_file(self, pos, mode='rb'):
|
||||
return open(MK4_FILENAME(pos), mode)
|
||||
|
||||
def _slot_is_blank(self, pos, buf):
|
||||
# read a few bytes from start of slot
|
||||
if mk_num <= 3:
|
||||
SF.read(pos, buf)
|
||||
return (buf[0] == buf[1] == buf[2] == buf[3] == 0xff)
|
||||
|
||||
try:
|
||||
with self._open_file(pos) as fd:
|
||||
fd.readinto(buf)
|
||||
@ -175,73 +178,38 @@ class SettingsObject:
|
||||
|
||||
def _wipe_slot(self, pos):
|
||||
# blank out a slot
|
||||
if mk_num <= 3:
|
||||
SF.wait_done()
|
||||
SF.sector_erase(pos)
|
||||
SF.wait_done()
|
||||
else:
|
||||
fn = MK4_FILENAME(pos)
|
||||
try:
|
||||
os.remove(fn)
|
||||
except BaseException as exc:
|
||||
# Error (ENOENT) expected here when saving first time, because the
|
||||
# "old" slot was not in use
|
||||
pass
|
||||
fn = MK4_FILENAME(pos)
|
||||
try:
|
||||
os.remove(fn)
|
||||
except Exception:
|
||||
# Error (ENOENT) expected here when saving first time, because the
|
||||
# "old" slot was not in use
|
||||
pass
|
||||
|
||||
def _deny_slot(self, pos):
|
||||
# write garbage to look legit in a slot
|
||||
if mk_num <= 3:
|
||||
with self._open_file(pos, 'wb') as fd:
|
||||
for i in range(0, 4096, 256):
|
||||
h = ngu.random.bytes(256)
|
||||
SF.wait_done()
|
||||
SF.write(pos+i, h)
|
||||
else:
|
||||
with self._open_file(pos, 'wb') as fd:
|
||||
for i in range(0, 4096, 256):
|
||||
h = ngu.random.bytes(256)
|
||||
fd.write(h)
|
||||
fd.write(h)
|
||||
|
||||
def _read_slot(self, pos, decryptor):
|
||||
# return decrypted (json text) and last 32-bytes which is SHA-256 over that
|
||||
if mk_num <= 3:
|
||||
chk = sha256()
|
||||
# Mk4 is just reading a binary file and decrypt as we go.
|
||||
with self._open_file(pos) as fd:
|
||||
# missing ftell(), so emulate
|
||||
ln = fd.seek(0, 2)
|
||||
fd.seek(0, 0)
|
||||
|
||||
from sram2 import nvstore_buf as _tmp
|
||||
buf = fd.read(ln - 32)
|
||||
assert len(buf) == ln-32
|
||||
|
||||
with SFFile(pos, length=4096, pre_erased=True) as fd:
|
||||
for i in range(4096/32):
|
||||
b = decryptor(fd.read(32))
|
||||
if i != 127:
|
||||
_tmp[i*32:(i*32)+32] = b
|
||||
chk.update(b)
|
||||
else:
|
||||
expect = b
|
||||
rv = decryptor(buf)
|
||||
digest = ngu.hash.sha256s(rv)
|
||||
|
||||
# check how much space was used for encoded JSON
|
||||
try:
|
||||
end = bytes(_tmp).index(b'\0')
|
||||
self.capacity = end / (4096-32)
|
||||
except ValueError:
|
||||
self.capacity = 1.0
|
||||
expect = decryptor(fd.read(32))
|
||||
assert len(expect) == 32
|
||||
|
||||
return _tmp, expect, chk.digest()
|
||||
else:
|
||||
# Mk4 is just reading a binary file and decrypt as we go.
|
||||
with self._open_file(pos) as fd:
|
||||
# missing ftell(), so emulate
|
||||
ln = fd.seek(0, 2)
|
||||
fd.seek(0, 0)
|
||||
|
||||
buf = fd.read(ln - 32)
|
||||
assert len(buf) == ln-32
|
||||
|
||||
rv = decryptor(buf)
|
||||
digest = ngu.hash.sha256s(rv)
|
||||
|
||||
expect = decryptor(fd.read(32))
|
||||
assert len(expect) == 32
|
||||
|
||||
return rv, expect, digest
|
||||
return rv, expect, digest
|
||||
|
||||
def _write_slot(self, pos, aes):
|
||||
# SHA-256 over plaintext
|
||||
@ -249,52 +217,26 @@ class SettingsObject:
|
||||
|
||||
# serialize the data into JSON
|
||||
d = ujson.dumps(self.current)
|
||||
with self._open_file(pos, 'wb') as fd:
|
||||
# pad w/ zeros at least to 4k, but allow larger
|
||||
dat_len = len(d)
|
||||
pad_len = (4096-32) - dat_len
|
||||
|
||||
if mk_num <= 3:
|
||||
with SFFile(pos, max_size=4096, pre_erased=True) as fd:
|
||||
# pad w/ zeros
|
||||
dat_len = len(d)
|
||||
pad_len = (4096-32) - dat_len
|
||||
assert pad_len >= 0, 'too big'
|
||||
fd.write(aes(d))
|
||||
assert fd.tell() == dat_len
|
||||
chk.update(d)
|
||||
del d
|
||||
|
||||
self.capacity = dat_len / 4096
|
||||
while pad_len > 0:
|
||||
here = min(32, pad_len)
|
||||
|
||||
fd.write(aes(d))
|
||||
chk.update(d)
|
||||
del d
|
||||
pad = bytes(here)
|
||||
fd.write(aes(pad))
|
||||
chk.update(pad)
|
||||
|
||||
while pad_len > 0:
|
||||
here = min(32, pad_len)
|
||||
pad_len -= here
|
||||
|
||||
pad = bytes(here)
|
||||
fd.write(aes(pad))
|
||||
chk.update(pad)
|
||||
|
||||
pad_len -= here
|
||||
|
||||
fd.write(aes(chk.digest()))
|
||||
assert fd.tell() == 4096
|
||||
else:
|
||||
with self._open_file(pos, 'wb') as fd:
|
||||
# pad w/ zeros at least to 4k, but allow larger
|
||||
dat_len = len(d)
|
||||
pad_len = (4096-32) - dat_len
|
||||
|
||||
fd.write(aes(d))
|
||||
assert fd.tell() == dat_len
|
||||
chk.update(d)
|
||||
del d
|
||||
|
||||
while pad_len > 0:
|
||||
here = min(32, pad_len)
|
||||
|
||||
pad = bytes(here)
|
||||
fd.write(aes(pad))
|
||||
chk.update(pad)
|
||||
|
||||
pad_len -= here
|
||||
|
||||
fd.write(aes(chk.digest()))
|
||||
fd.write(aes(chk.digest()))
|
||||
|
||||
def _used_slots(self):
|
||||
# mk4: faster list of slots in use; doesn't open them
|
||||
@ -304,45 +246,85 @@ class SettingsObject:
|
||||
def _nonempty_slots(self, dis=None):
|
||||
# generate slots that are non-empty
|
||||
taste = bytearray(4)
|
||||
# use directory listing
|
||||
files = self._used_slots()
|
||||
self.num_empty = NUM_SLOTS - len(files)
|
||||
|
||||
if mk_num <= 3:
|
||||
self.num_empty = 0
|
||||
for i, pos in enumerate(files):
|
||||
if dis:
|
||||
dis.progress_bar_show(i / len(files))
|
||||
|
||||
for pos in SLOTS:
|
||||
if dis:
|
||||
dis.progress_bar_show((pos-SLOTS.start) / (SLOTS.stop-SLOTS.start))
|
||||
gc.collect()
|
||||
if self._slot_is_blank(pos, taste):
|
||||
# unlikely case, but easy to handle
|
||||
continue
|
||||
|
||||
if self._slot_is_blank(pos, taste):
|
||||
# erased (probably)
|
||||
self.num_empty += 1
|
||||
continue
|
||||
yield pos, taste
|
||||
|
||||
yield pos, taste
|
||||
def leaving_master_seed(self):
|
||||
# going from master seed to a tmp seed, so capture a few values we need.
|
||||
|
||||
SettingsObject.master_nvram_key = self.nvram_key
|
||||
|
||||
for fn in SEEDVAULT_FIELDS:
|
||||
curr = self.current.get(fn, None)
|
||||
if curr is not None:
|
||||
SettingsObject.master_sv_data[fn] = curr
|
||||
|
||||
def return_to_master_seed(self):
|
||||
# switching from a tmp seed to the normal master seed
|
||||
# - we already kept the key needed, so just re-read
|
||||
assert SettingsObject.master_nvram_key
|
||||
self.nvram_key = SettingsObject.master_nvram_key
|
||||
self.load()
|
||||
|
||||
# these value no longer required, and might become stale
|
||||
SettingsObject.master_sv_data.clear()
|
||||
SettingsObject.master_nvram_key = None
|
||||
|
||||
def master_set(self, key, value):
|
||||
# Set a value, and it must be saved under the master seed's
|
||||
# Concern is we may be changing a setting from a tmp seed mode
|
||||
# - always does a save
|
||||
from glob import settings as self
|
||||
|
||||
if not SettingsObject.master_nvram_key:
|
||||
# simple, we are already on master seed
|
||||
self.set(key, value)
|
||||
self.save()
|
||||
else:
|
||||
# use directory listing
|
||||
files = self._used_slots()
|
||||
self.num_empty = NUM_SLOTS - len(files)
|
||||
# harder, slower: have to load, change and write
|
||||
master = SettingsObject(nvram_key=SettingsObject.master_nvram_key)
|
||||
master.load()
|
||||
master.set(key, value)
|
||||
master.save()
|
||||
del master
|
||||
|
||||
for i, pos in enumerate(files):
|
||||
if dis:
|
||||
dis.progress_bar_show(i / len(files))
|
||||
# track our copies
|
||||
if key in SEEDVAULT_FIELDS:
|
||||
SettingsObject.master_sv_data[key] = value
|
||||
|
||||
if self._slot_is_blank(pos, taste):
|
||||
# unlikely case, but easy to handle
|
||||
continue
|
||||
def master_get(self, kn, default=None):
|
||||
# Read a value from master seed's settings, perhaps from within context of tmp seed
|
||||
from glob import settings as self
|
||||
|
||||
yield pos, taste
|
||||
if not SettingsObject.master_nvram_key:
|
||||
# simple, we are already on master seed
|
||||
return self.get(kn, default)
|
||||
|
||||
# LIMITATION: only supporting a few values we know we will need
|
||||
assert kn in SEEDVAULT_FIELDS
|
||||
res = SettingsObject.master_sv_data.get(kn, default)
|
||||
if res is None:
|
||||
return default
|
||||
return res
|
||||
|
||||
def load(self, dis=None):
|
||||
# Search all slots for any we can read, decrypt that,
|
||||
# and pick the newest one (in unlikely case of dups)
|
||||
# reset
|
||||
self.current.clear()
|
||||
self.overrides.clear()
|
||||
self.my_pos = None
|
||||
self.is_dirty = 0
|
||||
self.capacity = 0
|
||||
nonempty = set()
|
||||
|
||||
for pos, taste in self._nonempty_slots(dis):
|
||||
@ -356,10 +338,8 @@ class SettingsObject:
|
||||
continue
|
||||
|
||||
# probably good, read it
|
||||
aes = aes.cipher
|
||||
json_data, expect, actual = self._read_slot(pos, aes)
|
||||
|
||||
try:
|
||||
json_data, expect, actual = self._read_slot(pos, aes.cipher)
|
||||
# verify checksum in last 32 bytes
|
||||
assert expect == actual
|
||||
|
||||
@ -373,10 +353,8 @@ class SettingsObject:
|
||||
# likely winner
|
||||
self.current = d
|
||||
self.my_pos = pos
|
||||
#print("NV: data @ 0x%x w/ age=%d" % (pos, got_age))
|
||||
else:
|
||||
# stale data seen; clean it up.
|
||||
#print("NV: cleanup @ 0x%x" % pos)
|
||||
assert self.current['_age'] > 0
|
||||
self._wipe_slot(pos)
|
||||
|
||||
@ -385,7 +363,6 @@ class SettingsObject:
|
||||
|
||||
# done, if we found something
|
||||
if self.my_pos is not None:
|
||||
#print("NV: load done")
|
||||
return
|
||||
|
||||
# nothing found, use defaults
|
||||
@ -393,23 +370,11 @@ class SettingsObject:
|
||||
|
||||
# pick a (new) random home
|
||||
self.my_pos = self.find_spot(-1)
|
||||
#print("NV: empty")
|
||||
|
||||
if is_devmode:
|
||||
self.current['chain'] = 'XTN'
|
||||
|
||||
if self.num_empty == NUM_SLOTS:
|
||||
# Whole thing is blank. Bad for plausible deniability. Write 3 slots
|
||||
# with white noise. They will be wasted space until it fills up.
|
||||
for _ in range(4):
|
||||
pos = self.find_spot(-1)
|
||||
self._deny_slot(pos)
|
||||
|
||||
def get(self, kn, default=None):
|
||||
if kn in self.overrides:
|
||||
return self.overrides.get(kn)
|
||||
else:
|
||||
return self.current.get(kn, default)
|
||||
return self.current.get(kn, default)
|
||||
|
||||
def changed(self):
|
||||
self.is_dirty += 1
|
||||
@ -425,24 +390,57 @@ class SettingsObject:
|
||||
self.current[kn] = v
|
||||
self.changed()
|
||||
|
||||
def put_volatile(self, kn, v):
|
||||
self.overrides[kn] = v
|
||||
|
||||
set = put
|
||||
|
||||
def remove_key(self, kn):
|
||||
self.current.pop(kn, None)
|
||||
self.changed()
|
||||
|
||||
def merge_previous_active(self, previous):
|
||||
if previous:
|
||||
for k in KEEP_IF_BLANK_SETTINGS:
|
||||
if previous.get(k, None) and not self.current.get(k, None):
|
||||
self.current[k] = previous[k]
|
||||
|
||||
# nfc, usb, vidsk handling
|
||||
self.update_interface_state()
|
||||
|
||||
def update_interface_state(self):
|
||||
# update current settings based on actual state
|
||||
# settings that need to be copied to any newly loaded settings
|
||||
# as they describe state as is (a.k.a current state)
|
||||
import pyb
|
||||
from glob import NFC, VD
|
||||
to_update = []
|
||||
|
||||
nfc_on = int(bool(NFC))
|
||||
if nfc_on != self.get("nfc", 0):
|
||||
to_update.append(("nfc", nfc_on))
|
||||
|
||||
vidsk_on = int(bool(VD))
|
||||
vidsk_setting = self.get("vidsk", 0)
|
||||
if vidsk_on != vidsk_setting:
|
||||
# state no2 is auto
|
||||
if not (vidsk_on and (vidsk_setting == 2)):
|
||||
to_update.append(("vidsk", vidsk_on))
|
||||
|
||||
usb_on = int(bool(pyb.usb_mode()))
|
||||
du_setting = self.get("du", 0)
|
||||
if usb_on == du_setting:
|
||||
to_update.append(("du", int(not du_setting)))
|
||||
|
||||
# actual setting
|
||||
for k, v in to_update:
|
||||
self.put(k, v)
|
||||
|
||||
def clear(self):
|
||||
# could be just:
|
||||
# self.current = {}
|
||||
# but accomidating the simulator here
|
||||
# but accommodating the simulator here
|
||||
rk = [k for k in self.current if k[0] != '_']
|
||||
for k in rk:
|
||||
del self.current[k]
|
||||
|
||||
self.overrides.clear()
|
||||
self.changed()
|
||||
|
||||
async def write_out(self):
|
||||
@ -463,39 +461,22 @@ class SettingsObject:
|
||||
# - check randomly and pick first blank one (wear leveling, deniability)
|
||||
# - we will write and then erase old slot
|
||||
# - if "full", blow away a random one
|
||||
if mk_num <= 3:
|
||||
options = [s for s in SLOTS if s != not_here]
|
||||
shuffle(options)
|
||||
# on mk4, use the filesystem to see what's already taken
|
||||
avail = set(SLOTS) - set(self._used_slots())
|
||||
avail.discard(not_here)
|
||||
|
||||
buf = bytearray(4)
|
||||
for pos in options:
|
||||
if self._slot_is_blank(pos, buf):
|
||||
# found a blank area
|
||||
return pos
|
||||
if avail:
|
||||
return avail.pop()
|
||||
|
||||
# No-where to write! (probably a bug because we have lots of slots)
|
||||
# ... so pick a random slot and kill what it had
|
||||
victim = options[0]
|
||||
else:
|
||||
# on mk4, use the filesystem to see what's already taken
|
||||
avail = set(SLOTS) - set(self._used_slots())
|
||||
avail.discard(not_here)
|
||||
|
||||
if avail:
|
||||
return avail.pop()
|
||||
|
||||
victim = randbelow(NUM_SLOTS)
|
||||
|
||||
#print("ERROR: nvram full")
|
||||
# TODO destructive
|
||||
victim = randbelow(NUM_SLOTS)
|
||||
self._wipe_slot(victim)
|
||||
|
||||
return victim
|
||||
|
||||
def save(self):
|
||||
# render as JSON, encrypt and write it.
|
||||
|
||||
self.current['_age'] = self.current.get('_age', 1) + 1
|
||||
|
||||
pos = self.find_spot(self.my_pos)
|
||||
|
||||
aes = self.get_aes(pos).cipher
|
||||
@ -509,10 +490,6 @@ class SettingsObject:
|
||||
self.my_pos = pos
|
||||
self.is_dirty = 0
|
||||
|
||||
def merge(self, prev):
|
||||
# take a dict of previous values and merge them into what we have
|
||||
self.current.update(prev)
|
||||
|
||||
def blank(self):
|
||||
# erase current copy of values in nvram; older ones may exist still
|
||||
# - use when clearing the seed value
|
||||
@ -522,9 +499,7 @@ class SettingsObject:
|
||||
|
||||
# act blank too, just in case.
|
||||
self.current.clear()
|
||||
self.overrides.clear()
|
||||
self.is_dirty = 0
|
||||
self.capacity = 0
|
||||
|
||||
@staticmethod
|
||||
def default_values():
|
||||
|
||||
@ -82,7 +82,7 @@ OP_RETURN = const(106)
|
||||
#OP_RSHIFT = const(153)
|
||||
#OP_BOOLAND = const(154)
|
||||
#OP_BOOLOR = const(155)
|
||||
#OP_NUMEQUAL = const(156)
|
||||
OP_NUMEQUAL = const(156)
|
||||
#OP_NUMEQUALVERIFY = const(157)
|
||||
#OP_NUMNOTEQUAL = const(158)
|
||||
#OP_LESSTHAN = const(159)
|
||||
@ -114,6 +114,7 @@ OP_CHECKMULTISIGVERIFY = const(175)
|
||||
#OP_NOP8 = const(183)
|
||||
#OP_NOP9 = const(184)
|
||||
#OP_NOP10 = const(185)
|
||||
OP_CHECKSIGADD = const(186)
|
||||
#OP_NULLDATA = const(252)
|
||||
#OP_PUBKEYHASH = const(253)
|
||||
#OP_PUBKEY = const(254)
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
#
|
||||
# paper.py - generate paper wallets, based on random values (not linked to wallet)
|
||||
#
|
||||
import ujson
|
||||
import ujson, ngu, chains
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from utils import imported
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH
|
||||
from auth import write_sig_file
|
||||
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), \
|
||||
@ -29,10 +32,6 @@ can still be made. Visit the Coldcard website to get some interesting templates.
|
||||
|
||||
SECP256K1_ORDER = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xba\xae\xdc\xe6\xaf\x48\xa0\x3b\xbf\xd2\x5e\x8c\xd0\x36\x41\x41"
|
||||
|
||||
# Aprox. time of this feature release (Nov 20/2019) so no need to scan
|
||||
# blockchain earlier than this during "importmulti"
|
||||
FEATURE_RELEASE_TIME = const(1574277000)
|
||||
|
||||
# These very-specific text values are matched on the Coldcard; cannot be changed.
|
||||
class placeholders:
|
||||
addr = b'ADDRESS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # 37 long
|
||||
@ -51,6 +50,12 @@ class PaperWalletMaker:
|
||||
self.my_menu = my_menu
|
||||
self.template_fn = None
|
||||
self.is_segwit = False
|
||||
self.is_taproot = False
|
||||
|
||||
def atype(self):
|
||||
if self.is_taproot: return 2, 'Taproot P2TR'
|
||||
if self.is_segwit: return 1, 'Segwit P2WPKH'
|
||||
return 0, 'Classic P2PKH'
|
||||
|
||||
async def pick_template(self, *a):
|
||||
fn = await file_picker('Pick PDF template to use, or X for none.',
|
||||
@ -63,22 +68,17 @@ class PaperWalletMaker:
|
||||
def addr_format_chooser(self, *a):
|
||||
# simple bool choice
|
||||
def set(idx, text):
|
||||
self.is_segwit = bool(idx)
|
||||
self.is_segwit = idx == 1
|
||||
self.is_taproot = idx == 2
|
||||
self.update_menu()
|
||||
return int(self.is_segwit), ['Classic', 'Segwit/Bech32'], set
|
||||
|
||||
@staticmethod
|
||||
def can_do_qr():
|
||||
import version
|
||||
return version.has_fatram
|
||||
return self.atype()[0], ['Classic P2PKH', 'Segwit P2WPKH', 'Taproot P2TR'], set
|
||||
|
||||
def update_menu(self):
|
||||
# Reconstruct the menu contents based on our state.
|
||||
self.my_menu.replace_items([
|
||||
MenuItem("Don't make PDF" if not self.template_fn else 'Making PDF',
|
||||
f=self.pick_template, predicate=self.can_do_qr),
|
||||
MenuItem('Classic Address' if not self.is_segwit else 'Segwit Address',
|
||||
chooser=self.addr_format_chooser),
|
||||
f=self.pick_template),
|
||||
MenuItem(self.atype()[1], chooser=self.addr_format_chooser),
|
||||
MenuItem('Use Dice', f=self.use_dice),
|
||||
MenuItem('GENERATE WALLET', f=self.doit),
|
||||
], keep_position=True)
|
||||
@ -88,12 +88,6 @@ class PaperWalletMaker:
|
||||
from glob import dis, VD
|
||||
|
||||
try:
|
||||
import ngu
|
||||
from auth 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)
|
||||
@ -110,27 +104,27 @@ class PaperWalletMaker:
|
||||
dis.fullscreen("Rendering...")
|
||||
|
||||
# make payment address
|
||||
digest = hash160(pubkey)
|
||||
ch = current_chain()
|
||||
ch = chains.current_chain()
|
||||
if self.is_segwit:
|
||||
addr = ngu.codecs.segwit_encode(ch.bech32_hrp, 0, digest)
|
||||
af = AF_P2WPKH
|
||||
elif self.is_taproot:
|
||||
af = AF_P2TR
|
||||
pubkey = pubkey[1:]
|
||||
else:
|
||||
addr = ngu.codecs.b58_encode(ch.b58_addr + digest)
|
||||
af = AF_CLASSIC
|
||||
|
||||
addr = ch.pubkey_to_address(pubkey, af)
|
||||
|
||||
wif = ngu.codecs.b58_encode(ch.b58_privkey + privkey + b'\x01')
|
||||
|
||||
if self.can_do_qr():
|
||||
with imported('uqr') as uqr:
|
||||
# make the QR's now, since it's slow
|
||||
is_alnum = self.is_segwit
|
||||
qr_addr = uqr.make(addr if not is_alnum else addr.upper(),
|
||||
min_version=4, max_version=4,
|
||||
encoding=(uqr.Mode_ALPHANUMERIC if is_alnum else 0))
|
||||
with imported('uqr') as uqr:
|
||||
# make the QR's now, since it's slow
|
||||
is_alnum = self.is_segwit
|
||||
qr_addr = uqr.make(addr if not is_alnum else addr.upper(),
|
||||
min_version=4, max_version=4,
|
||||
encoding=(uqr.Mode_ALPHANUMERIC if is_alnum else 0))
|
||||
|
||||
qr_wif = uqr.make(wif, min_version=4, max_version=4, encoding=uqr.Mode_BYTE)
|
||||
else:
|
||||
qr_addr = None
|
||||
qr_wif = None
|
||||
qr_wif = uqr.make(wif, min_version=4, max_version=4, encoding=uqr.Mode_BYTE)
|
||||
|
||||
# Use address as filename. clearly will be unique, but perhaps a bit
|
||||
# awkward to work with.
|
||||
@ -174,8 +168,10 @@ class PaperWalletMaker:
|
||||
else:
|
||||
nice_pdf = ''
|
||||
|
||||
nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename,
|
||||
addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC)
|
||||
nice_sig = None
|
||||
if af != AF_P2TR:
|
||||
nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename,
|
||||
addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC)
|
||||
|
||||
# Half-hearted attempt to cleanup secrets-contaminated memory
|
||||
# - better would be force user to reboot
|
||||
@ -195,7 +191,8 @@ class PaperWalletMaker:
|
||||
story = "Done! Created file(s):\n\n%s" % nice_txt
|
||||
if nice_pdf:
|
||||
story += "\n\n%s" % nice_pdf
|
||||
story += "\n\n%s" % nice_sig
|
||||
if nice_sig:
|
||||
story += "\n\n%s" % nice_sig
|
||||
await ux_show_story(story)
|
||||
|
||||
async def use_dice(self, *a):
|
||||
@ -224,10 +221,17 @@ class PaperWalletMaker:
|
||||
fp.write('Bitcoin Core command:\n\n')
|
||||
|
||||
# new hotness: output descriptors
|
||||
desc = ('wpkh(%s)' if self.is_segwit else 'pkh(%s)') % wif
|
||||
multi = ujson.dumps(dict(timestamp=FEATURE_RELEASE_TIME, desc=append_checksum(desc)))
|
||||
fp.write(" bitcoin-cli importmulti '[%s]'\n\n" % multi)
|
||||
fp.write('# OR (more compatible, but slower)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif)
|
||||
if self.is_taproot:
|
||||
desc = 'tr(%s)'
|
||||
elif self.is_segwit:
|
||||
desc = 'wpkh(%s)'
|
||||
else:
|
||||
desc = 'pkh(%s)'
|
||||
desc = desc % wif
|
||||
descriptor = ujson.dumps(dict(timestamp="now", desc=append_checksum(desc)))
|
||||
fp.write(" bitcoin-cli importdescriptors '[%s]'\n\n" % descriptor)
|
||||
if not self.is_taproot:
|
||||
fp.write('# OR (only supported with legacy wallets)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif)
|
||||
|
||||
if qr_addr and qr_wif:
|
||||
fp.write('\n\n--- QR Codes --- (requires UTF-8, unicode, white background)\n\n\n\n')
|
||||
@ -295,7 +299,8 @@ class PaperWalletMaker:
|
||||
|
||||
async def make_paper_wallet(*a):
|
||||
|
||||
msg = background_msg.format(can_qr=('\nIf you have a special PDF template file, it can also make a pretty version of the same data.' if PaperWalletMaker.can_do_qr() else ''))
|
||||
msg = background_msg.format(can_qr='\nIf you have a special PDF template file, '
|
||||
'it can also make a pretty version of the same data.')
|
||||
|
||||
if await ux_show_story(msg) != 'y':
|
||||
return
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
#
|
||||
# pincodes.py - manage PIN code (which map to wallet seeds)
|
||||
#
|
||||
import ustruct, ckcc, version
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
import ustruct, ckcc, version, chains, stash
|
||||
# from ubinascii import hexlify as b2a_hex
|
||||
from callgate import enter_dfu
|
||||
from bip39 import wordlist_en
|
||||
|
||||
@ -100,6 +100,12 @@ PIN_ATTEMPT_SIZE = const(248+32)
|
||||
# small cache of pin-prefix to words, for 608a based systems
|
||||
_word_cache = []
|
||||
|
||||
def retry_ae_fail(*args):
|
||||
err = ckcc.gate(*args)
|
||||
if err == -106: # AE_FAIL
|
||||
err = ckcc.gate(*args)
|
||||
return err
|
||||
|
||||
class BootloaderError(RuntimeError):
|
||||
pass
|
||||
|
||||
@ -123,8 +129,7 @@ class PinAttempt:
|
||||
|
||||
|
||||
assert MAX_PIN_LEN == 32 # update FMT otherwise
|
||||
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1, \
|
||||
ustruct.calcsize(PIN_ATTEMPT_FMT)
|
||||
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
|
||||
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V2_ADDITIONS) == PIN_ATTEMPT_SIZE - PIN_ATTEMPT_SIZE_V1
|
||||
|
||||
# check for bricked system early
|
||||
@ -149,7 +154,6 @@ class PinAttempt:
|
||||
if new_secret is not None:
|
||||
change_flags |= CHANGE_SECRET if not is_duress else CHANGE_DURESS_SECRET
|
||||
assert len(new_secret) in (32, AE_SECRET_LEN)
|
||||
import stash
|
||||
stash.SensitiveValues.clear_cache()
|
||||
else:
|
||||
new_secret = bytes(AE_SECRET_LEN)
|
||||
@ -254,7 +258,7 @@ class PinAttempt:
|
||||
|
||||
#print("> tx: %s" % b2a_hex(buf))
|
||||
|
||||
err = ckcc.gate(18, buf, method_num)
|
||||
err = retry_ae_fail(18, buf, method_num)
|
||||
|
||||
#print("[%d] rx: %s" % (err, b2a_hex(buf)))
|
||||
|
||||
@ -319,7 +323,7 @@ class PinAttempt:
|
||||
return bool(self.state_flags & PA_SUCCESSFUL)
|
||||
|
||||
def is_secret_blank(self):
|
||||
assert self.state_flags & PA_SUCCESSFUL
|
||||
assert self.is_successful()
|
||||
return bool(self.state_flags & PA_ZERO_SECRET)
|
||||
|
||||
# Mk1/2/3 concepts, not used in Mk4
|
||||
@ -362,7 +366,8 @@ class PinAttempt:
|
||||
|
||||
def change(self, **kws):
|
||||
# change various values, stored in secure element
|
||||
if self.tmp_value: return
|
||||
if not kws.pop("tmp_lockdown", False):
|
||||
if self.tmp_value: return
|
||||
|
||||
self.roundtrip(3, **kws)
|
||||
|
||||
@ -370,8 +375,8 @@ class PinAttempt:
|
||||
# - call new_main_secret() when main secret changes!
|
||||
# - is_secret_blank and is_successful may be wrong now, re-login to get again
|
||||
|
||||
def fetch(self, duress_pin=None, spare_num=0):
|
||||
if self.tmp_value:
|
||||
def fetch(self, duress_pin=None, spare_num=0, bypass_tmp=False):
|
||||
if self.tmp_value and not bypass_tmp:
|
||||
# must make a copy here, and must be mutable instance so not reused
|
||||
if spare_num:
|
||||
return bytearray(AE_SECRET_LEN)
|
||||
@ -391,15 +396,8 @@ class PinAttempt:
|
||||
if self.tmp_value:
|
||||
return bytes(AE_LONG_SECRET_LEN)
|
||||
|
||||
if version.mk_num < 4:
|
||||
secret = b''
|
||||
for n in range(13):
|
||||
secret += self.roundtrip(6, ls_offset=n)[0:32]
|
||||
|
||||
return secret
|
||||
else:
|
||||
# faster method for Mk4
|
||||
return self.roundtrip(8, after_buf=bytes(AE_LONG_SECRET_LEN))
|
||||
# faster method for Mk4
|
||||
return self.roundtrip(8, after_buf=bytes(AE_LONG_SECRET_LEN))
|
||||
|
||||
def ls_change(self, new_long_secret):
|
||||
# set the "long secret"
|
||||
@ -421,45 +419,101 @@ class PinAttempt:
|
||||
self.roundtrip(7, fw_upgrade=(start, length))
|
||||
# not-reached
|
||||
|
||||
def new_main_secret(self, raw_secret, chain=None):
|
||||
def new_main_secret(self, raw_secret=None, chain=None, bip39pw='', blank=False,
|
||||
target_nvram_key=None):
|
||||
# Main secret has changed: reset the settings+their key,
|
||||
# and capture xfp/xpub
|
||||
# if None is provided as raw_secret -> restore to main seed
|
||||
import nvstore
|
||||
from glob import settings
|
||||
import stash
|
||||
stash.SensitiveValues.clear_cache()
|
||||
|
||||
bypass_tmp = False
|
||||
stash.bip39_passphrase = bool(bip39pw)
|
||||
|
||||
# capture values we have already
|
||||
old_values = dict(settings.current)
|
||||
|
||||
settings.set_key(raw_secret)
|
||||
settings.load()
|
||||
if chain is None:
|
||||
chain = chains.get_chain(old_values.get("chain", None))
|
||||
|
||||
# merge in settings, including what chain to use, timeout, etc.
|
||||
settings.merge(old_values)
|
||||
if raw_secret is None:
|
||||
assert pa.tmp_value
|
||||
bypass_tmp = True
|
||||
pa.tmp_value = None
|
||||
if blank:
|
||||
# wipe current ephemeral secret settings slot
|
||||
settings.blank()
|
||||
old_values = None
|
||||
else:
|
||||
if target_nvram_key is None:
|
||||
settings.set_key(raw_secret)
|
||||
else:
|
||||
# we already have hashed nvram key calculated
|
||||
# from self.tmp_secret - use it
|
||||
settings.nvram_key = target_nvram_key
|
||||
|
||||
settings.load()
|
||||
|
||||
# Recalculate xfp/xpub values (depends both on secret and chain)
|
||||
with stash.SensitiveValues(raw_secret) as sv:
|
||||
if chain is not None:
|
||||
sv.chain = chain
|
||||
sv.capture_xpub()
|
||||
try:
|
||||
with stash.SensitiveValues(raw_secret, bypass_tmp=bypass_tmp) as sv:
|
||||
if chain is not None:
|
||||
sv.chain = chain
|
||||
|
||||
# does not call settings.save() but caller should!
|
||||
if raw_secret is None:
|
||||
# restore to main wallet's settings
|
||||
settings.return_to_master_seed()
|
||||
else:
|
||||
sv.capture_xpub()
|
||||
|
||||
def tmp_secret(self, encoded, chain=None):
|
||||
settings.merge_previous_active(old_values)
|
||||
|
||||
except stash.ZeroSecretException:
|
||||
# secret is zero - using ephemeral secrets in CC
|
||||
# with no se2 secret
|
||||
settings.nvram_key = b'\0'*32
|
||||
settings.load()
|
||||
self.state_flags |= PA_ZERO_SECRET
|
||||
|
||||
def tmp_secret(self, encoded, chain=None, bip39pw=''):
|
||||
# Use indicated secret and stop using the SE; operate like this until reboot
|
||||
self.tmp_value = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded)))
|
||||
from glob import settings
|
||||
from nvstore import SettingsObject
|
||||
|
||||
val = bytes(encoded + bytes(AE_SECRET_LEN - len(encoded)))
|
||||
if self.tmp_value == val:
|
||||
# noop - already enabled
|
||||
return False, "Temporary master key already in use."
|
||||
|
||||
target_nvram_key = None
|
||||
if encoded is not None:
|
||||
# disallow using master seed as temporary
|
||||
master_err = "Cannot use master seed as temporary."
|
||||
target_nvram_key = settings.hash_key(encoded)
|
||||
if SettingsObject.master_nvram_key:
|
||||
assert self.tmp_value
|
||||
if target_nvram_key == SettingsObject.master_nvram_key:
|
||||
return False, master_err
|
||||
else:
|
||||
if target_nvram_key == settings.nvram_key:
|
||||
return False, master_err
|
||||
|
||||
if not self.tmp_value:
|
||||
# leaving from master seed, might capture some useful values
|
||||
settings.leaving_master_seed()
|
||||
|
||||
self.tmp_value = val
|
||||
|
||||
# We're no longer blank. hard to say about duress secret and stuff tho
|
||||
self.state_flags = PA_SUCCESSFUL
|
||||
|
||||
# Clear bip-39 secret, not applicable anymore.
|
||||
import stash
|
||||
stash.bip39_passphrase = ''
|
||||
stash.SensitiveValues.clear_cache()
|
||||
|
||||
# Copies system settings to new encrypted-key value, calculates
|
||||
# XFP, XPUB and saves into that, and starts using them.
|
||||
self.new_main_secret(self.tmp_value, chain=chain)
|
||||
self.new_main_secret(self.tmp_value, chain=chain, bip39pw=bip39pw,
|
||||
target_nvram_key=target_nvram_key)
|
||||
|
||||
return True, None
|
||||
|
||||
def trick_request(self, method_num, data):
|
||||
# send/recv a trick-pin related request (mk4 only)
|
||||
@ -478,9 +532,6 @@ class PinAttempt:
|
||||
|
||||
def is_deltamode(self):
|
||||
# (mk4 only) are we operating w/ a slightly wrong PIN code?
|
||||
if version.mk_num < 4:
|
||||
return False
|
||||
|
||||
from trick_pins import TC_DELTA_MODE
|
||||
return bool(self.delay_required & TC_DELTA_MODE)
|
||||
|
||||
|
||||
1346
shared/psbt.py
1346
shared/psbt.py
File diff suppressed because it is too large
Load Diff
@ -55,6 +55,7 @@ class PSRAMWrapper:
|
||||
|
||||
def wipe_all(self):
|
||||
# works, but code in bootrom is much faster and better (rng values used)
|
||||
from glob import dis
|
||||
|
||||
z = bytes(16384)
|
||||
for pos in range(0, self.length, len(z)):
|
||||
|
||||
236
shared/pwsave.py
236
shared/pwsave.py
@ -2,111 +2,190 @@
|
||||
#
|
||||
# pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired)
|
||||
#
|
||||
import sys, stash, ujson, os, ngu, pyb
|
||||
import stash, ujson, ngu, pyb, os, aes256ctr
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
from ux import ux_dramatic_pause, ux_confirm, ux_show_story
|
||||
from utils import xfp2str, problem_file_line
|
||||
from menu import MenuItem, MenuSystem
|
||||
|
||||
|
||||
class PassphraseSaver:
|
||||
# Encrypts BIP-39 passphrase very carefully, and appends
|
||||
# to a file on MicroSD card. Order is preserved.
|
||||
# AES-256 CTR with key=SHA256(SHA256(salt + derived key off master + salt))
|
||||
# where: salt=sha256(microSD serial # details)
|
||||
def __init__(self):
|
||||
self.key = None
|
||||
|
||||
def filename(self, card):
|
||||
@staticmethod
|
||||
def filename(card):
|
||||
# Construct actual filename to use.
|
||||
# - some very minor obscurity, but we aren't relying on that.
|
||||
return card.get_sd_root() + '/.tmp.tmp'
|
||||
|
||||
def _calc_key(self, card, force=False):
|
||||
# calculate the key to be used.
|
||||
if not force and getattr(self, 'key', None):
|
||||
if not force and self.key:
|
||||
return
|
||||
|
||||
try:
|
||||
salt = card.get_id_hash()
|
||||
salt = card.get_id_hash()
|
||||
|
||||
with stash.SensitiveValues(bypass_pw=True) as sv:
|
||||
self.key = bytearray(sv.encryption_key(salt))
|
||||
|
||||
except:
|
||||
self.key = None
|
||||
with stash.SensitiveValues(bypass_tmp=True) as sv:
|
||||
self.key = bytearray(sv.encryption_key(salt))
|
||||
|
||||
def _read(self, card):
|
||||
# Return a list of saved passphrases, or empty list if fail.
|
||||
# Fail silently in all cases. Expect to see lots of noise here.
|
||||
decrypt = ngu.aes.CTR(self.key)
|
||||
assert self.key
|
||||
decrypt = aes256ctr.new(self.key)
|
||||
|
||||
try:
|
||||
fname = self.filename(card)
|
||||
msg = open(fname, 'rb').read()
|
||||
with open(fname, 'rb') as f:
|
||||
msg = f.read()
|
||||
txt = decrypt.cipher(msg)
|
||||
|
||||
return ujson.loads(txt)
|
||||
except OSError:
|
||||
#print('missing? ' + fname)
|
||||
return []
|
||||
except:
|
||||
return []
|
||||
|
||||
async def _save(self, card, data):
|
||||
assert self.key
|
||||
encrypt = ngu.aes.CTR(self.key)
|
||||
msg = encrypt.cipher(ujson.dumps(data))
|
||||
|
||||
async def append(self, xfp, bip39pw):
|
||||
# encrypt and save; always appends.
|
||||
from glob import dis
|
||||
# overwrites whatever already there
|
||||
with open(self.filename(card), 'wb') as fd:
|
||||
fd.write(msg)
|
||||
|
||||
while 1:
|
||||
dis.fullscreen('Saving...')
|
||||
async def delete(self, idx):
|
||||
with CardSlot() as card:
|
||||
self._calc_key(card)
|
||||
data = self._read(card)
|
||||
|
||||
try:
|
||||
with CardSlot() as card:
|
||||
self._calc_key(card)
|
||||
del data[idx]
|
||||
except IndexError: pass
|
||||
|
||||
data = self._read(card) if self.key else []
|
||||
await self._save(card, data)
|
||||
if not data:
|
||||
return True # is empty
|
||||
|
||||
data.append(dict(xfp=xfp, pw=bip39pw))
|
||||
async def append(self, xfp, bip39pw):
|
||||
from glob import dis
|
||||
dis.fullscreen('Reading...')
|
||||
with CardSlot() as card:
|
||||
self._calc_key(card)
|
||||
data = self._read(card)
|
||||
|
||||
encrypt = ngu.aes.CTR(self.key)
|
||||
|
||||
msg = encrypt.cipher(ujson.dumps(data))
|
||||
|
||||
with open(self.filename(card), 'wb') as fd:
|
||||
fd.write(msg)
|
||||
|
||||
await ux_dramatic_pause("Saved.", 1)
|
||||
return
|
||||
|
||||
except CardMissingError:
|
||||
ch = await needs_microsd()
|
||||
if ch == 'x': # undocumented, but needs escape route
|
||||
break
|
||||
to_add = dict(xfp=xfp, pw=bip39pw)
|
||||
if to_add not in data:
|
||||
dis.fullscreen('Saving...')
|
||||
data.append(to_add)
|
||||
await self._save(card, data)
|
||||
|
||||
|
||||
def make_menu(self):
|
||||
from menu import MenuItem, MenuSystem
|
||||
class PassphraseSaverMenu(MenuSystem):
|
||||
|
||||
def update_contents(self):
|
||||
tmp = PassphraseSaverMenu.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
@staticmethod
|
||||
async def apply(menu, idx, item):
|
||||
# apply the password immediately and drop them at top menu
|
||||
from actions import goto_top_menu
|
||||
from ux import ux_show_story
|
||||
from seed import set_bip39_passphrase
|
||||
from pincodes import pa
|
||||
from glob import settings
|
||||
|
||||
# Very quick check for card not present case.
|
||||
if not pyb.SDCard().present():
|
||||
return None
|
||||
bypass_tmp = True
|
||||
pw, expect_xfp = item.arg
|
||||
if pa.tmp_value and settings.get("words", True):
|
||||
xfp = settings.get("xfp", 0)
|
||||
title = "[%s]" % xfp2str(xfp)
|
||||
msg = (
|
||||
"Temporary seed is active. Press (1)"
|
||||
" to add passphrase to the current active"
|
||||
" temporary seed."
|
||||
)
|
||||
escape = "1x"
|
||||
if settings.master_get("words", True):
|
||||
escape += "y"
|
||||
msg += " Press OK to add to master seed."
|
||||
|
||||
msg += "Press X to exit."
|
||||
|
||||
ch = await ux_show_story(msg, title=title, escape=escape,
|
||||
strict_escape=True)
|
||||
if ch == "x": return
|
||||
if ch == '1':
|
||||
bypass_tmp = False
|
||||
|
||||
applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp,
|
||||
summarize_ux=False)
|
||||
if not applied:
|
||||
return
|
||||
|
||||
xfp = settings.get('xfp', 0)
|
||||
|
||||
# verification step
|
||||
if xfp == expect_xfp:
|
||||
# feedback that it worked
|
||||
await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp))
|
||||
else:
|
||||
got = xfp2str(xfp)
|
||||
exp = xfp2str(expect_xfp)
|
||||
await ux_show_story("XFP verification failed. Restored wallet XFP [%s] "
|
||||
"does not match expected XFP [%s] from "
|
||||
"saved passphrase file." % (got, exp))
|
||||
return
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
@staticmethod
|
||||
async def delete_entry(menu, idx, item):
|
||||
from ux import the_ux
|
||||
from glob import dis
|
||||
|
||||
pw_saver, i = item.arg
|
||||
if await ux_confirm("Delete saved passphrase?"):
|
||||
dis.fullscreen("Wait...")
|
||||
try:
|
||||
is_empty = await pw_saver.delete(i)
|
||||
the_ux.pop()
|
||||
if not is_empty:
|
||||
m = the_ux.top_of_stack()
|
||||
m.update_contents()
|
||||
else:
|
||||
# remove .tmp.tmp file after last passphrase
|
||||
# is deleted
|
||||
with CardSlot() as card:
|
||||
f_path = pw_saver.filename(card)
|
||||
os.remove(f_path)
|
||||
the_ux.pop()
|
||||
m = the_ux.top_of_stack()
|
||||
m.update_contents()
|
||||
except CardMissingError:
|
||||
await needs_microsd()
|
||||
except Exception as e:
|
||||
await ux_show_story(
|
||||
title="ERROR",
|
||||
msg='Delete failed!\n\n%s\n%s' % (e, problem_file_line(e))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# We have a list of xfp+pw fields. Make a menu.
|
||||
# Read file, decrypt and make a menu to show; OR return None
|
||||
# if any error hit.
|
||||
try:
|
||||
with CardSlot() as card:
|
||||
pw_saver = PassphraseSaver()
|
||||
with CardSlot() as card:
|
||||
pw_saver._calc_key(card)
|
||||
data = pw_saver._read(card)
|
||||
|
||||
self._calc_key(card)
|
||||
if not self.key: return None
|
||||
|
||||
data = self._read(card)
|
||||
|
||||
if not data: return None
|
||||
|
||||
except CardMissingError:
|
||||
# not an error: they just aren't using feature
|
||||
return None
|
||||
|
||||
# We have a list of xfp+pw fields. Make a menu.
|
||||
if not data: return None
|
||||
|
||||
# Challenge: we need to hint at which is which, but don't want to
|
||||
# show the password on-screen.
|
||||
@ -129,25 +208,16 @@ class PassphraseSaver:
|
||||
# give up: show it all!
|
||||
parts = [i for i,_ in pws]
|
||||
|
||||
async def doit(menu, idx, item):
|
||||
# apply the password immediately and drop them at top menu
|
||||
pw, expect_xfp = item.arg
|
||||
set_bip39_passphrase(pw)
|
||||
|
||||
from glob import settings
|
||||
from utils import xfp2str
|
||||
xfp = settings.get('xfp')
|
||||
|
||||
# verification step; I don't see any way for this to go wrong
|
||||
assert xfp == expect_xfp
|
||||
|
||||
# feedback that it worked
|
||||
await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp))
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
|
||||
return MenuSystem((MenuItem(label or '(empty)', f=doit, arg=pw) for pw, label in zip(pws, parts)))
|
||||
items = []
|
||||
for i, (pw, label) in enumerate(zip(pws, parts)):
|
||||
xfp_ui = "[%s]" % xfp2str(pw[1])
|
||||
submenu = MenuSystem([
|
||||
MenuItem(xfp_ui),
|
||||
MenuItem("Restore", f=cls.apply, arg=pw),
|
||||
MenuItem("Delete", f=cls.delete_entry, arg=(pw_saver, i)),
|
||||
])
|
||||
items.append(MenuItem(label or "(empty)", menu=submenu))
|
||||
return items
|
||||
|
||||
#
|
||||
# Support for using MicroSD as second factor to the login PIN.
|
||||
@ -207,6 +277,9 @@ class MicroSD2FA(PassphraseSaver):
|
||||
except:
|
||||
# die. wrong
|
||||
import callgate
|
||||
from glob import settings
|
||||
settings.remove_key("sd2fa")
|
||||
settings.save()
|
||||
callgate.fast_wipe(silent=False)
|
||||
|
||||
# proceed w/o any notice
|
||||
@ -251,13 +324,14 @@ class MicroSD2FA(PassphraseSaver):
|
||||
|
||||
data = dict(nonce=nonce)
|
||||
|
||||
encrypt = ngu.aes.CTR(self.key)
|
||||
encrypt = aes256ctr.new(self.key)
|
||||
msg = encrypt.cipher(ujson.dumps(data))
|
||||
|
||||
with open(self.filename(card), 'wb') as fd:
|
||||
fd.write(msg)
|
||||
|
||||
# update setting as well
|
||||
# TODO use general method that handles memory overflow
|
||||
v.append(nonce)
|
||||
settings.set('sd2fa', v)
|
||||
settings.save()
|
||||
@ -324,13 +398,15 @@ class MicroSD2FA(PassphraseSaver):
|
||||
ok = cls.authorized_card_present(cls.get_nonces())
|
||||
if ok:
|
||||
await ux_show_story("Need a different MicroSD card. "
|
||||
"This card would already be accepted.")
|
||||
"This card would already be accepted.")
|
||||
return
|
||||
|
||||
ctx = 'this card or one of the others' if count >= 1 else 'it'
|
||||
|
||||
ok = await ux_confirm("Add this card to authorized set? Going forward %s must be present during login process or the seed will be wiped!" % ctx)
|
||||
|
||||
ok = await ux_confirm("Add this card to authorized set? Going forward %s must be "
|
||||
"present during login process or the seed will be wiped!" % ctx)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
await cls().enroll()
|
||||
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
#
|
||||
# qrs.py - QR Display related UX
|
||||
#
|
||||
import framebuf, math, uqr
|
||||
import framebuf, uqr
|
||||
from ux import UserInteraction, ux_wait_keyup, the_ux
|
||||
from utils import word_wrap
|
||||
from version import has_fatram
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
|
||||
|
||||
class QRDisplaySingle(UserInteraction):
|
||||
# Show a single QR code for (typically) a list of addresses, or a single value.
|
||||
@ -30,14 +29,14 @@ class QRDisplaySingle(UserInteraction):
|
||||
# - inverted QR (black/white swap) still readable by scanners, altho wrong
|
||||
if self.is_alnum:
|
||||
# targeting 'alpha numeric' mode, nice and dense; caps only tho
|
||||
enc = uqr.Mode_ALPHANUMERIC
|
||||
enc = uqr.Mode_ALPHANUMERIC if not msg.isdigit() else uqr.Mode_NUMERIC
|
||||
msg = msg.upper()
|
||||
else:
|
||||
# has to be 'binary' mode, altho shorter msg, typical 34-36
|
||||
enc = uqr.Mode_BYTE
|
||||
|
||||
# can fail if not enough space in QR
|
||||
self.qr_data = uqr.make(msg, min_version=3, max_version=11, encoding=enc)
|
||||
self.qr_data = uqr.make(msg, min_version=2, max_version=11, encoding=enc)
|
||||
|
||||
def redraw(self):
|
||||
# Redraw screen.
|
||||
|
||||
606
shared/seed.py
606
shared/seed.py
@ -10,20 +10,21 @@
|
||||
# - 'abandon' * 17 + 'agent'
|
||||
# - 'abandon' * 11 + 'about'
|
||||
#
|
||||
import ngu, uctypes, bip39, random, stash, pyb
|
||||
from menu import MenuItem, MenuSystem
|
||||
from utils import xfp2str, parse_extended_key
|
||||
import ngu, uctypes, bip39, random, version
|
||||
from utils import xfp2str, parse_extended_key, swab32, pad_raw_secret, problem_file_line
|
||||
from uhashlib import sha256
|
||||
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, show_qr_code
|
||||
from ux import PressRelease, ux_input_numbers, ux_input_text
|
||||
from pincodes import AE_SECRET_LEN, AE_LONG_SECRET_LEN
|
||||
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm
|
||||
from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code
|
||||
from actions import goto_top_menu
|
||||
from stash import SecretStash, SensitiveValues
|
||||
from stash import SecretStash
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
from pwsave import PassphraseSaver
|
||||
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
|
||||
|
||||
|
||||
# seed words lengths we support: 24=>256 bits, and recommended
|
||||
VALID_LENGTHS = (24, 18, 12)
|
||||
@ -276,10 +277,8 @@ 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!"
|
||||
|
||||
|
||||
if version.has_fatram:
|
||||
escape = (escape or '') + '1'
|
||||
extra += 'Press (1) to view as QR Code. '
|
||||
escape = (escape or '') + '1'
|
||||
extra += 'Press (1) to view as QR Code. '
|
||||
|
||||
if extra:
|
||||
msg += '\n\n'
|
||||
@ -294,6 +293,7 @@ async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False)
|
||||
|
||||
return ch
|
||||
|
||||
|
||||
async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
|
||||
from glob import dis
|
||||
from display import FontTiny, FontLarge
|
||||
@ -407,19 +407,90 @@ async def new_from_dice(nwords):
|
||||
# send them to home menu, now with a wallet enabled
|
||||
goto_top_menu(first_time=True)
|
||||
|
||||
async def set_ephemeral_seed(encoded, chain=None):
|
||||
pa.tmp_secret(encoded, chain=chain)
|
||||
dis.progress_bar_show(1)
|
||||
xfp = settings.get("xfp", "")
|
||||
if xfp:
|
||||
xfp = "[" + xfp2str(xfp) + "]\n"
|
||||
await ux_show_story("%sNew ephemeral master key in effect until next power down.\n\nIt is NOT stored anywhere." % xfp)
|
||||
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]:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def set_ephemeral_seed_words(words):
|
||||
async def add_seed_to_vault(encoded, meta=None):
|
||||
|
||||
if not settings.master_get("seedvault", False):
|
||||
# seed vault disabled
|
||||
return
|
||||
if pa.is_secret_blank():
|
||||
# do not save anything if no secrets yet
|
||||
return
|
||||
|
||||
# do not offer to store secrets that are already in vault
|
||||
if in_seed_vault(encoded):
|
||||
return
|
||||
|
||||
main_xfp = settings.master_get("xfp", 0)
|
||||
|
||||
# parse encoded
|
||||
_,_,node = SecretStash.decode(encoded)
|
||||
new_xfp = swab32(node.my_fp())
|
||||
new_xfp_str = xfp2str(new_xfp)
|
||||
|
||||
# do not offer to store main seed
|
||||
if new_xfp == main_xfp:
|
||||
return
|
||||
|
||||
seeds = settings.master_get("seeds", [])
|
||||
|
||||
xfp_ui = "[%s]" % new_xfp_str
|
||||
story = ("Press (1) to "
|
||||
"store temporary seed into Seed Vault. This way you can easily switch "
|
||||
"to this secret and use it as temporary seed in future.\n\nPress OK "
|
||||
"to continue without saving.")
|
||||
|
||||
ch = await ux_show_story(story, escape="1")
|
||||
if ch != "1":
|
||||
# didn't want to save
|
||||
return
|
||||
|
||||
# Save it into master settings
|
||||
seeds.append((new_xfp_str,
|
||||
stash.SecretStash.storage_serialize(encoded),
|
||||
xfp_ui,
|
||||
meta))
|
||||
|
||||
settings.master_set("seeds", seeds)
|
||||
|
||||
await ux_show_story(xfp_ui + "\nSaved to Seed Vault")
|
||||
|
||||
return True
|
||||
|
||||
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
|
||||
is_restore=False, meta=None):
|
||||
if not is_restore:
|
||||
await add_seed_to_vault(encoded, meta=meta)
|
||||
dis.fullscreen("Wait...")
|
||||
|
||||
applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw)
|
||||
|
||||
dis.progress_bar_show(1)
|
||||
|
||||
if not applied:
|
||||
await ux_show_story(title="FAILED", msg=err_msg)
|
||||
return
|
||||
|
||||
|
||||
xfp = "[" + xfp2str(settings.get("xfp", 0)) + "]"
|
||||
if summarize_ux:
|
||||
await ux_show_story(title=xfp, msg="New temporary master key is in effect now.")
|
||||
|
||||
return applied
|
||||
|
||||
async def set_ephemeral_seed_words(words, meta):
|
||||
dis.progress_bar_show(0.1)
|
||||
encoded = seed_words_to_encoded_secret(words)
|
||||
dis.progress_bar_show(0.5)
|
||||
await set_ephemeral_seed(encoded)
|
||||
await set_ephemeral_seed(encoded, meta=meta)
|
||||
goto_top_menu()
|
||||
|
||||
async def ephemeral_seed_generate_from_dice(nwords):
|
||||
@ -436,7 +507,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)
|
||||
await set_ephemeral_seed_words(words, meta='Dice')
|
||||
|
||||
def generate_seed():
|
||||
seed = random.bytes(32)
|
||||
@ -455,12 +526,12 @@ async def make_new_wallet(nwords):
|
||||
# send them to home menu, now with a wallet enabled
|
||||
goto_top_menu(first_time=True)
|
||||
|
||||
async def ephemeral_seed_import_done_cb(words):
|
||||
dis.fullscreen("Applying...")
|
||||
await set_ephemeral_seed_words(words)
|
||||
|
||||
async def ephemeral_seed_import(nwords):
|
||||
return WordNestMenu(nwords, done_cb=ephemeral_seed_import_done_cb)
|
||||
async def import_done_cb(words):
|
||||
await set_ephemeral_seed_words(words, meta='Imported')
|
||||
|
||||
return WordNestMenu(nwords, done_cb=import_done_cb)
|
||||
|
||||
async def ephemeral_seed_generate(nwords):
|
||||
await ux_dramatic_pause('Generating...', 3)
|
||||
@ -468,16 +539,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)
|
||||
await set_ephemeral_seed_words(words, meta="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()
|
||||
goto_top_menu(first_time=True)
|
||||
|
||||
async def set_ephemeral_seed_extended_key(extended_key):
|
||||
async def set_ephemeral_seed_extended_key(extended_key, meta=None):
|
||||
encoded, chain = xprv_to_encoded_secret(extended_key)
|
||||
await set_ephemeral_seed(encoded=encoded, chain=chain)
|
||||
dis.fullscreen("Applying...")
|
||||
await set_ephemeral_seed(encoded=encoded, chain=chain, meta=meta)
|
||||
goto_top_menu()
|
||||
|
||||
async def approve_word_list(seed, nwords, ephemeral=False):
|
||||
@ -577,52 +649,71 @@ def set_seed_value(words=None, encoded=None, chain=None):
|
||||
finally:
|
||||
dis.busy_bar(False)
|
||||
|
||||
def set_bip39_passphrase(pw):
|
||||
# apply bip39 passphrase for now (volatile)
|
||||
|
||||
# takes a bit, so show something
|
||||
from glob import dis
|
||||
async def calc_bip39_passphrase(pw, bypass_tmp=False):
|
||||
from glob import dis, settings
|
||||
from pincodes import pa
|
||||
|
||||
dis.fullscreen("Working...")
|
||||
# get xfp of parent reliably - cannot go to settings for this if in ephemeral
|
||||
if pa.tmp_value:
|
||||
with stash.SensitiveValues(bypass_tmp=bypass_tmp) as sv:
|
||||
assert sv.mode == 'words', sv.mode
|
||||
current_xfp = swab32(sv.node.my_fp())
|
||||
else:
|
||||
current_xfp = settings.get("xfp", 0)
|
||||
|
||||
# set passphrase
|
||||
import stash
|
||||
stash.bip39_passphrase = pw
|
||||
|
||||
# capture updated XFP
|
||||
with stash.SensitiveValues() as sv:
|
||||
with stash.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'
|
||||
assert sv.mode == 'words', sv.mode
|
||||
nv = SecretStash.encode(xprv=sv.node)
|
||||
xfp = swab32(sv.node.my_fp())
|
||||
|
||||
sv.capture_xpub()
|
||||
return nv, xfp, current_xfp
|
||||
|
||||
async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True):
|
||||
nv, _, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp)
|
||||
return await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw,
|
||||
meta="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp))
|
||||
# Might need to bounce the USB connection, because our pubkey has changed,
|
||||
# altho if they have already picked a shared session key, no need, and
|
||||
# would only affect MitM test, which has already been done.
|
||||
|
||||
async def remember_bip39_passphrase():
|
||||
async def remember_ephemeral_seed():
|
||||
# Compute current xprv and switch to using that as root secret.
|
||||
import stash
|
||||
from glob import dis
|
||||
from nvstore import SettingsObject
|
||||
from glob import dis, settings
|
||||
|
||||
dis.fullscreen('Check...')
|
||||
# we are already at temporary seed, with correct
|
||||
# settings in use - no need to call new_main_secret
|
||||
# at the end
|
||||
|
||||
with stash.SensitiveValues() as sv:
|
||||
nv = SecretStash.encode(xprv=sv.node)
|
||||
|
||||
# Important: won't write new XFP to nvram if pw still set
|
||||
stash.bip39_passphrase = ''
|
||||
# locking down temporary as new master
|
||||
# old master settings are destroyed
|
||||
dis.fullscreen("Cleanup...")
|
||||
assert pa.tmp_value, "no tmp"
|
||||
assert SettingsObject.master_nvram_key, "master nvram k"
|
||||
old_master = SettingsObject(SettingsObject.master_nvram_key)
|
||||
old_master.load()
|
||||
old_master.blank()
|
||||
del old_master
|
||||
|
||||
dis.fullscreen('Saving...')
|
||||
pa.change(new_secret=nv)
|
||||
pa.change(new_secret=pa.tmp_value, tmp_lockdown=True)
|
||||
|
||||
# re-read settings since key is now different
|
||||
# - also captures xfp, xpub at this point
|
||||
pa.new_main_secret(nv)
|
||||
# not needed - will be handled by reboot
|
||||
SettingsObject.master_nvram_key = None
|
||||
SettingsObject.master_sv_data = {}
|
||||
|
||||
# check and reload secret
|
||||
pa.reset()
|
||||
pa.login()
|
||||
|
||||
async def restore_to_main_secret(preserve_settings=False):
|
||||
# go back to main se2 secret
|
||||
pa.new_main_secret(raw_secret=None, blank=not preserve_settings)
|
||||
|
||||
def clear_seed():
|
||||
from glob import dis
|
||||
import utime, callgate
|
||||
@ -633,21 +724,9 @@ def clear_seed():
|
||||
# clear settings associated with this key, since it will be no more
|
||||
settings.blank()
|
||||
|
||||
if version.mk_num >= 4:
|
||||
callgate.fast_wipe(True)
|
||||
# NOT REACHED
|
||||
else:
|
||||
# save a blank secret (all zeros is a special case, detected by bootloader)
|
||||
nv = bytes(AE_SECRET_LEN)
|
||||
pa.change(new_secret=nv)
|
||||
callgate.fast_wipe(True)
|
||||
# NOT REACHED
|
||||
|
||||
if version.has_608:
|
||||
# wipe the long secret too
|
||||
nv = bytes(AE_LONG_SECRET_LEN)
|
||||
pa.ls_change(nv)
|
||||
|
||||
dis.busy_bar(False)
|
||||
dis.fullscreen('Reboot...')
|
||||
utime.sleep(1)
|
||||
|
||||
# security: need to reboot to really be sure to clear the secrets from main memory.
|
||||
@ -715,6 +794,222 @@ async def word_quiz(words, limited=None, title='Word %d is?'):
|
||||
|
||||
return
|
||||
|
||||
async def make_seed_vault_menu(*a):
|
||||
rv = SeedVaultMenu.construct()
|
||||
return SeedVaultMenu(rv)
|
||||
|
||||
class SeedVaultMenu(MenuSystem):
|
||||
|
||||
@staticmethod
|
||||
async def _set(menu, label, item):
|
||||
from glob import dis
|
||||
dis.fullscreen("Applying...")
|
||||
|
||||
xfp, encoded = item.arg
|
||||
|
||||
await set_ephemeral_seed(encoded, is_restore=True)
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
@staticmethod
|
||||
async def _remove(menu, label, item):
|
||||
from glob import dis, settings
|
||||
|
||||
idx, xfp_str, encoded = item.arg
|
||||
|
||||
msg = ("Remove seed from seed vault and delete its "
|
||||
"settings?\n\nPress OK to continue, press (1) to "
|
||||
"only remove from seed vault and keep "
|
||||
"encrypted settings for later use.\n\n"
|
||||
"WARNING: Funds will be lost if wallet is"
|
||||
" not backed-up elsewhere.")
|
||||
|
||||
ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape="1")
|
||||
if ch == "x": return
|
||||
|
||||
dis.fullscreen("Saving...")
|
||||
|
||||
wipe_slot = (ch != "1")
|
||||
tmp_val = False
|
||||
|
||||
if pa.tmp_value:
|
||||
tmp_val = True
|
||||
|
||||
if wipe_slot:
|
||||
# are we deleting current active ephemeral wallet
|
||||
# and its settings ?
|
||||
# slot wiping
|
||||
if tmp_val:
|
||||
# wipe current settings
|
||||
settings.blank()
|
||||
pa.tmp_value = False
|
||||
settings.return_to_master_seed()
|
||||
else:
|
||||
# in main settings
|
||||
xs = SettingsObject()
|
||||
xs.set_key(encoded)
|
||||
xs.load()
|
||||
xs.blank()
|
||||
del xs
|
||||
|
||||
# CAUTION: will get shadow copy if in tmp seed mode already
|
||||
seeds = settings.master_get("seeds", [])
|
||||
try:
|
||||
del seeds[idx]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# need to load and work on master secrets, will be slow if on tmp seed
|
||||
settings.master_set("seeds", seeds)
|
||||
|
||||
if tmp_val and wipe_slot:
|
||||
goto_top_menu()
|
||||
|
||||
# pop menu stack
|
||||
the_ux.pop()
|
||||
m = the_ux.top_of_stack()
|
||||
m.update_contents()
|
||||
|
||||
@staticmethod
|
||||
async def _detail(menu, label, item):
|
||||
xfp_str, encoded, name, meta = item.arg
|
||||
|
||||
# - first byte represents type of secret (internal encoding flag)
|
||||
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)
|
||||
|
||||
await ux_show_story(detail)
|
||||
|
||||
@staticmethod
|
||||
async def _rename(menu, label, item):
|
||||
# let them edit the name
|
||||
from glob import dis
|
||||
from ux import ux_input_text
|
||||
|
||||
idx, xfp_str = item.arg
|
||||
|
||||
seeds = settings.master_get("seeds", [])
|
||||
chk_xfp, encoded, old_name, meta = seeds[idx]
|
||||
assert chk_xfp == xfp_str
|
||||
|
||||
new_name = await ux_input_text(old_name, confirm_exit=False, max_len=40)
|
||||
|
||||
if not new_name:
|
||||
return
|
||||
|
||||
dis.fullscreen("Saving...")
|
||||
|
||||
# save it
|
||||
seeds[idx] = (chk_xfp, encoded, new_name, meta)
|
||||
|
||||
# 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:]
|
||||
|
||||
# .. and name in parent menu too
|
||||
parent = the_ux.parent_of(menu)
|
||||
if parent:
|
||||
parent.update_contents()
|
||||
|
||||
@staticmethod
|
||||
async def _add_current_tmp(*a):
|
||||
from pincodes import pa
|
||||
|
||||
assert pa.tmp_value
|
||||
main_xfp = settings.master_get("xfp", 0)
|
||||
|
||||
new_xfp = settings.get("xfp", 0)
|
||||
new_xfp_str = xfp2str(new_xfp)
|
||||
|
||||
# do not offer to store main seed
|
||||
if new_xfp == main_xfp:
|
||||
return
|
||||
|
||||
xfp_ui = "[%s]" % new_xfp_str
|
||||
|
||||
ch = await ux_show_story(title=xfp_ui, msg="Add to Seed Vault?")
|
||||
if ch != "y":
|
||||
return
|
||||
|
||||
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"))
|
||||
|
||||
settings.master_set("seeds", seeds)
|
||||
|
||||
await ux_show_story(xfp_ui + "\nSaved to Seed Vault")
|
||||
|
||||
m = the_ux.top_of_stack()
|
||||
m.update_contents()
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# Dynamic menu with user-defined names of seeds shown
|
||||
from glob import settings
|
||||
from pincodes import pa
|
||||
|
||||
rv = []
|
||||
add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp)
|
||||
|
||||
seeds = settings.master_get("seeds", [])
|
||||
|
||||
if not seeds:
|
||||
rv.append(MenuItem('(none saved yet)'))
|
||||
if pa.tmp_value:
|
||||
rv.append(add_current_tmp)
|
||||
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
|
||||
else:
|
||||
tmp_in_sv = False
|
||||
for i, (xfp_str, encoded, name, meta) in enumerate(seeds):
|
||||
is_active = False
|
||||
encoded = pad_raw_secret(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)),
|
||||
]
|
||||
if is_active:
|
||||
submenu[1] = MenuItem("Seed In Use")
|
||||
submenu[1].is_chosen = lambda: True
|
||||
|
||||
if pa.tmp_value and (not is_active):
|
||||
# if different ephemeral wallet active
|
||||
# DO NOT offer any modification api (rename/delete)
|
||||
submenu = submenu[:2]
|
||||
|
||||
item = MenuItem('%d: %s' % (i+1, name), menu=MenuSystem(submenu))
|
||||
if is_active:
|
||||
item.is_chosen = lambda: True
|
||||
|
||||
rv.append(item)
|
||||
|
||||
if pa.tmp_value:
|
||||
if seeds and (not tmp_in_sv):
|
||||
# give em chance to store current active
|
||||
rv.append(add_current_tmp)
|
||||
|
||||
from actions import restore_main_secret
|
||||
rv.append(MenuItem("Restore Master", f=restore_main_secret))
|
||||
|
||||
return rv
|
||||
|
||||
def update_contents(self):
|
||||
# Reconstruct the list of wallets on this dynamic menu, because
|
||||
# we added or changed them and are showing that same menu again.
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
class EphemeralSeedMenu(MenuSystem):
|
||||
|
||||
@ -732,44 +1027,43 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
from glob import NFC, settings
|
||||
from actions import nfc_recv_ephemeral, import_tapsigner_backup_file, import_xprv
|
||||
from glob import NFC
|
||||
from actions import nfc_recv_ephemeral, import_tapsigner_backup_file, import_xprv, restore_temporary
|
||||
|
||||
import_ephemeral_menu = [
|
||||
MenuItem("24 Words", f=cls.ephemeral_seed_import, arg=24),
|
||||
MenuItem("18 Words", f=cls.ephemeral_seed_import, arg=18),
|
||||
MenuItem("12 Words", f=cls.ephemeral_seed_import, arg=12),
|
||||
MenuItem("18 Words", f=cls.ephemeral_seed_import, arg=18),
|
||||
MenuItem("24 Words", f=cls.ephemeral_seed_import, arg=24),
|
||||
MenuItem("Import via NFC", f=nfc_recv_ephemeral, predicate=lambda: NFC is not None),
|
||||
]
|
||||
gen_ephemeral_menu = [
|
||||
MenuItem("24 Words", f=cls.ephemeral_seed_generate, arg=24),
|
||||
MenuItem("12 Words", f=cls.ephemeral_seed_generate, arg=12),
|
||||
MenuItem("24 Word Dice Roll", f=cls.ephemeral_seed_generate_from_dice, arg=24),
|
||||
MenuItem("24 Words", f=cls.ephemeral_seed_generate, arg=24),
|
||||
MenuItem("12 Word Dice Roll", f=cls.ephemeral_seed_generate_from_dice, arg=12),
|
||||
MenuItem("24 Word Dice Roll", f=cls.ephemeral_seed_generate_from_dice, arg=24),
|
||||
]
|
||||
|
||||
rv = [
|
||||
MenuItem("Generate Words", menu=gen_ephemeral_menu),
|
||||
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("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True
|
||||
MenuItem("Coldcard Backup", f=restore_temporary),
|
||||
]
|
||||
if pa.tmp_value:
|
||||
xfp = settings.get("xfp", "")
|
||||
if xfp:
|
||||
rv.insert(0, MenuItem("[%s]" % xfp2str(xfp)))
|
||||
else:
|
||||
rv.insert(0, MenuItem("[Active]"))
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
async def make_ephemeral_seed_menu(*a):
|
||||
if not pa.tmp_value:
|
||||
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(
|
||||
"Ephemeral seed is a temporary secret stored solely in device RAM, persisted for only a single boot. "
|
||||
"This defeats all of the benefits of Coldcard's secure element design."
|
||||
"\n\nPress (4) to prove you read to the end of this message and accept all consequences.",
|
||||
"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.",
|
||||
title="WARNING",
|
||||
escape="4"
|
||||
)
|
||||
@ -788,10 +1082,18 @@ class PassphraseMenu(MenuSystem):
|
||||
# singleton (cls level) vars
|
||||
done_cb = None
|
||||
|
||||
def __init__(self, done_cb=None, items=None):
|
||||
def __init__(self):
|
||||
global pp_sofar
|
||||
pp_sofar = ''
|
||||
|
||||
items = self.construct()
|
||||
super(PassphraseMenu, self).__init__(items)
|
||||
|
||||
def update_contents(self):
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
def construct(self):
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Edit Phrase', f=self.view_edit_phrase),
|
||||
@ -801,16 +1103,36 @@ class PassphraseMenu(MenuSystem):
|
||||
MenuItem('APPLY', f=self.done_apply),
|
||||
MenuItem('CANCEL', f=self.done_cancel),
|
||||
]
|
||||
# quick SD card check
|
||||
if pyb.SDCard().present():
|
||||
try:
|
||||
with CardSlot() as card:
|
||||
# check if passphrases file exists on SD
|
||||
# if yes add menu item
|
||||
if card.exists(PassphraseSaver.filename(card)):
|
||||
items.insert(0, MenuItem('Restore Saved', menu=self.restore_saved))
|
||||
|
||||
except: pass
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def restore_saved(*a):
|
||||
dis.fullscreen("Decrypting...")
|
||||
try:
|
||||
saved = PassphraseSaver().make_menu()
|
||||
if saved:
|
||||
items.insert(0, MenuItem('Restore Saved', menu=saved))
|
||||
except:
|
||||
# don't want bugs/corrupt files to make rest of menu inaccessible
|
||||
pass
|
||||
items = PassphraseSaverMenu.construct()
|
||||
except CardMissingError:
|
||||
await needs_microsd()
|
||||
return
|
||||
except Exception as e:
|
||||
await ux_show_story(title="Failure", msg=str(e) + problem_file_line(e))
|
||||
return
|
||||
|
||||
super(PassphraseMenu, self).__init__(items)
|
||||
if not items:
|
||||
await ux_show_story("Nothing found")
|
||||
return
|
||||
|
||||
return PassphraseSaverMenu(items)
|
||||
|
||||
def on_cancel(self):
|
||||
# zip to cancel item when they fail to exit via X button
|
||||
@ -821,12 +1143,15 @@ class PassphraseMenu(MenuSystem):
|
||||
|
||||
async def add_numbers(self, *a):
|
||||
global pp_sofar
|
||||
pp_sofar = await ux_input_numbers(pp_sofar, self.check_length)
|
||||
pw = await ux_input_numbers(pp_sofar, self.check_length)
|
||||
if pw is not None:
|
||||
pp_sofar = pw
|
||||
self.check_length()
|
||||
|
||||
async def empty_phrase(self, *a):
|
||||
global pp_sofar
|
||||
|
||||
if pp_sofar and len(pp_sofar) >= 3:
|
||||
if len(pp_sofar) >= 3:
|
||||
if not await ux_confirm("Press OK to clear passphrase. X to cancel."):
|
||||
return
|
||||
|
||||
@ -871,27 +1196,84 @@ class PassphraseMenu(MenuSystem):
|
||||
goto_top_menu()
|
||||
|
||||
async def done_apply(self, *a):
|
||||
# apply the passphrase.
|
||||
# - important to work on empty string here too.
|
||||
from stash import bip39_passphrase
|
||||
old_pw = str(bip39_passphrase)
|
||||
# apply the passphrase
|
||||
import stash
|
||||
from glob import settings
|
||||
from pincodes import pa
|
||||
|
||||
set_bip39_passphrase(pp_sofar)
|
||||
|
||||
xfp = settings.get('xfp')
|
||||
|
||||
msg = '''Above is the master key fingerprint of the new wallet.
|
||||
|
||||
Press X to abort and keep editing passphrase, OK to use the new wallet, or 1 to use and save to MicroSD'''
|
||||
|
||||
ch = await ux_show_story(msg, title="[%s]" % xfp2str(xfp), escape='1')
|
||||
if ch == 'x':
|
||||
# go back!
|
||||
set_bip39_passphrase(old_pw)
|
||||
if not pp_sofar:
|
||||
# empty string here - noop
|
||||
return
|
||||
|
||||
mdata = None
|
||||
tdata = None
|
||||
|
||||
try:
|
||||
m_nv, m_xfp, m_parent_xfp = await calc_bip39_passphrase(pp_sofar,
|
||||
bypass_tmp=True)
|
||||
m_parent_xfp_str = xfp2str(m_parent_xfp)
|
||||
m_xfp_str = xfp2str(m_xfp)
|
||||
mdata = (
|
||||
m_nv, m_xfp, m_xfp_str, m_parent_xfp_str,
|
||||
"master seed [%s]" % m_parent_xfp_str,
|
||||
"(1) master+pass:\n%s→%s\n\n" % (m_parent_xfp_str, m_xfp_str),
|
||||
)
|
||||
except AssertionError: pass
|
||||
|
||||
if pa.tmp_value and settings.get("words", True):
|
||||
# we have ephemeral seed - can add passphrase to it as it is word based
|
||||
t_nv, t_xfp, t_parent_xfp = await calc_bip39_passphrase(pp_sofar,
|
||||
bypass_tmp=False)
|
||||
t_parent_xfp_str = xfp2str(t_parent_xfp)
|
||||
t_xfp_str = xfp2str(t_xfp)
|
||||
tdata = (
|
||||
t_nv, t_xfp, t_xfp_str, t_parent_xfp_str,
|
||||
"current active temporary seed [%s]" % t_parent_xfp_str,
|
||||
"(2) tmp+pass:\n%s→%s\n\n" % (t_parent_xfp_str, t_xfp_str),
|
||||
)
|
||||
|
||||
if tdata is None and mdata is None:
|
||||
# if master is not word based, temporary has to be, otherwise "Passphrase"
|
||||
# not offered in menu
|
||||
# should never be seen by user because flow.py::bip39_passphrase_active
|
||||
await ux_show_story(title="FAILED", msg="Need word based secret")
|
||||
return
|
||||
|
||||
tmp = False
|
||||
if tdata and mdata:
|
||||
ch = await ux_show_story(mdata[-1] + tdata[-1], escape='12x',
|
||||
strict_escape=True, scrollbar=False)
|
||||
if ch == "x": return # exit
|
||||
if ch == "2":
|
||||
tmp = True
|
||||
elif tdata:
|
||||
tmp = True
|
||||
|
||||
data = tdata if tmp else mdata
|
||||
nv, xfp, xfp_str, parent_xfp_str, msg, _ = data
|
||||
|
||||
msg = ('Above is the master key fingerprint of the new wallet'
|
||||
' created by adding passphrase to %s.'
|
||||
' Press X to abort and keep editing passphrase,'
|
||||
' OK to use the new wallet, (1) to use'
|
||||
' and save to MicroSD') % msg
|
||||
|
||||
ch = await ux_show_story(msg, title="[%s]" % xfp_str, escape='1')
|
||||
if ch == 'x':
|
||||
return
|
||||
|
||||
await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=pp_sofar,
|
||||
meta="BIP-39 Passphrase on [%s]" % parent_xfp_str)
|
||||
if ch == '1':
|
||||
await PassphraseSaver().append(xfp, pp_sofar)
|
||||
try:
|
||||
await PassphraseSaver().append(xfp, pp_sofar)
|
||||
except CardMissingError:
|
||||
await needs_microsd()
|
||||
except Exception as e:
|
||||
await ux_show_story(
|
||||
title="ERROR",
|
||||
msg='Save failed!\n\n%s\n%s' % (e, problem_file_line(e))
|
||||
)
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
|
||||
@ -56,11 +56,7 @@ async def test_secure_element():
|
||||
assert not get_is_bricked() # bricked already
|
||||
|
||||
# test right chips installed
|
||||
if version.has_fatram:
|
||||
assert version.has_608 # expect 608
|
||||
else:
|
||||
assert not version.has_608 # expect 508a
|
||||
assert version.hw_label == 'mk2'
|
||||
assert version.has_608 # expect 608
|
||||
|
||||
if ckcc.is_simulator(): return
|
||||
|
||||
@ -115,9 +111,6 @@ async def test_sd_active():
|
||||
|
||||
async def test_usb_light():
|
||||
# Mk4's new USB activity light (right by connector)
|
||||
|
||||
if version.mk_num < 4: return
|
||||
|
||||
from machine import Pin
|
||||
p = Pin('USB_ACTIVE', Pin.OUT)
|
||||
|
||||
@ -139,8 +132,6 @@ async def test_nfc():
|
||||
await NFCHandler.selftest()
|
||||
|
||||
async def test_psram():
|
||||
if not version.has_psram: return
|
||||
|
||||
from glob import PSRAM
|
||||
from ustruct import pack
|
||||
import ngu
|
||||
@ -168,54 +159,6 @@ async def test_psram():
|
||||
assert chk == rnd, "RB bad @ 0x%x" % pos
|
||||
dis.progress_bar_show((PSRAM.length + pos) / test_len)
|
||||
|
||||
async def test_sflash():
|
||||
if version.has_psram: return
|
||||
|
||||
dis.clear()
|
||||
dis.text(None, 18, 'Serial Flash')
|
||||
dis.show()
|
||||
|
||||
from sflash import SF
|
||||
from ustruct import pack
|
||||
import ngu
|
||||
|
||||
msize = 1024*1024
|
||||
SF.chip_erase()
|
||||
|
||||
for phase in [0, 1]:
|
||||
steps = 7*4
|
||||
for i in range(steps):
|
||||
dis.progress_bar(i/steps)
|
||||
dis.show()
|
||||
await sleep_ms(250)
|
||||
if not SF.is_busy(): break
|
||||
|
||||
assert not SF.is_busy() # "didn't finish"
|
||||
|
||||
# leave chip blank
|
||||
if phase == 1: break
|
||||
|
||||
|
||||
buf = bytearray(32)
|
||||
for addr in range(0, msize, 1024):
|
||||
SF.read(addr, buf)
|
||||
assert set(buf) == {255} # "not blank"
|
||||
|
||||
rnd = ngu.hash.sha256s(pack('I', addr))
|
||||
SF.write(addr, rnd)
|
||||
SF.wait_done()
|
||||
SF.read(addr, buf)
|
||||
assert buf == rnd # "write failed"
|
||||
|
||||
dis.progress_bar_show(addr/msize)
|
||||
|
||||
# check no aliasing, also right size part
|
||||
for addr in range(0, msize, 1024):
|
||||
expect = ngu.hash.sha256s(pack('I', addr))
|
||||
SF.read(addr, buf)
|
||||
assert buf == expect # "readback failed"
|
||||
|
||||
dis.progress_bar_show(addr/msize)
|
||||
|
||||
async def test_oled():
|
||||
# all on/off tests
|
||||
@ -306,7 +249,6 @@ async def start_selftest():
|
||||
await test_oled()
|
||||
await test_psram()
|
||||
await test_nfc()
|
||||
await test_sflash()
|
||||
await test_microsd()
|
||||
await test_numpad()
|
||||
await test_secure_element()
|
||||
|
||||
@ -16,7 +16,6 @@ ser_*, deser_*: functions that handle serialization/deserialization
|
||||
"""
|
||||
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
import ustruct as struct
|
||||
import ngu
|
||||
from opcodes import *
|
||||
@ -30,13 +29,15 @@ hash160 = ngu.hash.hash160
|
||||
def bytes_to_hex_str(s):
|
||||
return str(b2a_hex(s), 'ascii')
|
||||
|
||||
SIGHASH_ALL = const(1)
|
||||
SIGHASH_NONE = const(2)
|
||||
SIGHASH_SINGLE = const(3)
|
||||
SIGHASH_DEFAULT = const(0) # in taproot meaning same as SIGHASH_ALL (over whole TX)
|
||||
SIGHASH_ALL = const(1)
|
||||
SIGHASH_NONE = const(2)
|
||||
SIGHASH_SINGLE = const(3)
|
||||
SIGHASH_ANYONECANPAY = const(0x80)
|
||||
|
||||
# list containing all flags that we support signing for
|
||||
ALL_SIGHASH_FLAGS = [
|
||||
SIGHASH_DEFAULT,
|
||||
SIGHASH_ALL,
|
||||
SIGHASH_NONE,
|
||||
SIGHASH_SINGLE,
|
||||
@ -56,14 +57,20 @@ def ser_compact_size(l):
|
||||
else:
|
||||
return struct.pack("<BQ", 255, l)
|
||||
|
||||
def deser_compact_size(f):
|
||||
def deser_compact_size(f, ret_num_bytes=False):
|
||||
nit = struct.unpack("<B", f.read(1))[0]
|
||||
num_bytes = 1
|
||||
if nit == 253:
|
||||
nit = struct.unpack("<H", f.read(2))[0]
|
||||
num_bytes += 2
|
||||
elif nit == 254:
|
||||
nit = struct.unpack("<I", f.read(4))[0]
|
||||
num_bytes += 4
|
||||
elif nit == 255:
|
||||
nit = struct.unpack("<Q", f.read(8))[0]
|
||||
num_bytes += 8
|
||||
if ret_num_bytes:
|
||||
return nit, num_bytes
|
||||
return nit
|
||||
|
||||
def deser_string(f):
|
||||
@ -367,6 +374,11 @@ class CTxOut(object):
|
||||
# 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
|
||||
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
|
||||
@ -384,7 +396,7 @@ class CTxOut(object):
|
||||
|
||||
# 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))
|
||||
raise ValueError('scriptPubKey template fail: ' + b2a_hex(self.scriptPubKey).decode())
|
||||
|
||||
def is_p2sh(self):
|
||||
return len(self.scriptPubKey) == 23 and self.scriptPubKey[0] == 0xa9 \
|
||||
@ -557,6 +569,7 @@ class CTransaction(object):
|
||||
self.hash = b2a_hex(bytes(tmp[i] for i in range(len(tmp)-1, -1, -1)))
|
||||
|
||||
def is_valid(self):
|
||||
COIN = 100000000
|
||||
self.calc_sha256()
|
||||
for tout in self.vout:
|
||||
if tout.nValue < 0 or tout.nValue > 21000000 * COIN:
|
||||
|
||||
154
shared/sffile.py
154
shared/sffile.py
@ -1,42 +1,25 @@
|
||||
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# sffile.py - file-like objects stored in SPI Flash (Mk1-3) or PSRAM (Mk4+)
|
||||
# sffile.py - file-like objects stored in PSRAM (Mk4+) (used to be SPI Flash)
|
||||
#
|
||||
# - implements stream IO protoccol
|
||||
# - does erasing for you
|
||||
# - random read, sequential write
|
||||
# - only a few of these are possible
|
||||
# - the offset is the file name
|
||||
# - (<Mk3) last 64k of memory reserved for settings
|
||||
#
|
||||
from uasyncio import sleep_ms
|
||||
from uio import BytesIO
|
||||
from uhashlib import sha256
|
||||
from version import has_psram
|
||||
|
||||
if not has_psram:
|
||||
# Use SPI flash chip
|
||||
from sflash import SF
|
||||
# Use PSRAM chip
|
||||
from glob import PSRAM
|
||||
blksize = 4
|
||||
|
||||
# this code works on large "blocks" defined by the chip as 64k
|
||||
blksize = 65536
|
||||
|
||||
def PADOUT(n):
|
||||
# rounds up
|
||||
return (n + blksize - 1) & ~(blksize-1)
|
||||
else:
|
||||
# Use PSRAM chip
|
||||
from glob import PSRAM
|
||||
blksize = 4
|
||||
PADOUT = lambda n: n
|
||||
|
||||
def ALIGN4(n):
|
||||
return n & ~0x3
|
||||
def ALIGN4(n):
|
||||
return n & ~0x3
|
||||
|
||||
class SFFile:
|
||||
def __init__(self, start, length=0, max_size=None, message=None, pre_erased=False):
|
||||
if not pre_erased:
|
||||
assert start % blksize == 0 # 'misaligned'
|
||||
def __init__(self, start, length=0, max_size=None, message=None):
|
||||
# Operate in PSRAM and pretend to be a filesystem's file
|
||||
assert start % blksize == 0 # 'misaligned'
|
||||
self.start = start
|
||||
self.pos = 0
|
||||
self.length = length # byte-wise length
|
||||
@ -45,14 +28,13 @@ class SFFile:
|
||||
|
||||
if max_size != None:
|
||||
# Write
|
||||
self.max_size = PADOUT(max_size) if not pre_erased else max_size
|
||||
self.max_size = max_size
|
||||
self.readonly = False
|
||||
self.checksum = sha256()
|
||||
|
||||
if has_psram:
|
||||
# up to 3 bytes that haven't been written-out yet
|
||||
self.runt = bytearray()
|
||||
self._pos = 0
|
||||
# up to 3 bytes that haven't been written-out yet
|
||||
self.runt = bytearray()
|
||||
self.wr_pos = 0
|
||||
else:
|
||||
# Read
|
||||
self.readonly = True
|
||||
@ -87,21 +69,10 @@ class SFFile:
|
||||
|
||||
async def erase(self):
|
||||
# must be used by caller before writing any bytes
|
||||
# - now just checks, used to be a slow erase cycle
|
||||
assert not self.readonly
|
||||
assert self.length == 0 # 'already wrote?'
|
||||
|
||||
if has_psram: return
|
||||
|
||||
for i in range(0, self.max_size, blksize):
|
||||
SF.block_erase(self.start + i)
|
||||
|
||||
if i and self.message:
|
||||
from glob import dis
|
||||
dis.progress_bar_show(i/self.max_size)
|
||||
|
||||
# expect block erase to take up to 2 seconds
|
||||
while SF.is_busy():
|
||||
await sleep_ms(50)
|
||||
assert self.length == 0 # 'already wrote?'
|
||||
return
|
||||
|
||||
def __enter__(self):
|
||||
if self.message:
|
||||
@ -118,23 +89,17 @@ class SFFile:
|
||||
|
||||
return False
|
||||
|
||||
def wait_writable(self):
|
||||
if has_psram: return
|
||||
|
||||
while SF.is_busy():
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
# PSRAM might leave a little behind
|
||||
if self.runt:
|
||||
# write final runt, might be up to 3 bytes (padding w/ zeros)
|
||||
assert len(self.runt) <= 3 # , 'rl=%d'%len(self.runt)
|
||||
assert self._pos + len(self.runt) == self.pos
|
||||
assert self.wr_pos + len(self.runt) == self.pos
|
||||
self.runt.extend(bytes(4-len(self.runt)))
|
||||
PSRAM.write(self.start + self._pos, self.runt)
|
||||
PSRAM.write(self.start + self.wr_pos, self.runt)
|
||||
|
||||
self.runt = None
|
||||
self._pos = self.pos
|
||||
self.wr_pos = self.pos
|
||||
|
||||
def write(self, b):
|
||||
# immediate write, no buffering
|
||||
@ -144,58 +109,24 @@ class SFFile:
|
||||
|
||||
left = len(b)
|
||||
|
||||
if has_psram:
|
||||
# Mk4: memory-mapped, but can only do word-aligned writes
|
||||
self.checksum.update(b)
|
||||
# PSRAM is memory-mapped, but can only do word-aligned writes!
|
||||
self.checksum.update(b)
|
||||
|
||||
self.runt.extend(b)
|
||||
here = ALIGN4(len(self.runt))
|
||||
if here:
|
||||
PSRAM.write(self.start + self._pos, self.runt[0:here])
|
||||
self._pos += here
|
||||
self.runt = self.runt[here:]
|
||||
self.runt.extend(b)
|
||||
here = ALIGN4(len(self.runt))
|
||||
if here:
|
||||
PSRAM.write(self.start + self.wr_pos, self.runt[0:here])
|
||||
self.wr_pos += here
|
||||
self.runt = self.runt[here:]
|
||||
|
||||
self.pos += left
|
||||
self.length = self.pos
|
||||
|
||||
self.pos += left
|
||||
self.length = self.pos
|
||||
if self.message:
|
||||
from glob import dis
|
||||
dis.progress_sofar(self.pos, self.length)
|
||||
|
||||
if self.message:
|
||||
from glob import dis
|
||||
dis.progress_sofar(self.pos, self.length)
|
||||
|
||||
return left
|
||||
|
||||
else:
|
||||
# must perform page-aligned (256) writes, but can start
|
||||
# anywhere in the page, and can write just one byte
|
||||
sofar = 0
|
||||
|
||||
while left:
|
||||
if (self.pos + sofar) % 256 != 0:
|
||||
# start is unaligned, do a partial write to align
|
||||
assert sofar == 0 #, (sofar, (self.pos+sofar)) # can only happen on first page
|
||||
runt = min(left, 256 - (self.pos % 256))
|
||||
here = memoryview(b)[0:runt]
|
||||
assert len(here) == runt
|
||||
else:
|
||||
# write full pages, or final runt
|
||||
here = memoryview(b)[sofar:sofar+256]
|
||||
assert 1 <= len(here) <= 256
|
||||
|
||||
self.wait_writable()
|
||||
|
||||
SF.write(self.start + self.pos + sofar, here)
|
||||
|
||||
left -= len(here)
|
||||
sofar += len(here)
|
||||
self.checksum.update(here)
|
||||
|
||||
assert left >= 0
|
||||
|
||||
assert sofar == len(b)
|
||||
self.pos += sofar
|
||||
self.length = self.pos
|
||||
return sofar
|
||||
return left
|
||||
|
||||
def read(self, ll=None):
|
||||
if ll == 0:
|
||||
@ -210,10 +141,13 @@ class SFFile:
|
||||
return b''
|
||||
|
||||
rv = bytearray(ll)
|
||||
if has_psram:
|
||||
PSRAM.read(self.start + self.pos, rv)
|
||||
else:
|
||||
SF.read(self.start + self.pos, rv)
|
||||
if self.runt and self.pos + ll > self.wr_pos:
|
||||
# put the runt data into place, because we are about to read it
|
||||
t = bytearray(self.runt)
|
||||
t.extend(bytes(4-len(t)))
|
||||
PSRAM.write(self.start + self.wr_pos, t)
|
||||
|
||||
PSRAM.read(self.start + self.pos, rv)
|
||||
|
||||
self.pos += ll
|
||||
|
||||
@ -227,10 +161,7 @@ class SFFile:
|
||||
if actual <= 0:
|
||||
return 0
|
||||
|
||||
if has_psram:
|
||||
PSRAM.read(self.start + self.pos, b)
|
||||
else:
|
||||
SF.read(self.start + self.pos, b)
|
||||
PSRAM.read(self.start + self.pos, b)
|
||||
|
||||
self.pos += actual
|
||||
|
||||
@ -252,9 +183,6 @@ class SizerFile(SFFile):
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
return False
|
||||
|
||||
def wait_writable(self):
|
||||
return
|
||||
|
||||
def write(self, b):
|
||||
# immediate write, no buffering
|
||||
assert self.pos == self.length # "can only append"
|
||||
|
||||
137
shared/sflash.py
137
shared/sflash.py
@ -1,137 +0,0 @@
|
||||
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# sflash.py - SPI Flash on rev D and up boards. Simple serial SPI flash on SPI2 port.
|
||||
#
|
||||
# see also ../external/micropython/drivers/memory/spiflash.c
|
||||
# but not using that, because:
|
||||
# - not exposed as python objects
|
||||
# - it wants to waste 4k on a buffer
|
||||
#
|
||||
# Layout for project:
|
||||
# - 384k PSBT incoming (MAX_TXN_LEN)
|
||||
# - 384k PSBT outgoing (MAX_TXN_LEN)
|
||||
# - 128k nvram settings (32 slots of 4k each)
|
||||
#
|
||||
# During firmware updates, entire flash, starting at zero may be used.
|
||||
#
|
||||
import machine
|
||||
from version import mk_num
|
||||
|
||||
CMD_WRSR = const(0x01)
|
||||
CMD_WRITE = const(0x02)
|
||||
CMD_READ = const(0x03)
|
||||
CMD_FAST_READ = const(0x0b)
|
||||
CMD_RDSR = const(0x05)
|
||||
CMD_WREN = const(0x06)
|
||||
CMD_RDCR = const(0x35)
|
||||
CMD_RD_DEVID = const(0x9f)
|
||||
CMD_SEC_ERASE = const(0x20) # 4k unit, 40-200ms
|
||||
CMD_BLK_ERASE = const(0xd8) # 64k, 0.4 - 2s
|
||||
CMD_CHIP_ERASE = const(0xc7) # 1MB, 3.5 - 6s
|
||||
CMD_C4READ = const(0xeb)
|
||||
|
||||
class SPIFlash:
|
||||
# must write with this page size granulatity
|
||||
PAGE_SIZE = 256
|
||||
# must erase with one of these size granulatity!
|
||||
SECTOR_SIZE = 4096
|
||||
BLOCK_SIZE = 65536
|
||||
|
||||
def __init__(self):
|
||||
from machine import Pin
|
||||
|
||||
# chip can do 80Mhz, but very limited prescaler-only baudrate generation, so
|
||||
# sysclk/2 or /4 will happen depending on Mk3 vs. 4
|
||||
# - Mk4: 120Mhz => 60Mhz result (div 2)
|
||||
# - Mk3: 80Mhz => 40Mhz result (div 2)
|
||||
self.spi = machine.SPI(2, baudrate=80_000_000)
|
||||
self.cs = Pin('SF_CS', Pin.OUT)
|
||||
|
||||
def cmd(self, cmd, addr=None, complete=True, pad=False):
|
||||
if addr is not None:
|
||||
buf = bytes([cmd, (addr>>16) & 0xff, (addr >> 8) & 0xff, addr & 0xff])
|
||||
else:
|
||||
buf = bytes([cmd])
|
||||
|
||||
if pad:
|
||||
buf = buf + b'\0'
|
||||
|
||||
self.cs.low()
|
||||
self.spi.write(buf)
|
||||
if complete:
|
||||
self.cs.high()
|
||||
|
||||
def read(self, address, buf, cmd=CMD_FAST_READ):
|
||||
# random read (fast mode, because why wouldn't we?!)
|
||||
self.cmd(cmd, address, complete=False, pad=True)
|
||||
self.spi.readinto(buf)
|
||||
self.cs.high()
|
||||
|
||||
def write(self, address, buf):
|
||||
# 'page program', must already be erased
|
||||
assert 1 <= len(buf) <= 256 # "max 256"
|
||||
assert address & ~0xff == (address+len(buf)-1) & ~0xff # "page boundary"
|
||||
|
||||
self.cmd(CMD_WREN)
|
||||
self.cmd(CMD_WRITE, address, complete=False)
|
||||
self.spi.write(buf)
|
||||
self.cs.high()
|
||||
|
||||
def read_reg(self, cmd, length=3):
|
||||
# read register
|
||||
rv = bytearray(length)
|
||||
self.cmd(cmd, 0, complete=False)
|
||||
self.spi.readinto(rv)
|
||||
self.cs.high()
|
||||
|
||||
return rv
|
||||
|
||||
def is_busy(self):
|
||||
# return status of WIP = Write In Progress bit
|
||||
r = self.read_reg(CMD_RDSR, 1)
|
||||
return bool(r[0] & 0x01)
|
||||
|
||||
def wait_done(self):
|
||||
# wait until write done; could be fancier
|
||||
while 1:
|
||||
if not self.is_busy():
|
||||
return
|
||||
|
||||
def chip_erase(self):
|
||||
# can take up to 6 seconds, so poll is_busy()
|
||||
self.cmd(CMD_WREN)
|
||||
self.cmd(CMD_CHIP_ERASE)
|
||||
|
||||
def sector_erase(self, address):
|
||||
# erase 4k. 40-200ms delay; poll is_busy()
|
||||
assert address % 4096 == 0 # "not sector start"
|
||||
|
||||
self.cmd(CMD_WREN)
|
||||
self.cmd(CMD_SEC_ERASE, address)
|
||||
|
||||
def block_erase(self, address):
|
||||
# erase 64k at once
|
||||
assert address % 65536 == 0 # "not block start"
|
||||
self.cmd(CMD_WREN)
|
||||
self.cmd(CMD_BLK_ERASE, address)
|
||||
|
||||
def wipe_most(self):
|
||||
# erase everything except settings: takes 5 seconds at least
|
||||
from glob import dis
|
||||
|
||||
assert mk_num <= 3 # obsolete in mk4
|
||||
|
||||
from nvstore import SLOTS
|
||||
end = SLOTS[0]
|
||||
|
||||
for addr in range(0, end, self.BLOCK_SIZE):
|
||||
self.block_erase(addr)
|
||||
dis.progress_bar_show(addr/end)
|
||||
|
||||
while self.is_busy():
|
||||
pass
|
||||
|
||||
# singleton
|
||||
SF = SPIFlash()
|
||||
|
||||
# EOF
|
||||
@ -1,46 +0,0 @@
|
||||
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# sram2.py - Jam some larger, long-lived objects into the SRAM2 area, which isn't used enough.
|
||||
#
|
||||
# Cautions/Notes:
|
||||
# - mpy heap does not include SRAM2, so doing manual memory alloc here.
|
||||
# - top 8k reserved for bootloader, which will wipe it on each entry
|
||||
# - top page of that is specially marked to cause reset if any attempt to change
|
||||
# - 2k at bottom reserved for code in `flashbdev.c` to use as cache data for flash writing
|
||||
# - keep this file in sync with simulated version
|
||||
# - none of the above is true anymore on mk4
|
||||
#
|
||||
import uctypes, ckcc
|
||||
from version import mk_num
|
||||
|
||||
if mk_num < 4:
|
||||
# see stm32/COLDCARD/layout.ld where this is effectively defined
|
||||
SRAM2_START = const(0x10000800)
|
||||
SRAM2_LENGTH = const(0x5800)
|
||||
|
||||
_start = SRAM2_START
|
||||
|
||||
def _alloc(ln):
|
||||
global _start
|
||||
rv = uctypes.bytearray_at(_start, ln)
|
||||
_start += ln
|
||||
return rv
|
||||
else:
|
||||
# Mk4 has tons of memory, so just use it
|
||||
def _alloc(ln):
|
||||
return bytearray(ln)
|
||||
|
||||
nvstore_buf = _alloc(4096-32)
|
||||
display_buf = _alloc(1024)
|
||||
display2_buf = _alloc(1024)
|
||||
usb_buf = _alloc(2048+12) # 2060 @ 0x10001be0
|
||||
tmp_buf = _alloc(1024)
|
||||
psbt_tmp256 = _alloc(256)
|
||||
|
||||
if mk_num < 4:
|
||||
assert _start <= 0x10006000
|
||||
|
||||
# observed: about 22k on Mk2
|
||||
ckcc.stack_limit(SRAM2_LENGTH - (_start - SRAM2_START))
|
||||
|
||||
# EOF
|
||||
@ -37,8 +37,7 @@ class SSD1306(framebuf.FrameBuffer):
|
||||
|
||||
#self.buffer = bytearray(self.pages * self.width)
|
||||
|
||||
from sram2 import display_buf
|
||||
self.buffer = display_buf
|
||||
self.buffer = bytearray(1024)
|
||||
assert len(self.buffer) == self.pages * self.width
|
||||
|
||||
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
|
||||
|
||||
100
shared/stash.py
100
shared/stash.py
@ -12,8 +12,12 @@
|
||||
#
|
||||
import ngu, uctypes, gc, bip39, utime
|
||||
from uhashlib import sha256
|
||||
from pincodes import AE_SECRET_LEN
|
||||
from utils import swab32, call_later_ms
|
||||
from utils import swab32, call_later_ms, B2A
|
||||
|
||||
|
||||
class ZeroSecretException(ValueError):
|
||||
# raised when there is no secret or secret is zero
|
||||
pass
|
||||
|
||||
def blank_object(item):
|
||||
# Use/abuse uctypes to blank objects under python. Will likely
|
||||
@ -49,7 +53,7 @@ class SecretStash:
|
||||
|
||||
@staticmethod
|
||||
def encode(seed_phrase=None, master_secret=None, xprv=None):
|
||||
nv = bytearray(AE_SECRET_LEN)
|
||||
nv = bytearray(72) # AE_SECRET_LEN
|
||||
|
||||
if seed_phrase:
|
||||
# typical: packed version of memonic phrase
|
||||
@ -115,7 +119,7 @@ class SecretStash:
|
||||
|
||||
elif marker == 0x00:
|
||||
# probably all zeros, which we don't normally store, and represents "no secret"
|
||||
raise ValueError('actually zero secret')
|
||||
raise ZeroSecretException
|
||||
else:
|
||||
# variable-length master secret for BIP-32
|
||||
vlen = secret[0]
|
||||
@ -127,8 +131,34 @@ class SecretStash:
|
||||
|
||||
return 'master', ms, hd
|
||||
|
||||
@staticmethod
|
||||
def storage_serialize(secret):
|
||||
# make it a JSON-compatible field
|
||||
return B2A(bytes(secret).rstrip(b"\x00"))
|
||||
|
||||
@staticmethod
|
||||
def summary(marker):
|
||||
# decode enough to explain what we have in a text form
|
||||
# - give us the first byte of the stored, encoded secret
|
||||
if marker == 0x01:
|
||||
# xprv => BIP-32 private key values
|
||||
return 'xprv'
|
||||
|
||||
if marker & 0x80:
|
||||
# seed phrase
|
||||
ll = ((marker & 0x3) + 2) * 8
|
||||
return '%d words' % len_to_numwords(ll)
|
||||
|
||||
if marker == 0x00:
|
||||
# probably all zeros, which we don't normally store, and represents "no secret"
|
||||
return 'zeros'
|
||||
|
||||
# variable-length master secret for BIP-32
|
||||
return '%d bytes' % marker
|
||||
|
||||
# optional global value: user-supplied passphrase to salt BIP-39 seed process
|
||||
bip39_passphrase = ''
|
||||
# just a boolean flag from version 5.2.0
|
||||
bip39_passphrase = False
|
||||
|
||||
CACHE_CHECK_RATE = const(10*1000) # 10 seconds
|
||||
CACHE_MAX_LIFE = const(60*1000) # one minute
|
||||
@ -136,16 +166,14 @@ CACHE_MAX_LIFE = const(60*1000) # one minute
|
||||
class SensitiveValues:
|
||||
# be a context manager, and holder of secrets in-memory
|
||||
|
||||
# class-level cache, key is bip39 pass
|
||||
_cache = {}
|
||||
# class-level cache
|
||||
_cache_secret = None
|
||||
_cache_used = None
|
||||
|
||||
def __init__(self, secret=None, bypass_pw=False):
|
||||
def __init__(self, secret=None, bip39pw='', bypass_tmp=False):
|
||||
self.spots = []
|
||||
|
||||
# backup during volatile bip39 encryption: do not use passphrase
|
||||
self._bip39pw = '' if bypass_pw else str(bip39_passphrase)
|
||||
self._bip39pw = bip39pw
|
||||
|
||||
if secret is not None:
|
||||
# sometimes we already know the secret
|
||||
@ -159,27 +187,23 @@ class SensitiveValues:
|
||||
from pincodes import pa
|
||||
|
||||
if pa.is_secret_blank():
|
||||
raise ValueError('no secrets yet')
|
||||
raise ZeroSecretException
|
||||
self.deltamode = pa.is_deltamode()
|
||||
|
||||
if self._bip39pw in self._cache:
|
||||
# cache hit
|
||||
if self._cache_secret and not bypass_tmp:
|
||||
# they are using new BIP39 passphrase but we already have raw secret
|
||||
self.secret = bytearray(self._cache_secret)
|
||||
self.mode, r, n = self._cache[self._bip39pw]
|
||||
self.raw = bytearray(r)
|
||||
self.node = n.copy()
|
||||
self.__class__._cache_used = utime.ticks_ms()
|
||||
else:
|
||||
if self._cache_secret:
|
||||
# they are using new BIP39 passphrase but we already have raw secret
|
||||
self.secret = bytearray(self._cache_secret)
|
||||
else:
|
||||
# slow: read from secure element(s)
|
||||
self.secret = pa.fetch()
|
||||
# slow: read from secure element(s)
|
||||
self.secret = pa.fetch(bypass_tmp=bypass_tmp)
|
||||
|
||||
# slow: do bip39 key stretching (typically)
|
||||
self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw)
|
||||
# slow: do bip39 key stretching (typically)
|
||||
self.mode, self.raw, self.node = SecretStash.decode(self.secret, self._bip39pw)
|
||||
|
||||
if not bypass_tmp:
|
||||
# DO NOT save to cache if we are bypassing tmp
|
||||
# we mostly just need it for some specific
|
||||
# operation after which we go back to tmp
|
||||
self.save_to_cache()
|
||||
|
||||
self.spots.append(self.secret)
|
||||
@ -197,12 +221,6 @@ class SensitiveValues:
|
||||
# - will be called after 2 minutes of idle keypad
|
||||
blank_object(cls._cache_secret)
|
||||
cls._cache_secret = None
|
||||
|
||||
for _,raw,node in cls._cache.values():
|
||||
blank_object(raw)
|
||||
blank_object(node)
|
||||
|
||||
cls._cache.clear()
|
||||
cls._cache_used = None
|
||||
|
||||
def save_to_cache(self):
|
||||
@ -212,7 +230,6 @@ class SensitiveValues:
|
||||
else:
|
||||
assert SensitiveValues._cache_secret == self.secret
|
||||
|
||||
SensitiveValues._cache[self._bip39pw] = ( self.mode, bytearray(self.raw), self.node.copy() )
|
||||
SensitiveValues._cache_used = utime.ticks_ms()
|
||||
|
||||
call_later_ms(CACHE_CHECK_RATE, self.cache_check)
|
||||
@ -290,14 +307,8 @@ class SensitiveValues:
|
||||
xfp = swab32(self.node.my_fp())
|
||||
xpub = self.chain.serialize_public(self.node)
|
||||
|
||||
if self._bip39pw:
|
||||
settings.put_volatile('xfp', xfp)
|
||||
settings.put_volatile('xpub', xpub)
|
||||
else:
|
||||
settings.overrides.clear()
|
||||
settings.put('xfp', xfp)
|
||||
settings.put('xpub', xpub)
|
||||
|
||||
settings.put('xfp', xfp)
|
||||
settings.put('xpub', xpub)
|
||||
settings.put('chain', self.chain.ctype)
|
||||
|
||||
# calc num words in seed, or zero
|
||||
@ -368,4 +379,13 @@ 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
|
||||
|
||||
@ -7,8 +7,7 @@
|
||||
# - replaces old "duress wallet" and "brickme" features
|
||||
# - changes require knowledge of real PIN code (it is checked)
|
||||
#
|
||||
import version, uctypes, errno, ngu, sys, ckcc, stash, bip39
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
import uctypes, errno, ngu, sys, stash, bip39
|
||||
from menu import MenuSystem, MenuItem
|
||||
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_enter_number, the_ux, ux_aborted
|
||||
from stash import SecretStash
|
||||
@ -437,6 +436,8 @@ class TrickPinMenu(MenuSystem):
|
||||
async def done_picking(self, item, parents):
|
||||
# done picking/drilling down tree.
|
||||
# - shows point-form summary and gets confirmation
|
||||
from glob import dis
|
||||
|
||||
wants_wipe = (self.WillWipeMenu in parents)
|
||||
self.WillWipeMenu = None # memory free
|
||||
|
||||
@ -477,10 +478,11 @@ class TrickPinMenu(MenuSystem):
|
||||
if ch != 'y': return
|
||||
|
||||
# save it
|
||||
dis.fullscreen("Saving...")
|
||||
try:
|
||||
bpin = self.proposed_pin.encode()
|
||||
b, slot = tp.update_slot(bpin, new=True, tc_flags=flags,
|
||||
tc_arg=tc_arg, secret=new_secret)
|
||||
tp.update_slot(bpin, new=True, tc_flags=flags,
|
||||
tc_arg=tc_arg, secret=new_secret)
|
||||
await ux_dramatic_pause("Saved.", 1)
|
||||
except BaseException as exc:
|
||||
sys.print_exception(exc)
|
||||
@ -512,7 +514,7 @@ class TrickPinMenu(MenuSystem):
|
||||
have.remove(existing_pin)
|
||||
|
||||
if (new_pin == self.current_pin) or (new_pin in have):
|
||||
await ux_show_story("That PIN (%s) is already in use. All PIN codes must be unique." % new_pin);
|
||||
await ux_show_story("That PIN (%s) is already in use. All PIN codes must be unique." % new_pin)
|
||||
return
|
||||
|
||||
# check if we "forgot" this pin, and read it back if we did.
|
||||
@ -555,7 +557,6 @@ class TrickPinMenu(MenuSystem):
|
||||
StoryMenuItem('BIP-85 Wallet #2', b85, arg=dbase+2, flags=TC_WORD_WALLET),
|
||||
StoryMenuItem('BIP-85 Wallet #3', b85, arg=dbase+3, flags=TC_WORD_WALLET),
|
||||
StoryMenuItem('Legacy Wallet', "Uses duress wallet created on Mk3 Coldcard, using a fixed derivation.\n\nRecommended only for existing UTXO compatibility.", flags=TC_XPRV_WALLET),
|
||||
StoryMenuItem('Blank Coldcard', "Look and act like a freshly wiped Coldcard", flags=TC_BLANK_WALLET),
|
||||
]
|
||||
self.WillWipeMenu = MenuSystem([
|
||||
# xxxxxxxxxxxxxxxx
|
||||
@ -586,7 +587,7 @@ class TrickPinMenu(MenuSystem):
|
||||
StoryMenuItem('Wipe Seed', "Wipe the seed and maybe do more. See next menu.",
|
||||
menu=self.WillWipeMenu),
|
||||
StoryMenuItem('Duress Wallet', "Goes directly to a specific duress wallet. No side effects.", menu=DuressOptions),
|
||||
StoryMenuItem('Login Countdown', "Pretends a login countdown timer (%s) is in effect but wipes seed first. Resets system at end of countdown or bricks it." % lgto_map[def_to].strip(),
|
||||
StoryMenuItem('Login Countdown', "Pretends a login countdown timer (%s) is in effect. Can wipe seed or brick system or do nothing." % lgto_map[def_to].strip(),
|
||||
menu=countdown_menu),
|
||||
StoryMenuItem('Look Blank', "Look and act like a freshly- wiped Coldcard but don't affect actual seed.", flags=TC_BLANK_WALLET),
|
||||
StoryMenuItem('Just Reboot', "Reboot when this PIN is entered. Doesn't do anything else.", flags=TC_REBOOT),
|
||||
@ -634,7 +635,6 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
|
||||
arg=num, flags=TC_WIPE|TC_FAKE_OUT),
|
||||
StoryMenuItem('Brick Self', "Become a brick instantly and forever.", 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('Look Blank', "Look and act like a freshly- wiped Coldcard but don't affect actual seed.", arg=num, flags=TC_BLANK_WALLET),
|
||||
StoryMenuItem('Just Reboot', "Reboot when this happens. Doesn't do anything else.", arg=num, flags=TC_REBOOT),
|
||||
])
|
||||
|
||||
@ -750,25 +750,24 @@ normal operation.''')
|
||||
# TC_BLANK_WALLET here would be nice, but no support working w/ fake empty secret
|
||||
|
||||
# emulate stash.py encoding
|
||||
name = 'Duress #%d' % (arg % 10)
|
||||
if flags & TC_XPRV_WALLET:
|
||||
encoded = b'\x01' + slot.xdata[0:64]
|
||||
name = 'Mk3 Duress'
|
||||
elif flags & TC_WORD_WALLET and (arg // 1000 == 1):
|
||||
encoded = b'\x82' + slot.xdata[0:32]
|
||||
elif flags & TC_WORD_WALLET and (arg // 1000 == 2):
|
||||
encoded = b'\x80' + slot.xdata[0:16]
|
||||
else:
|
||||
raise ValueError('f=0x%x a=%d' % (flags, arg))
|
||||
raise ValueError #('f=0x%x a=%d' % (flags, arg))
|
||||
|
||||
from glob import dis
|
||||
from seed import set_ephemeral_seed
|
||||
from actions import goto_top_menu
|
||||
|
||||
# switch over to new secret!
|
||||
dis.fullscreen("Applying...")
|
||||
pa.tmp_secret(encoded)
|
||||
tp.reload()
|
||||
|
||||
await ux_show_story("New master key in effect until next power down.")
|
||||
|
||||
from actions import goto_top_menu
|
||||
await set_ephemeral_seed(encoded, meta=name)
|
||||
goto_top_menu()
|
||||
|
||||
async def countdown_details(self, m, l, item):
|
||||
@ -801,7 +800,7 @@ normal operation.''')
|
||||
va = lgto_va[1:]
|
||||
|
||||
def set_it(idx, text):
|
||||
new_val = va[idx+1]
|
||||
new_val = va[idx]
|
||||
# save it
|
||||
try:
|
||||
b, slot = tp.update_slot(pin.encode(), tc_flags=flags, tc_arg=new_val)
|
||||
@ -905,10 +904,14 @@ class StoryMenuItem(MenuItem):
|
||||
super().__init__(label, **kws)
|
||||
|
||||
async def activate(self, menu, idx):
|
||||
from glob import dis
|
||||
|
||||
ch = await ux_show_story(self.story)
|
||||
if ch == 'x':
|
||||
return
|
||||
|
||||
dis.fullscreen('Wait...')
|
||||
|
||||
if getattr(self, 'next_menu', None):
|
||||
# drill down more
|
||||
return await super().activate(menu, idx)
|
||||
|
||||
233
shared/usb.py
233
shared/usb.py
@ -10,7 +10,7 @@ from public_constants import STXN_FLAGS_MASK
|
||||
from ustruct import pack, unpack_from
|
||||
from ckcc import watchpoint, is_simulator
|
||||
from utils import problem_file_line, call_later_ms
|
||||
from version import has_fatram, is_devmode, has_psram, MAX_TXN_LEN, MAX_UPLOAD_LEN
|
||||
from version import is_devmode, MAX_TXN_LEN, MAX_UPLOAD_LEN
|
||||
from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled
|
||||
|
||||
# Unofficial, unpermissioned... numbers
|
||||
@ -53,7 +53,7 @@ HSM_WHITELIST = frozenset({
|
||||
'blkc', 'hsts', # report status values
|
||||
'stok', 'smok', # completion check: sign txn or msg
|
||||
'xpub', 'msck', # quick status checks
|
||||
'p2sh', 'show', # limited by HSM policy
|
||||
'p2sh', 'show', 'msas', # limited by HSM policy
|
||||
'user', # auth HSM user, other user cmds not allowed
|
||||
'gslr', # read storage locker; hsm mode only, limited usage
|
||||
})
|
||||
@ -90,7 +90,7 @@ def enable_usb():
|
||||
else:
|
||||
# subclass, protocol, max packet length, polling interval, report descriptor
|
||||
hid_info = (0x0, 0x0, 64, 5, hid_descp)
|
||||
classes = 'VCP+HID' if not has_psram else 'VCP+MSC+HID'
|
||||
classes = 'VCP+MSC+HID'
|
||||
pyb.usb_mode(classes, vid=COINKITE_VID, pid=CKCC_PID, hid=hid_info)
|
||||
|
||||
global handler
|
||||
@ -129,8 +129,7 @@ class USBHandler:
|
||||
# handle simulator
|
||||
self.blockable = getattr(self.dev, 'pipe', self.dev)
|
||||
|
||||
from sram2 import usb_buf
|
||||
self.msg = usb_buf
|
||||
self.msg = bytearray(2048+12)
|
||||
assert len(self.msg) == MAX_MSG_LEN
|
||||
|
||||
self.encrypted_req = False
|
||||
@ -478,7 +477,7 @@ class USBHandler:
|
||||
file_len, file_sha = unpack_from('<I32s', args)
|
||||
if file_sha != self.file_checksum.digest():
|
||||
return b'err_Checksum'
|
||||
assert 100 < file_len <= (20*200), "badlen"
|
||||
assert 100 < file_len <= (32*200), "badlen"
|
||||
|
||||
# Start an UX interaction, return immediately here
|
||||
from auth import maybe_enroll_xpub
|
||||
@ -486,6 +485,82 @@ class USBHandler:
|
||||
|
||||
return None
|
||||
|
||||
if cmd == 'mins':
|
||||
# Enroll new xpubkey to be involved in miniscript.
|
||||
# - descriptor text config file must already be uploaded
|
||||
|
||||
file_len, file_sha = unpack_from('<I32s', args)
|
||||
if file_sha != self.file_checksum.digest():
|
||||
return b'err_Checksum'
|
||||
assert 100 < file_len <= (100*200), "badlen"
|
||||
|
||||
# Start an UX interaction, return immediately here
|
||||
from auth import maybe_enroll_xpub
|
||||
maybe_enroll_xpub(sf_len=file_len, ux_reset=True, miniscript=True)
|
||||
|
||||
return None
|
||||
|
||||
if cmd == "msls":
|
||||
# list all registered miniscript wallet names
|
||||
assert self.encrypted_req, 'must encrypt'
|
||||
from miniscript import MiniScriptWallet
|
||||
wallets = [w.name for w in MiniScriptWallet.iter_wallets()]
|
||||
import ujson
|
||||
return b'asci' + ujson.dumps(wallets)
|
||||
|
||||
if cmd == "msdl":
|
||||
# delete miniscript wallet by its name (unique id)
|
||||
assert self.encrypted_req, 'must encrypt'
|
||||
from miniscript import MiniScriptWallet
|
||||
|
||||
assert len(args) < 40, "len args"
|
||||
for w in MiniScriptWallet.iter_wallets():
|
||||
if w.name == str(args, 'ascii'):
|
||||
break
|
||||
else:
|
||||
return b'err_Miniscript wallet not found'
|
||||
|
||||
from auth import maybe_delete_miniscript
|
||||
maybe_delete_miniscript(w)
|
||||
return None
|
||||
|
||||
if cmd == "msgt":
|
||||
# takes name and returns descriptor + name json
|
||||
assert self.encrypted_req, 'must encrypt'
|
||||
from miniscript import MiniScriptWallet
|
||||
|
||||
assert len(args) < 40, "len args"
|
||||
for w in MiniScriptWallet.iter_wallets():
|
||||
if w.name == str(args, 'ascii'):
|
||||
import ujson
|
||||
return b'asci' + ujson.dumps({"name": w.name, "desc": w.to_string()})
|
||||
return b'err_Miniscript wallet not found'
|
||||
|
||||
if cmd == "msas":
|
||||
# get miniscript address based on int/ext index
|
||||
assert self.encrypted_req, 'must encrypt'
|
||||
if hsm_active and not hsm_active.approve_address_share(miniscript=True):
|
||||
raise HSMDenied
|
||||
|
||||
from miniscript import MiniScriptWallet
|
||||
|
||||
change, idx, = unpack_from('<II', args)
|
||||
assert change in (0,1), "change not bool"
|
||||
assert 0 <= idx < (2 ** 31), "child idx"
|
||||
|
||||
name = args[8:]
|
||||
|
||||
msc = None
|
||||
for w in MiniScriptWallet.iter_wallets():
|
||||
if w.name == str(name, 'ascii'):
|
||||
msc = w
|
||||
break
|
||||
else:
|
||||
return b'err_Miniscript wallet not found'
|
||||
|
||||
from auth import start_show_miniscript_address
|
||||
return b'asci' + start_show_miniscript_address(msc, change, idx)
|
||||
|
||||
if cmd == 'msck':
|
||||
# Quick check to test if we have a wallet already installed.
|
||||
from multisig import MultisigWallet
|
||||
@ -547,10 +622,11 @@ class USBHandler:
|
||||
if cmd == 'pass':
|
||||
# bip39 passphrase provided, maybe use it if authorized
|
||||
assert self.encrypted_req, 'must encrypt'
|
||||
from flow import bip39_passphrase_active
|
||||
from auth import start_bip39_passphrase
|
||||
from glob import settings
|
||||
|
||||
assert settings.get('words', True), 'no seed'
|
||||
assert bip39_passphrase_active(), 'no seed'
|
||||
assert len(args) < 400, 'too long'
|
||||
pw = str(args, 'utf8')
|
||||
assert len(pw) < 100, 'too long'
|
||||
@ -571,66 +647,64 @@ class USBHandler:
|
||||
if cmd == 'bagi':
|
||||
return self.handle_bag_number(args)
|
||||
|
||||
if has_fatram:
|
||||
# HSM and user-related features only supported on larger-memory Mk3
|
||||
# HSM and user-related features only supported on larger-memory Mk3
|
||||
|
||||
if cmd == 'hsms':
|
||||
# HSM mode "start" -- requires user approval
|
||||
if args:
|
||||
file_len, file_sha = unpack_from('<I32s', args)
|
||||
if file_sha != self.file_checksum.digest():
|
||||
return b'err_Checksum'
|
||||
assert 2 <= file_len <= (200*1000), "badlen"
|
||||
else:
|
||||
file_len = 0
|
||||
if cmd == 'hsms':
|
||||
# HSM mode "start" -- requires user approval
|
||||
if args:
|
||||
file_len, file_sha = unpack_from('<I32s', args)
|
||||
if file_sha != self.file_checksum.digest():
|
||||
return b'err_Checksum'
|
||||
assert 2 <= file_len <= (200*1000), "badlen"
|
||||
else:
|
||||
file_len = 0
|
||||
|
||||
# Start an UX interaction but return (mostly) immediately here
|
||||
from hsm_ux import start_hsm_approval
|
||||
await start_hsm_approval(sf_len=file_len, usb_mode=True)
|
||||
# Start an UX interaction but return (mostly) immediately here
|
||||
from hsm_ux import start_hsm_approval
|
||||
await start_hsm_approval(sf_len=file_len, usb_mode=True)
|
||||
|
||||
return None
|
||||
|
||||
if cmd == 'hsts':
|
||||
# can always query HSM mode
|
||||
from hsm import hsm_status_report
|
||||
import ujson
|
||||
return b'asci' + ujson.dumps(hsm_status_report())
|
||||
|
||||
if cmd == 'gslr':
|
||||
# get the value held in the Storage Locker
|
||||
assert hsm_active, 'need hsm'
|
||||
return b'biny' + hsm_active.fetch_storage_locker()
|
||||
|
||||
# User Mgmt
|
||||
if cmd == 'nwur': # new user
|
||||
from users import Users
|
||||
auth_mode, ul, sl = unpack_from('<BBB', args)
|
||||
username = bytes(args[3:3+ul]).decode('ascii')
|
||||
secret = bytes(args[3+ul:3+ul+sl])
|
||||
|
||||
return b'asci' + Users.create(username, auth_mode, secret).encode('ascii')
|
||||
|
||||
if cmd == 'rmur': # delete user
|
||||
from users import Users
|
||||
ul, = unpack_from('<B', args)
|
||||
username = bytes(args[1:1+ul]).decode('ascii')
|
||||
|
||||
return Users.delete(username)
|
||||
|
||||
if cmd == 'user': # auth user (HSM mode)
|
||||
from users import Users
|
||||
totp_time, ul, tl = unpack_from('<IBB', args)
|
||||
username = bytes(args[6:6+ul]).decode('ascii')
|
||||
token = bytes(args[6+ul:6+ul+tl])
|
||||
|
||||
if hsm_active:
|
||||
# just queues these details, can't be checked until PSBT on-hand
|
||||
hsm_active.usb_auth_user(username, token, totp_time)
|
||||
return None
|
||||
|
||||
if cmd == 'hsts':
|
||||
# can always query HSM mode
|
||||
from hsm import hsm_status_report
|
||||
import ujson
|
||||
return b'asci' + ujson.dumps(hsm_status_report())
|
||||
|
||||
if cmd == 'gslr':
|
||||
# get the value held in the Storage Locker
|
||||
assert hsm_active, 'need hsm'
|
||||
return b'biny' + hsm_active.fetch_storage_locker()
|
||||
|
||||
|
||||
# User Mgmt
|
||||
if cmd == 'nwur': # new user
|
||||
from users import Users
|
||||
auth_mode, ul, sl = unpack_from('<BBB', args)
|
||||
username = bytes(args[3:3+ul]).decode('ascii')
|
||||
secret = bytes(args[3+ul:3+ul+sl])
|
||||
|
||||
return b'asci' + Users.create(username, auth_mode, secret).encode('ascii')
|
||||
|
||||
if cmd == 'rmur': # delete user
|
||||
from users import Users
|
||||
ul, = unpack_from('<B', args)
|
||||
username = bytes(args[1:1+ul]).decode('ascii')
|
||||
|
||||
return Users.delete(username)
|
||||
|
||||
if cmd == 'user': # auth user (HSM mode)
|
||||
from users import Users
|
||||
totp_time, ul, tl = unpack_from('<IBB', args)
|
||||
username = bytes(args[6:6+ul]).decode('ascii')
|
||||
token = bytes(args[6+ul:6+ul+tl])
|
||||
|
||||
if hsm_active:
|
||||
# just queues these details, can't be checked until PSBT on-hand
|
||||
hsm_active.usb_auth_user(username, token, totp_time)
|
||||
return None
|
||||
else:
|
||||
# dryrun/testing purposes: validate only, doesn't unlock nothing
|
||||
return b'asci' + Users.auth_okay(username, token, totp_time).encode('ascii')
|
||||
else:
|
||||
# dryrun/testing purposes: validate only, doesn't unlock nothing
|
||||
return b'asci' + Users.auth_okay(username, token, totp_time).encode('ascii')
|
||||
|
||||
#print("USB garbage: %s +[%d]" % (cmd, len(args)))
|
||||
|
||||
@ -722,22 +796,15 @@ class USBHandler:
|
||||
|
||||
pos = (MAX_TXN_LEN * file_number) + offset
|
||||
|
||||
if has_psram:
|
||||
from glob import PSRAM
|
||||
PSRAM.read(pos, buf)
|
||||
else:
|
||||
from sflash import SF
|
||||
SF.read(pos, buf)
|
||||
from glob import PSRAM
|
||||
PSRAM.read(pos, buf)
|
||||
|
||||
self.file_checksum.update(buf)
|
||||
|
||||
return resp
|
||||
|
||||
async def handle_upload(self, offset, total_size, data):
|
||||
if has_psram:
|
||||
from glob import PSRAM
|
||||
else:
|
||||
from sflash import SF
|
||||
from glob import PSRAM
|
||||
from glob import dis, hsm_active
|
||||
from utils import check_firmware_hdr
|
||||
from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC
|
||||
@ -760,15 +827,6 @@ class USBHandler:
|
||||
if pos % 4096 == 0:
|
||||
dis.fullscreen("Receiving...", offset/total_size)
|
||||
|
||||
if not has_psram:
|
||||
# erase here
|
||||
SF.sector_erase(pos)
|
||||
|
||||
# expect 10-22 ms delay here
|
||||
await sleep_ms(12)
|
||||
while SF.is_busy():
|
||||
await sleep_ms(2)
|
||||
|
||||
# write up to 256 bytes
|
||||
here = data[pos-offset:pos-offset+256]
|
||||
self.file_checksum.update(here)
|
||||
@ -802,15 +860,8 @@ class USBHandler:
|
||||
# pretend we wrote it, so ckcc-protocol or whatever gives normal feedback
|
||||
return offset
|
||||
|
||||
# write to SPI Flash / PSRAM
|
||||
if has_psram:
|
||||
PSRAM.write(pos, here)
|
||||
else:
|
||||
SF.write(pos, here)
|
||||
|
||||
# full page write: 0.6 to 3ms
|
||||
while SF.is_busy():
|
||||
await sleep_ms(1)
|
||||
# write to PSRAM
|
||||
PSRAM.write(pos, here)
|
||||
|
||||
if offset+len(data) >= total_size and not hsm_active:
|
||||
# probably done
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
# items imported here may be useful to EVAL and EXEC commands, which test cases depend on.
|
||||
import uio, sys, version, nvstore, glob, callgate
|
||||
try:
|
||||
from sflash import SF
|
||||
except: pass
|
||||
try:
|
||||
import sim_display
|
||||
except: pass
|
||||
|
||||
190
shared/utils.py
190
shared/utils.py
@ -2,11 +2,14 @@
|
||||
#
|
||||
# utils.py - Misc utils. My favourite kind of source file.
|
||||
#
|
||||
import gc, sys, ustruct, ngu, chains, ure
|
||||
import gc, sys, ustruct, chains, ure, uos, uio, time, aes256ctr
|
||||
from ubinascii import unhexlify as a2b_hex
|
||||
from ubinascii import hexlify as b2a_hex
|
||||
from ubinascii import a2b_base64, b2a_base64
|
||||
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
|
||||
|
||||
|
||||
B2A = lambda x: str(b2a_hex(x), 'ascii')
|
||||
|
||||
@ -84,7 +87,6 @@ def pop_count(i):
|
||||
|
||||
def get_filesize(fn):
|
||||
# like os.path.getsize()
|
||||
import uos
|
||||
try:
|
||||
return uos.stat(fn)[6]
|
||||
except OSError:
|
||||
@ -177,8 +179,6 @@ def str2xfp(txt):
|
||||
def problem_file_line(exc):
|
||||
# return a string of just the filename.py and line number where
|
||||
# an exception occured. Best used on AssertionError.
|
||||
import uio, sys, ure
|
||||
|
||||
tmp = uio.StringIO()
|
||||
sys.print_exception(exc, tmp)
|
||||
lines = tmp.getvalue().split('\n')[-3:]
|
||||
@ -208,8 +208,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 *' (wildcard)
|
||||
import ure
|
||||
from public_constants import MAX_PATH_DEPTH
|
||||
try:
|
||||
s = str(bin_path, 'ascii').lower()
|
||||
except UnicodeError:
|
||||
@ -240,7 +238,7 @@ def cleanup_deriv_path(bin_path, allow_star=False):
|
||||
# - star or star' can be last only (checked by regex above)
|
||||
assert p == '*' or p == "*'", "bad wildcard"
|
||||
continue
|
||||
if p[-1] == "'":
|
||||
if p[-1] in "'h":
|
||||
p = p[0:-1]
|
||||
try:
|
||||
ip = int(p, 10)
|
||||
@ -266,7 +264,7 @@ def str_to_keypath(xfp, path):
|
||||
if i == 'm': continue
|
||||
if not i: continue # trailing or duplicated slashes
|
||||
|
||||
if i[-1] == "'":
|
||||
if i[-1] in "'h":
|
||||
here = int(i[:-1]) | 0x80000000
|
||||
else:
|
||||
here = int(i)
|
||||
@ -296,6 +294,13 @@ def match_deriv_path(patterns, path):
|
||||
|
||||
return False
|
||||
|
||||
def validate_derivation_path_length(length, allow_master=False):
|
||||
# force them to use a derived key, never the master
|
||||
if not allow_master:
|
||||
assert length >= 4, 'too short key path'
|
||||
assert (length % 4) == 0, 'corrupt key path'
|
||||
assert (length // 4) <= MAX_PATH_DEPTH, 'too deep'
|
||||
|
||||
class DecodeStreamer:
|
||||
def __init__(self):
|
||||
self.runt = bytearray()
|
||||
@ -387,17 +392,11 @@ def clean_shutdown(style=0):
|
||||
settings.save_if_dirty()
|
||||
|
||||
try:
|
||||
from glob import dis
|
||||
from glob import dis, NFC
|
||||
dis.fullscreen("Cleanup...")
|
||||
|
||||
if not version.has_psram:
|
||||
from sflash import SF
|
||||
SF.wipe_most()
|
||||
else:
|
||||
from glob import NFC
|
||||
|
||||
if NFC:
|
||||
uasyncio.run(NFC.wipe(True))
|
||||
if NFC:
|
||||
uasyncio.run(NFC.wipe(True))
|
||||
|
||||
except: pass
|
||||
|
||||
@ -436,9 +435,7 @@ def word_wrap(ln, w):
|
||||
|
||||
def parse_addr_fmt_str(addr_fmt):
|
||||
# accepts strings and also integers if already parsed
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
|
||||
|
||||
if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]:
|
||||
if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC, AF_P2TR]:
|
||||
return addr_fmt
|
||||
|
||||
addr_fmt = addr_fmt.lower()
|
||||
@ -448,9 +445,10 @@ def parse_addr_fmt_str(addr_fmt):
|
||||
return AF_CLASSIC
|
||||
elif addr_fmt == "p2wpkh":
|
||||
return AF_P2WPKH
|
||||
elif addr_fmt == "p2tr":
|
||||
return AF_P2TR
|
||||
else:
|
||||
raise ValueError("Invalid address format: '%s'\n\n"
|
||||
"Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % addr_fmt)
|
||||
raise ValueError("Unsupported address format: '%s'" % addr_fmt)
|
||||
|
||||
|
||||
def parse_extended_key(ln, private=False):
|
||||
@ -479,16 +477,16 @@ def parse_extended_key(ln, private=False):
|
||||
return node, chain, addr_fmt
|
||||
|
||||
|
||||
def import_prompt_builder(title):
|
||||
def import_prompt_builder(title, no_nfc=False):
|
||||
from glob import NFC, VD
|
||||
prompt, escape = None, None
|
||||
if NFC or VD:
|
||||
if (NFC and (not no_nfc)) or VD:
|
||||
prompt = "Press (1) to import %s from SD Card" % title
|
||||
escape = "1"
|
||||
if VD is not None:
|
||||
prompt += ", press (2) to import from Virtual Disk"
|
||||
escape += "2"
|
||||
if NFC is not None:
|
||||
if NFC is not None and not no_nfc:
|
||||
prompt += ", press (3) to import via NFC"
|
||||
escape += "3"
|
||||
prompt += "."
|
||||
@ -526,7 +524,7 @@ def chunk_writer(fd, body):
|
||||
def decrypt_tapsigner_backup(backup_key, data):
|
||||
try:
|
||||
backup_key = a2b_hex(backup_key)
|
||||
decrypt = ngu.aes.CTR(backup_key, bytes(16)) # IV 0
|
||||
decrypt = aes256ctr.new(backup_key, bytes(16)) # IV 0
|
||||
decrypted = decrypt.cipher(data).decode().strip()
|
||||
# format of TAPSIGNER backup is known in advance
|
||||
# extended private key is expected at the beginning of the first line
|
||||
@ -536,4 +534,144 @@ def decrypt_tapsigner_backup(backup_key, data):
|
||||
|
||||
return decrypted.split("\n")
|
||||
|
||||
def addr_fmt_label(addr_fmt):
|
||||
return {
|
||||
AF_CLASSIC: "Classic P2PKH",
|
||||
AF_P2WPKH_P2SH: "P2SH-Segwit",
|
||||
AF_P2WPKH: "Segwit P2WPKH",
|
||||
AF_P2TR: "Taproot P2TR",
|
||||
AF_P2WSH: "Segwit P2WSH",
|
||||
AF_P2WSH_P2SH: "P2SH-P2WSH"
|
||||
}[addr_fmt]
|
||||
|
||||
|
||||
def pad_raw_secret(raw_sec_str):
|
||||
# Chip can hold 72-bytes as a secret
|
||||
# every secret has 0th byte as marker
|
||||
# then secret and padded to zero to AE_SECRET_LEN
|
||||
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)
|
||||
raw[0:len(x)] = x
|
||||
return raw
|
||||
|
||||
def seconds2human_readable(s):
|
||||
days = s // (3600 * 24)
|
||||
hours = s % (3600 * 24) // 3600
|
||||
minutes = (s % 3600) // 60
|
||||
seconds = (s % 3600) % 60
|
||||
msg = []
|
||||
if days:
|
||||
msg.append("%dd" % days)
|
||||
if hours:
|
||||
msg.append("%dh" % hours)
|
||||
if minutes:
|
||||
msg.append("%dm" % minutes)
|
||||
if seconds:
|
||||
msg.append("%ds" % seconds)
|
||||
|
||||
return " ".join(msg)
|
||||
|
||||
def datetime_from_timestamp(ts):
|
||||
gm_t = time.gmtime(0)
|
||||
if gm_t[0] == 1970:
|
||||
# unix
|
||||
epoch_sub = 0
|
||||
elif gm_t[0] == 2000:
|
||||
# stm32
|
||||
epoch_sub = 946684800
|
||||
else:
|
||||
assert False
|
||||
|
||||
return time.gmtime(ts - epoch_sub)
|
||||
|
||||
def datetime_to_str(dt, fmt="%d-%02d-%02d %02d:%02d:%02d"):
|
||||
y, mo, d, h, mi, s = dt[:6]
|
||||
dts = fmt % (y, mo, d, h, mi, s)
|
||||
return dts + " UTC"
|
||||
|
||||
def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False):
|
||||
# Shared code: consider an xpub for inclusion into a wallet
|
||||
# return T if it's our own key and parsed details in form (xfp, deriv, xpub)
|
||||
# - deriv can be None, and in very limited cases can recover derivation path
|
||||
# - could enforce all same depth, and/or all depth >= 1, but
|
||||
# seems like more restrictive than needed, so "m" is allowed
|
||||
import stash
|
||||
from public_constants import AF_P2SH
|
||||
try:
|
||||
# Note: addr fmt detected here via SLIP-132 isn't useful
|
||||
node, chain, _ = parse_extended_key(xpub)
|
||||
except:
|
||||
raise AssertionError('unable to parse xpub')
|
||||
|
||||
try:
|
||||
assert node.privkey() == None # 'no privkeys plz'
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if expect_chain == "XRT":
|
||||
# HACK but there is no difference extended_keys - just bech32 hrp
|
||||
assert chain.ctype == "XTN"
|
||||
else:
|
||||
assert chain.ctype == expect_chain, 'wrong chain'
|
||||
|
||||
depth = node.depth()
|
||||
|
||||
if depth == 1:
|
||||
if not xfp:
|
||||
# allow a shortcut: zero/omit xfp => use observed parent value
|
||||
xfp = swab32(node.parent_fp())
|
||||
else:
|
||||
# generally cannot check fingerprint values, but if we can, do so.
|
||||
if not disable_checks:
|
||||
assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong'
|
||||
|
||||
assert xfp, 'need fingerprint' # happens if bare xpub given
|
||||
|
||||
# In most cases, we cannot verify the derivation path because it's hardened
|
||||
# and we know none of the private keys involved.
|
||||
if depth == 1:
|
||||
# but derivation is implied at depth==1
|
||||
kn, is_hard = node.child_number()
|
||||
if is_hard: kn |= 0x80000000
|
||||
guess = keypath_to_str([kn], skip=0)
|
||||
|
||||
if deriv:
|
||||
if not disable_checks:
|
||||
assert guess == deriv, '%s != %s' % (guess, deriv)
|
||||
else:
|
||||
deriv = guess # reachable? doubt it
|
||||
|
||||
assert deriv, 'empty deriv' # or force to be 'm'?
|
||||
assert deriv[0] == 'm'
|
||||
|
||||
# path length of derivation given needs to match xpub's depth
|
||||
if not disable_checks:
|
||||
p_len = deriv.count('/')
|
||||
assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % (
|
||||
p_len, depth, xfp2str(xfp))
|
||||
|
||||
if xfp == my_xfp:
|
||||
# its supposed to be my key, so I should be able to generate pubkey
|
||||
# - might indicate collision on xfp value between co-signers,
|
||||
# and that's not supported
|
||||
with stash.SensitiveValues() as sv:
|
||||
chk_node = sv.derive_path(deriv)
|
||||
assert node.pubkey() == chk_node.pubkey(), \
|
||||
"[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:])
|
||||
|
||||
# serialize xpub w/ BIP-32 standard now.
|
||||
# - this has effect of stripping SLIP-132 confusion away
|
||||
return xfp == my_xfp, (xfp, deriv, chain.serialize_public(node, AF_P2SH))
|
||||
|
||||
def truncate_address(addr):
|
||||
# Truncates address to width of screen, replacing middle chars
|
||||
# - 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:]
|
||||
|
||||
# EOF
|
||||
|
||||
18
shared/ux.py
18
shared/ux.py
@ -53,6 +53,12 @@ class UserInteraction:
|
||||
old = self.stack.pop()
|
||||
del old
|
||||
|
||||
def parent_of(self, child_ux):
|
||||
for n, x in enumerate(self.stack):
|
||||
if x == child_ux and n:
|
||||
return self.stack[n-1]
|
||||
return None
|
||||
|
||||
# Singleton. User interacts with this "menu" stack.
|
||||
the_ux = UserInteraction()
|
||||
|
||||
@ -167,7 +173,8 @@ class PressRelease:
|
||||
# (using FontSmall)
|
||||
CH_PER_W = const(17)
|
||||
|
||||
async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_escape=False):
|
||||
async def ux_show_story(msg, title=None, escape=None, sensitive=False,
|
||||
strict_escape=False, scrollbar=True):
|
||||
# show a big long string, and wait for XY to continue
|
||||
# - returns character used to get out (X or Y)
|
||||
# - can accept other chars to 'escape' as well.
|
||||
@ -233,7 +240,10 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_es
|
||||
|
||||
y += 13
|
||||
|
||||
dis.scroll_bar(top / len(lines))
|
||||
if scrollbar:
|
||||
# help in cases when last char in a row hidden by scroll bar
|
||||
dis.scroll_bar(top / len(lines))
|
||||
|
||||
dis.show()
|
||||
|
||||
# wait to do something
|
||||
@ -358,9 +368,9 @@ async def show_qr_codes(addrs, is_alnum, start_n):
|
||||
o = QRDisplaySingle(addrs, is_alnum, start_n, sidebar=None)
|
||||
await o.interact_bare()
|
||||
|
||||
async def show_qr_code(data, is_alnum):
|
||||
async def show_qr_code(data, is_alnum, msg=None):
|
||||
from qrs import QRDisplaySingle
|
||||
o = QRDisplaySingle([data], is_alnum)
|
||||
o = QRDisplaySingle([data], is_alnum, sidebar=msg)
|
||||
await o.interact_bare()
|
||||
|
||||
async def ux_enter_bip32_index(prompt, can_cancel=False, unlimited=False):
|
||||
|
||||
@ -21,12 +21,12 @@ def decode_firmware_header(hdr):
|
||||
|
||||
def get_fw_header():
|
||||
# located in our own flash
|
||||
from sigheader import FLASH_HEADER_BASE, FLASH_HEADER_BASE_MK4, FW_HEADER_SIZE
|
||||
from sigheader import FLASH_HEADER_BASE_MK4, FW_HEADER_SIZE
|
||||
import uctypes
|
||||
|
||||
global mk_num
|
||||
|
||||
return uctypes.bytes_at(FLASH_HEADER_BASE_MK4 if mk_num == 4 else FLASH_HEADER_BASE,
|
||||
return uctypes.bytes_at(FLASH_HEADER_BASE_MK4,
|
||||
FW_HEADER_SIZE)
|
||||
|
||||
def get_mpy_version():
|
||||
@ -58,36 +58,8 @@ def nfc_presence_check():
|
||||
|
||||
def get_is_devmode():
|
||||
# what firmware signing key did we boot with? are we in dev mode?
|
||||
|
||||
if mk_num == 4:
|
||||
# mk4: are we built differently?
|
||||
import ckcc
|
||||
return ckcc.is_debug_build()
|
||||
|
||||
from sigheader import RAM_HEADER_BASE, FWH_PK_NUM_OFFSET
|
||||
import stm
|
||||
|
||||
# Important? Use the RAM version of this, not flash version!
|
||||
kn = stm.mem32[RAM_HEADER_BASE + FWH_PK_NUM_OFFSET]
|
||||
|
||||
# For now, all keys are "production" except number zero, which will be made public
|
||||
# - some other keys may be de-authorized and so on in the future
|
||||
is_devmode = (kn == 0)
|
||||
|
||||
return is_devmode
|
||||
|
||||
|
||||
def is_fresh_version():
|
||||
# Did we just boot into a new firmware for the first time?
|
||||
# - mk4+ does not use this approach, light will be solid green during upgrade
|
||||
if mk_num >= 4: return False
|
||||
|
||||
from sigheader import RAM_BOOT_FLAGS, RBF_FRESH_VERSION
|
||||
import stm
|
||||
|
||||
flags = stm.mem32[RAM_BOOT_FLAGS]
|
||||
|
||||
return bool(flags & RBF_FRESH_VERSION)
|
||||
import ckcc
|
||||
return ckcc.is_debug_build()
|
||||
|
||||
|
||||
def serial_number():
|
||||
@ -102,49 +74,25 @@ def serial_number():
|
||||
|
||||
def probe_system():
|
||||
# run-once code to determine what hardware we are running on
|
||||
global hw_label, has_608, has_fatram, is_factory_mode, is_devmode, has_psram
|
||||
global has_se2, mk_num, has_nfc
|
||||
global hw_label, has_608, is_factory_mode
|
||||
global mk_num, has_nfc, is_devmode, is_edge
|
||||
global MAX_UPLOAD_LEN, MAX_TXN_LEN
|
||||
|
||||
from sigheader import RAM_BOOT_FLAGS, RBF_FACTORY_MODE
|
||||
import ckcc, callgate, stm
|
||||
from machine import Pin
|
||||
import ckcc, callgate
|
||||
|
||||
# NOTE: mk1 not supported anymore.
|
||||
# PA10 is pulled-down in Mark2, open in previous revs
|
||||
#mark2 = (Pin('MARK2', Pin.IN, pull=Pin.PULL_UP).value() == 0)
|
||||
|
||||
hw_label = 'mk2'
|
||||
has_fatram = False
|
||||
has_psram = False
|
||||
hw_label = 'mk4'
|
||||
has_608 = True
|
||||
has_se2 = False
|
||||
has_nfc = False # hardware present; they might not be using it
|
||||
mk_num = 2
|
||||
has_nfc = nfc_presence_check() # hardware present; they might not be using it
|
||||
mk_num = 4
|
||||
|
||||
cpuid = ckcc.get_cpu_id()
|
||||
if cpuid == 0x461: # STM32L496RG6
|
||||
hw_label = 'mk3'
|
||||
has_fatram = True
|
||||
mk_num = 3
|
||||
elif cpuid == 0x470: # STM32L4S5VI
|
||||
hw_label = 'mk4'
|
||||
has_fatram = True
|
||||
has_psram = True
|
||||
has_se2 = True
|
||||
mk_num = 4
|
||||
has_nfc = nfc_presence_check()
|
||||
else:
|
||||
# mark 2
|
||||
has_608 = callgate.has_608()
|
||||
assert cpuid == 0x470 # STM32L4S5VI
|
||||
|
||||
# Boot loader needs to tell us stuff about how we were booted, sometimes:
|
||||
# - did we just install a new version, for example (obsolete in mk4)
|
||||
# - are we running in "factory mode" with flash un-secured?
|
||||
if mk_num < 4:
|
||||
is_factory_mode = bool(stm.mem32[RAM_BOOT_FLAGS] & RBF_FACTORY_MODE)
|
||||
else:
|
||||
is_factory_mode = callgate.get_factory_mode()
|
||||
is_factory_mode = callgate.get_factory_mode()
|
||||
|
||||
bn = callgate.get_bag_number()
|
||||
if bn:
|
||||
@ -154,11 +102,13 @@ def probe_system():
|
||||
# what firmware signing key did we boot with? are we in dev mode?
|
||||
is_devmode = get_is_devmode()
|
||||
|
||||
# newer, edge code in effect?
|
||||
is_edge = (get_mpy_version()[1][-1] == 'X')
|
||||
|
||||
# increase size limits for mk4
|
||||
if has_psram:
|
||||
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
|
||||
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()
|
||||
|
||||
|
||||
125
shared/wallet_base.py
Normal file
125
shared/wallet_base.py
Normal file
@ -0,0 +1,125 @@
|
||||
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
import chains
|
||||
from glob import settings
|
||||
|
||||
|
||||
class WalletOutOfSpace(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BaseWallet:
|
||||
key_name = None
|
||||
|
||||
def __init__(self, chain_type=None):
|
||||
self.storage_idx = -1
|
||||
self.chain_type = chain_type or 'BTC'
|
||||
|
||||
@property
|
||||
def chain(self):
|
||||
return chains.get_chain(self.chain_type)
|
||||
|
||||
@classmethod
|
||||
def none_setup_yet(cls, other_chain=False):
|
||||
return '(none setup yet)' + ("*" if other_chain else "")
|
||||
|
||||
@classmethod
|
||||
def is_correct_chain(cls, o, curr_chain):
|
||||
if o[1] is None:
|
||||
# mainnet
|
||||
ch = "BTC"
|
||||
else:
|
||||
ch = o[1]
|
||||
|
||||
if ch == curr_chain.ctype:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def exists(cls):
|
||||
# are there any wallets defined?
|
||||
exists = False
|
||||
exists_other_chain = False
|
||||
c = chains.current_key_chain()
|
||||
for o in settings.get(cls.key_name, []):
|
||||
if cls.is_correct_chain(o, c):
|
||||
exists = True
|
||||
else:
|
||||
exists_other_chain = True
|
||||
|
||||
return exists, exists_other_chain
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
# return them all, as a generator
|
||||
return cls.iter_wallets()
|
||||
|
||||
@classmethod
|
||||
def iter_wallets(cls):
|
||||
# - 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 cls.is_correct_chain(rec, c):
|
||||
yield cls.deserialize(rec, idx)
|
||||
|
||||
def serialize(self):
|
||||
raise NotImplemented
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, c, idx=-1):
|
||||
raise NotImplemented
|
||||
|
||||
@classmethod
|
||||
def get_by_idx(cls, nth):
|
||||
# instance from index number (used in menu)
|
||||
lst = settings.get(cls.key_name, [])
|
||||
try:
|
||||
obj = lst[nth]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
return cls.deserialize(obj, nth)
|
||||
|
||||
def commit(self):
|
||||
# data to save
|
||||
# - important that this fails immediately when nvram overflows
|
||||
obj = self.serialize()
|
||||
|
||||
v = settings.get(self.key_name, [])
|
||||
orig = v.copy()
|
||||
if not v or self.storage_idx == -1:
|
||||
# create
|
||||
self.storage_idx = len(v)
|
||||
v.append(obj)
|
||||
else:
|
||||
# update in place
|
||||
v[self.storage_idx] = obj
|
||||
|
||||
settings.set(self.key_name, v)
|
||||
|
||||
# save now, rather than in background, so we can recover
|
||||
# from out-of-space situation
|
||||
try:
|
||||
settings.save()
|
||||
except:
|
||||
# back out change; no longer sure of NVRAM state
|
||||
try:
|
||||
settings.set(self.key_name, orig)
|
||||
settings.save()
|
||||
except: pass # give up on recovery
|
||||
|
||||
raise WalletOutOfSpace
|
||||
|
||||
def delete(self):
|
||||
# remove saved entry
|
||||
# - important: not expecting more than one instance of this class in memory
|
||||
assert self.storage_idx >= 0
|
||||
lst = settings.get(self.key_name, [])
|
||||
try:
|
||||
del lst[self.storage_idx]
|
||||
settings.set(self.key_name, lst)
|
||||
settings.save()
|
||||
except IndexError: pass
|
||||
self.storage_idx = -1
|
||||
@ -7,10 +7,11 @@
|
||||
#
|
||||
import stash, ngu, bip39, random
|
||||
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause
|
||||
from seed import word_quiz, WordNestMenu, set_seed_value
|
||||
from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed
|
||||
from glob import settings
|
||||
from actions import goto_top_menu
|
||||
|
||||
|
||||
def xor(*args):
|
||||
# bit-wise xor between all args
|
||||
vlen = len(args[0])
|
||||
@ -132,11 +133,13 @@ class XORWordNestMenu(WordNestMenu):
|
||||
XORWordNestMenu.pop_all()
|
||||
|
||||
num_parts = len(import_xor_parts)
|
||||
seed = xor(*(bip39.a2b_words(w) for w in import_xor_parts))
|
||||
enc_parts = [bip39.a2b_words(w) for w in import_xor_parts]
|
||||
seed = xor(*enc_parts)
|
||||
chk_word = bip39.b2a_words(seed).split(' ')[-1]
|
||||
|
||||
msg = "You've entered %d parts so far.\n\n" % num_parts
|
||||
if num_parts >= 2:
|
||||
chk_word = bip39.b2a_words(seed).split(' ')[-1]
|
||||
|
||||
msg += "If you stop now, the %dth word of the XOR-combined seed phrase\nwill be:\n\n" % self.target_words
|
||||
msg += "%d: %s\n\n" % (self.target_words, chk_word)
|
||||
|
||||
@ -169,8 +172,22 @@ class XORWordNestMenu(WordNestMenu):
|
||||
# update menu contents now that wallet defined
|
||||
goto_top_menu(first_time=True)
|
||||
else:
|
||||
pa.tmp_secret(enc)
|
||||
await ux_show_story("New master key in effect until next power down.")
|
||||
# set as ephemeral seed, maybe save it too
|
||||
# below is super costly as we need to bip32 generate master secret from entropy bytes
|
||||
# only need XFPs for UI
|
||||
# xfps = [
|
||||
# xfp2str(swab32(
|
||||
# stash.SecretStash.decode(stash.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
|
||||
)
|
||||
)
|
||||
goto_top_menu()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ -31,12 +31,13 @@ class FontBase:
|
||||
|
||||
class FontSmall(FontBase):
|
||||
height = 14
|
||||
code_range = range(32, 8627)
|
||||
code_range = range(32, 8943)
|
||||
|
||||
_bboxes = [None, (0, -3, 7, 14, 0), (0, -3, 7, 14, 4), (0, -3, 7,
|
||||
14, 5), (0, -3, 7, 14, 7), (0, -3, 7, 14, 9), (0, -3, 7, 14, 10),
|
||||
(0, -3, 7, 14, 11), (0, -3, 7, 14, 12), (0, -3, 7, 14, 13), (0, -3,
|
||||
7, 14, 14), (0, 0, 8, 8, 8), (0, 0, 11, 9, 18), (0, 0, 14, 10, 20)]
|
||||
7, 14, 14), (0, 0, 8, 8, 8), (0, 0, 11, 8, 16), (0, 0, 11, 9, 18),
|
||||
(0, 0, 14, 10, 20)]
|
||||
|
||||
_code_points = [
|
||||
(range(32, 127), [1, 2, 14, 20, 31, 43, 55, 67, 73, 87, 101, 111, 122,
|
||||
@ -51,6 +52,7 @@ class FontSmall(FontBase):
|
||||
(range(8592, 8593), [1145]), # ←
|
||||
(range(8594, 8595), [1166]), # →
|
||||
(range(8627, 8628), [1187]), # ↳
|
||||
(range(8943, 8944), [1208]), # ⋯
|
||||
]
|
||||
|
||||
_bitmaps = b"""\
|
||||
@ -116,12 +118,13 @@ class FontSmall(FontBase):
|
||||
\x3a\x02\x02\x42\x3c\x07\x00\x00\x00\x00\x7e\x02\x04\x08\x10\x20\x7e\x09\
|
||||
\x06\x08\x08\x08\x08\x08\x30\x08\x08\x08\x08\x08\x06\x08\x10\x10\x10\x10\
|
||||
\x10\x10\x10\x10\x10\x10\x10\x10\x09\x60\x10\x10\x10\x10\x10\x0c\x10\x10\
|
||||
\x10\x10\x10\x60\x03\x00\x00\x32\x4a\x44\x0c\x00\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x00\x03\x80\x03\x80\x03\x80\x00\x00\x0d\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x08\x00\x18\x00\x3f\xf8\x18\x00\x08\x00\x00\x00\x0d\x00\x00\x00\
|
||||
\x00\x00\x00\x00\x00\x00\x20\x00\x30\x3f\xf8\x00\x30\x00\x20\x00\x00\x0d\
|
||||
\x10\x10\x10\x60\x03\x00\x00\x32\x4a\x44\x0d\x00\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x00\x03\x80\x03\x80\x03\x80\x00\x00\x0e\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x08\x00\x18\x00\x3f\xf8\x18\x00\x08\x00\x00\x00\x0e\x00\x00\x00\
|
||||
\x00\x00\x00\x00\x00\x00\x20\x00\x30\x3f\xf8\x00\x30\x00\x20\x00\x00\x0e\
|
||||
\x00\x00\x10\x00\x10\x00\x10\x00\x10\x20\x10\x30\x1f\xf8\x00\x30\x00\x20\
|
||||
\x00\x00\
|
||||
\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2a\xa0\x00\
|
||||
\x00\
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
// (c) Copyright 2020-2022 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
// (c) Copyright 2020-2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
//
|
||||
// AUTO-generated.
|
||||
//
|
||||
// built: 2023-02-03
|
||||
// version: 5.1.0
|
||||
// built: 2023-05-12
|
||||
// version: 6.0.0X
|
||||
//
|
||||
#include <stdint.h>
|
||||
|
||||
// this overrides ports/stm32/fatfs_port.c
|
||||
uint32_t get_fattime(void) {
|
||||
return 0x56432820UL;
|
||||
return 0x56ac3000UL;
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
// (c) Copyright 2020-2022 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
// (c) Copyright 2020-2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
//
|
||||
// AUTO-generated.
|
||||
//
|
||||
// built: 2023-04-07
|
||||
// version: 5.1.2
|
||||
// built: 2024-01-18
|
||||
// version: 6.2.2X
|
||||
//
|
||||
#include <stdint.h>
|
||||
|
||||
// this overrides ports/stm32/fatfs_port.c
|
||||
uint32_t get_fattime(void) {
|
||||
return 0x56872820UL;
|
||||
return 0x58323040UL;
|
||||
}
|
||||
|
||||
@ -14,12 +14,10 @@
|
||||
$(MAKE) DEBUG_BUILD=1 -f MK4-Makefile $(MAKECMDGOALS)
|
||||
|
||||
|
||||
clean clobber rc1:
|
||||
clean clobber:
|
||||
$(MAKE) -f MK4-Makefile $(MAKECMDGOALS)
|
||||
$(MAKE) -f MK3-Makefile $(MAKECMDGOALS)
|
||||
|
||||
release repro:
|
||||
release repro rc1:
|
||||
$(MAKE) -f MK4-Makefile $(MAKECMDGOALS)
|
||||
#NOTYET#$(MAKE) -f MK3-Makefile $(MAKECMDGOALS)
|
||||
|
||||
# EOF
|
||||
|
||||
@ -24,12 +24,13 @@ value = ((today.year - 1980) << 25) | (today.month << 21) | (today.day << 16)
|
||||
|
||||
# only 2second resolution for times, so can only support minor verion up to x.x.5 and hard to see
|
||||
# anyway, let's omit ... worst case, use the date instead
|
||||
h, m, _ = [int(x) for x in version.split('b')[0].split('.')]
|
||||
ver = version.replace("X", "")
|
||||
h, m, _ = [int(x) for x in ver.split('b')[0].split('.')]
|
||||
value |= (h << 11) | (m << 5)
|
||||
|
||||
with open(out_fname, 'wt') as fd:
|
||||
fd.write('''\
|
||||
// (c) Copyright 2020-2022 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
// (c) Copyright 2020-2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
//
|
||||
// AUTO-generated.
|
||||
//
|
||||
|
||||
@ -335,7 +335,7 @@ psram_do_upgrade(const uint8_t *start, uint32_t size)
|
||||
if(dest % FLASH_ERASE_SIZE == 0) {
|
||||
// page erase as we go
|
||||
rv = flash_page_erase(dest);
|
||||
#if 1
|
||||
#if 0
|
||||
if(rv) {
|
||||
puts2("erase rv=");
|
||||
puthex2(rv);
|
||||
@ -347,7 +347,7 @@ psram_do_upgrade(const uint8_t *start, uint32_t size)
|
||||
|
||||
memcpy(&tmp, start+pos, 8);
|
||||
rv = flash_burn(dest, tmp);
|
||||
#if 1
|
||||
#if 0
|
||||
if(rv) {
|
||||
puts2("burn rv=");
|
||||
puthex2(rv);
|
||||
|
||||
@ -102,7 +102,8 @@ checksum_flash(uint8_t fw_digest[32], uint8_t world_digest[32], uint32_t fw_leng
|
||||
const uint8_t *base = (const uint8_t *)BL_FLASH_BASE;
|
||||
checksum_more(&ctx, &total_len, base, ((uint8_t *)MCU_KEYS)-base);
|
||||
|
||||
// Probably-blank area after firmware, and filesystem area
|
||||
// Probably-blank area after firmware, and filesystem area.
|
||||
// Important: firmware images (fw_length) must be aligned with flash erase unit size (4k).
|
||||
const uint8_t *fs = start + fw_length;
|
||||
const uint8_t *last = base + MAIN_FLASH_SIZE;
|
||||
checksum_more(&ctx, &total_len, fs, last-fs);
|
||||
|
||||
@ -28,7 +28,6 @@ if ! touch repro-build.sh ; then
|
||||
git clone /work/src/.git firmware
|
||||
cd firmware/external
|
||||
git submodule update --init
|
||||
cd ../stm32
|
||||
rsync --ignore-missing-args -av /work/src/releases/20*.dfu ../releases
|
||||
fi
|
||||
|
||||
|
||||
@ -72,6 +72,11 @@ relink:
|
||||
dev: dev.dfu
|
||||
ckcc upgrade dev.dfu
|
||||
|
||||
# Requires special bootorm w/ DFU still enabled
|
||||
.PHONY: up-dfu
|
||||
up-dfu: dev.dfu
|
||||
$(PYTHON_DO_DFU) -u dev.dfu
|
||||
|
||||
$(BOARD)/file_time.c: make_filetime.py version.mk
|
||||
./make_filetime.py $(BOARD)/file_time.c $(VERSION_STRING)
|
||||
cp $(BOARD)/file_time.c .
|
||||
@ -215,6 +220,7 @@ size:
|
||||
setup:
|
||||
cd $(MPY_TOP) ; git submodule update --init lib/stm32lib
|
||||
cd ../external/libngu; make min-one-time
|
||||
cd ../external/libngu/libs/bech32; git apply ../../bech32.patch || true
|
||||
cd $(MPY_TOP)/mpy-cross ; make
|
||||
-ln -s $(PORT_TOP) l-port
|
||||
-ln -s $(MPY_TOP) l-mpy
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
# Our version for this release.
|
||||
VERSION_STRING = 5.1.2
|
||||
VERSION_STRING = 6.2.2X
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
#
|
||||
# needs local bitcoind in PATH
|
||||
|
||||
import os, time, uuid, socket, shutil, pytest, tempfile, subprocess, signal
|
||||
import os, time, uuid, socket, shutil, pytest, tempfile, subprocess, signal, base64
|
||||
from authproxy import AuthServiceProxy, JSONRPCException
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ class Bitcoind:
|
||||
self.bitcoind_proc = None
|
||||
self.userpass = None
|
||||
self.supply_wallet = None
|
||||
self.has_bdb = True
|
||||
|
||||
def start(self):
|
||||
|
||||
@ -47,6 +48,9 @@ class Bitcoind:
|
||||
self.bitcoind_proc = subprocess.Popen(
|
||||
[
|
||||
self.bitcoind_path,
|
||||
# needed for newest master
|
||||
# TODO legacy wallet will be deprecated in 26
|
||||
"-deprecatedrpc=create_bdb",
|
||||
"-regtest",
|
||||
f"-datadir={self.datadir}",
|
||||
"-noprinttoconsole",
|
||||
@ -85,7 +89,13 @@ class Bitcoind:
|
||||
assert self.rpc.getblockchaininfo()['chain'] == 'regtest'
|
||||
assert self.rpc.getnetworkinfo()['version'] >= 220000, "we require >= 22.0 of Core"
|
||||
# not descriptors so that we can do dumpwallet
|
||||
self.supply_wallet = self.create_wallet(wallet_name="supply", descriptors=False)
|
||||
try:
|
||||
self.supply_wallet = self.create_wallet(wallet_name="supply", descriptors=False)
|
||||
except JSONRPCException as e:
|
||||
assert "BDB wallet creation is deprecated" in str(e)
|
||||
self.has_bdb = False
|
||||
self.supply_wallet = self.create_wallet(wallet_name="supply", descriptors=True)
|
||||
|
||||
# Make sure there are blocks and coins available
|
||||
self.supply_wallet.generatetoaddress(101, self.supply_wallet.getnewaddress())
|
||||
|
||||
@ -144,17 +154,24 @@ def match_key(bitcoind, set_master_key, reset_seed_words):
|
||||
# bummer: dumpmasterprivkey RPC call was removed!
|
||||
#prv = bitcoind.dumpmasterprivkey()
|
||||
|
||||
from tempfile import mktemp
|
||||
fn = mktemp()
|
||||
bitcoind.supply_wallet.dumpwallet(fn)
|
||||
prv = None
|
||||
# bummer: dumpwallet RPC call was removed does not work with descriptor wallets
|
||||
try:
|
||||
from tempfile import mktemp
|
||||
fn = mktemp()
|
||||
bitcoind.supply_wallet.dumpwallet(fn)
|
||||
prv = None
|
||||
|
||||
for ln in open(fn, 'rt').readlines():
|
||||
if 'extended private masterkey' in ln:
|
||||
assert not prv
|
||||
prv = ln.split(": ", 1)[1].strip()
|
||||
for ln in open(fn, 'rt').readlines():
|
||||
if 'extended private masterkey' in ln:
|
||||
assert not prv
|
||||
prv = ln.split(": ", 1)[1].strip()
|
||||
|
||||
os.unlink(fn)
|
||||
os.unlink(fn)
|
||||
except JSONRPCException as e:
|
||||
print(str(e))
|
||||
assert "Only legacy wallets are supported by this command" in str(e)
|
||||
prv_descs = bitcoind.supply_wallet.listdescriptors(True) # True --> show private
|
||||
prv = prv_descs["descriptors"][0]["desc"].replace("pkh(", "").split("/")[0]
|
||||
|
||||
assert prv.startswith('tprv')
|
||||
|
||||
@ -163,12 +180,33 @@ def match_key(bitcoind, set_master_key, reset_seed_words):
|
||||
yield xfp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finalize_v2_v0_convert(bitcoind):
|
||||
def doit(psbt_obj):
|
||||
# compat wrapper - can be removed after below released
|
||||
# https://github.com/bitcoin/bitcoin/pull/21283 PSBTv2
|
||||
# convert v2 -> v0 if bitcoind does not support PSBTv2
|
||||
# to be able to finalize
|
||||
from authproxy import JSONRPCException
|
||||
try:
|
||||
resp = bitcoind.supply_wallet.finalizepsbt(psbt_obj.as_b64_str())
|
||||
except JSONRPCException as e:
|
||||
assert "Unsupported version number" in e.error["message"]
|
||||
# this version of bitcoind does not support PSBTv2
|
||||
# convert to v0 - needed for finalize
|
||||
resp = bitcoind.supply_wallet.finalizepsbt(
|
||||
base64.b64encode(psbt_obj.to_v0()).decode()
|
||||
)
|
||||
return resp
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def bitcoind_wallet(bitcoind):
|
||||
# Use bitcoind to create a temporary wallet file
|
||||
w_name = 'ckcc-test-wallet-%s' % uuid.uuid4()
|
||||
conn = bitcoind.create_wallet(wallet_name=w_name, disable_private_keys=True, blank=True,
|
||||
passphrase=None, avoid_reuse=False, descriptors=False)
|
||||
passphrase=None, avoid_reuse=False, descriptors=not bitcoind.has_bdb)
|
||||
yield conn
|
||||
|
||||
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
# (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
|
||||
import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, bech32
|
||||
from subprocess import check_output
|
||||
from ckcc.protocol import CCProtocolPacker
|
||||
from helpers import B2A, U2SAT
|
||||
from helpers import B2A, U2SAT, prandom, taptweak
|
||||
from msg import verify_message
|
||||
from api import bitcoind, match_key
|
||||
from api import bitcoind_wallet, bitcoind_d_wallet, bitcoind_d_wallet_w_sk, bitcoind_d_sim_sign, bitcoind_d_sim_watch
|
||||
from api import bitcoind_wallet, bitcoind_d_wallet, bitcoind_d_wallet_w_sk, bitcoind_d_sim_sign
|
||||
from api import bitcoind_d_sim_watch, finalize_v2_v0_convert
|
||||
from binascii import b2a_hex, a2b_hex
|
||||
from pycoin.contrib.segwit_addr import encode as sw_encode
|
||||
from pycoin.encoding import a2b_hashed_base58, hash160
|
||||
from constants import *
|
||||
|
||||
# lock down randomness
|
||||
@ -33,6 +37,10 @@ def pytest_addoption(parser):
|
||||
|
||||
parser.addoption("--ms-danger", action="store_true",
|
||||
default=False, help="Operate with multisig checks off")
|
||||
parser.addoption("--psbt2", action="store_true",
|
||||
default=False, help="fake_txn produces PSBTv2")
|
||||
# to make bitcoind produce psbt v2 one currently needs https://github.com/achow101/bitcoin/tree/psbt2
|
||||
# or wait until https://github.com/bitcoin/bitcoin/pull/21283 merged and released
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def dev(request):
|
||||
@ -194,7 +202,7 @@ def enter_pin(enter_number, need_keypress, cap_screen):
|
||||
def master_xpub(dev):
|
||||
if hasattr(dev.dev, 'pipe'):
|
||||
# this works better against simulator in HSM mode, where the xpub cmd may be disabled
|
||||
return simulator_fixed_xpub
|
||||
return simulator_fixed_tpub
|
||||
|
||||
r = dev.send_recv(CCProtocolPacker.get_xpub('m'), timeout=None, encrypt=1)
|
||||
|
||||
@ -247,7 +255,7 @@ def addr_vs_path(master_xpub):
|
||||
from pycoin.key.BIP32Node import BIP32Node
|
||||
from ckcc_protocol.constants import AF_CLASSIC, AFC_PUBKEY, AF_P2WPKH, AFC_SCRIPT
|
||||
from ckcc_protocol.constants import AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH
|
||||
from bech32 import bech32_decode, convertbits, Encoding
|
||||
from bech32 import bech32_decode, convertbits, Encoding, decode
|
||||
from pycoin.encoding import a2b_hashed_base58, hash160
|
||||
from pycoin.key.BIP32Node import PublicPrivateMismatchError
|
||||
from hashlib import sha256
|
||||
@ -261,12 +269,17 @@ def addr_vs_path(master_xpub):
|
||||
mk._netcode = "BTC"
|
||||
sk = mk.subkey_for_path(path[2:])
|
||||
except PublicPrivateMismatchError:
|
||||
mk = BIP32Node.from_wallet_key(simulator_fixed_xprv)
|
||||
mk = BIP32Node.from_wallet_key(simulator_fixed_tprv)
|
||||
if not testnet:
|
||||
mk._netcode = "BTC"
|
||||
sk = mk.subkey_for_path(path[2:])
|
||||
|
||||
if addr_fmt in {None, AF_CLASSIC}:
|
||||
if addr_fmt == AF_P2TR:
|
||||
tweaked_xonly = taptweak(sk.sec()[1:])
|
||||
decoded = decode(given_addr[:2], given_addr)
|
||||
assert not given_addr.startswith("bcrt") # regtest
|
||||
assert tweaked_xonly == bytes(decoded[1])
|
||||
elif addr_fmt in {None, AF_CLASSIC}:
|
||||
# easy
|
||||
assert sk.address() == given_addr
|
||||
|
||||
@ -337,7 +350,7 @@ def cap_menu(sim_exec):
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def is_ftux_screen(sim_exec):
|
||||
"are we presenting a view from ftux.py"
|
||||
"are we presenting a view from ftux.py??"
|
||||
def doit():
|
||||
rv = sim_exec('from ux import the_ux; RV.write(repr('
|
||||
'type(the_ux.top_of_stack())))')
|
||||
@ -354,15 +367,7 @@ def expect_ftux(cap_menu, cap_story, need_keypress, is_ftux_screen):
|
||||
_, story = cap_story()
|
||||
if not story:
|
||||
break
|
||||
# XXX test more here
|
||||
if 'Enable NFC' in story:
|
||||
need_keypress('x')
|
||||
elif 'Enable USB' in story:
|
||||
need_keypress('y')
|
||||
elif 'Disable USB' in story:
|
||||
need_keypress('x')
|
||||
else:
|
||||
raise ValueError(story)
|
||||
need_keypress('y')
|
||||
|
||||
m = cap_menu()
|
||||
assert m[0] == 'Ready To Sign'
|
||||
@ -448,7 +453,9 @@ def qr_quality_check():
|
||||
fnt = ImageFont.load_default()
|
||||
|
||||
dr = ImageDraw.Draw(rv)
|
||||
mw = int((w*scale) / dr.textsize('M', fnt)[0])
|
||||
left, top, right, bottom = dr.textbbox((0, 0), text='M', font=fnt)
|
||||
size = (right - left, bottom - top)
|
||||
mw = int((w*scale) / size[0])
|
||||
|
||||
for test_name, img in QR_HISTORY:
|
||||
if '[' in test_name:
|
||||
@ -533,9 +540,8 @@ def get_secrets(sim_execfile):
|
||||
assert 'Error' not in resp
|
||||
for ln in resp.split('\n'):
|
||||
ln = ln.strip()
|
||||
if '#' in ln:
|
||||
ln = ln[0:ln.index('#')]
|
||||
if not ln: continue
|
||||
if ln[0] == '#': continue
|
||||
|
||||
assert ' = ' in ln
|
||||
n, v = ln.split(' = ', 1)
|
||||
@ -544,6 +550,12 @@ def get_secrets(sim_execfile):
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def clear_miniscript(unit_test):
|
||||
def doit():
|
||||
unit_test('devtest/wipe_miniscript.py')
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def goto_home(cap_menu, need_keypress, pick_menu_item):
|
||||
|
||||
@ -563,6 +575,9 @@ def goto_home(cap_menu, need_keypress, pick_menu_item):
|
||||
|
||||
if m[0] in { 'New Seed Words', 'Ready To Sign'}:
|
||||
break
|
||||
if len(m) > 1 and (m[1] == "Ready To Sign") and (m[0][0] == "["):
|
||||
# ephemeral has XFP as first menu item
|
||||
break
|
||||
else:
|
||||
raise pytest.fail("trapped in a menu")
|
||||
|
||||
@ -666,6 +681,24 @@ def open_microsd(simulator, microsd_path):
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def settings_path(simulator):
|
||||
# open a file from the simulated microsd
|
||||
|
||||
def doit(fn):
|
||||
# could use: ckcc.get_sim_root_dirs() here
|
||||
return '../unix/work/settings/' + fn
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def settings_slots(settings_path):
|
||||
def doit():
|
||||
return [fn
|
||||
for fn in os.listdir(settings_path(""))
|
||||
if fn.endswith(".aes")]
|
||||
return doit
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def set_master_key(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
# load simulator w/ a specific bip32 master key
|
||||
@ -691,7 +724,7 @@ def set_master_key(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
reset_seed_words()
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def set_xfp(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
def set_xfp(sim_exec):
|
||||
# set the XFP, without really knowing the private keys
|
||||
# - won't be able to sign, but should accept PSBT for signing
|
||||
|
||||
@ -701,11 +734,12 @@ def set_xfp(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
import struct
|
||||
need_xfp, = struct.unpack("<I", a2b_hex(xfp))
|
||||
|
||||
sim_exec('from main import settings; settings.put_volatile("xfp", 0x%x);' % need_xfp)
|
||||
sim_exec('from main import settings; settings.set("xfp", 0x%x);' % need_xfp)
|
||||
|
||||
yield doit
|
||||
|
||||
sim_exec('from main import settings; settings.overrides.clear();')
|
||||
sim_exec('from main import settings; settings.set("xfp", 0x%x);' % simulator_fixed_xfp)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def set_encoded_secret(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
@ -760,8 +794,8 @@ def set_seed_words(sim_exec, sim_execfile, simulator, reset_seed_words):
|
||||
# load simulator w/ a specific bip32 master key
|
||||
|
||||
def doit(words):
|
||||
|
||||
sim_exec('import main; main.WORDS = %r; ' % words.split())
|
||||
cmd = 'import main; main.WORDS = %r;' % words.split()
|
||||
sim_exec(cmd)
|
||||
rv = sim_execfile('devtest/set_seed.py')
|
||||
if rv: pytest.fail(rv)
|
||||
|
||||
@ -783,8 +817,8 @@ def reset_seed_words(sim_exec, sim_execfile, simulator):
|
||||
|
||||
def doit():
|
||||
words = simulator_fixed_words
|
||||
|
||||
sim_exec('import main; main.WORDS = %r; ' % words.split())
|
||||
cmd = 'import main; main.WORDS = %r;' % words.split()
|
||||
sim_exec(cmd)
|
||||
rv = sim_execfile('devtest/set_seed.py')
|
||||
if rv: pytest.fail(rv)
|
||||
|
||||
@ -819,6 +853,17 @@ def settings_get(sim_exec):
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def master_settings_get(sim_exec):
|
||||
|
||||
def doit(key):
|
||||
cmd = f"RV.write(repr(settings.master_get('{key}')))"
|
||||
resp = sim_exec(cmd)
|
||||
assert 'Traceback' not in resp, resp
|
||||
return eval(resp)
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_remove(sim_exec):
|
||||
|
||||
@ -1215,7 +1260,7 @@ def end_sign(dev, need_keypress):
|
||||
else:
|
||||
done = None
|
||||
while done == None:
|
||||
time.sleep(0.00)
|
||||
time.sleep(0.050)
|
||||
done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
|
||||
|
||||
assert len(done) == 2
|
||||
@ -1375,6 +1420,9 @@ def nfc_read(request, only_mk4):
|
||||
def nfc_write(request, only_mk4):
|
||||
# WRITE data into NFC "chip"
|
||||
def doit_usb(ccfile):
|
||||
from ckcc.constants import MAX_MSG_LEN
|
||||
if len(ccfile) >= MAX_MSG_LEN:
|
||||
pytest.xfail("MAX_MSG_LEN")
|
||||
sim_exec = request.getfixturevalue('sim_exec')
|
||||
need_keypress = request.getfixturevalue('need_keypress')
|
||||
rv = sim_exec('list(glob.NFC.big_write(%r))' % ccfile)
|
||||
@ -1441,7 +1489,7 @@ def nfc_block4rf(sim_eval):
|
||||
for i in range(timeout*4):
|
||||
rv = sim_eval('glob.NFC.rf_on')
|
||||
if rv: break
|
||||
sleep(0.250)
|
||||
time.sleep(.25)
|
||||
else:
|
||||
raise pytest.fail("NFC timeout")
|
||||
|
||||
@ -1547,37 +1595,38 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_
|
||||
load_export_and_verify_signature):
|
||||
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):
|
||||
fpattern=None, skip_query=False):
|
||||
key_map = {
|
||||
"sd": sd_key or "1",
|
||||
"vdisk": vdisk_key or "2",
|
||||
"nfc": nfc_key or "3",
|
||||
}
|
||||
time.sleep(0.2)
|
||||
title, story = cap_story()
|
||||
if way == "sd":
|
||||
if f"({key_map['sd']}) to save {label} file to SD Card" in story:
|
||||
need_keypress(key_map['sd'])
|
||||
if not skip_query:
|
||||
time.sleep(0.2)
|
||||
title, story = cap_story()
|
||||
if way == "sd":
|
||||
if f"({key_map['sd']}) to save {label} file to SD Card" in story:
|
||||
need_keypress(key_map['sd'])
|
||||
|
||||
elif way == "nfc":
|
||||
if f"({key_map['nfc']}) to share via NFC" not in story:
|
||||
pytest.skip("NFC disabled")
|
||||
else:
|
||||
need_keypress(key_map['nfc'])
|
||||
time.sleep(0.2)
|
||||
if is_json:
|
||||
nfc_export = nfc_read_json()
|
||||
elif way == "nfc":
|
||||
if f"({key_map['nfc']}) to share via NFC" not in story:
|
||||
pytest.skip("NFC disabled")
|
||||
else:
|
||||
nfc_export = nfc_read_text()
|
||||
time.sleep(0.3)
|
||||
need_keypress("x") # exit NFC animation
|
||||
return nfc_export
|
||||
else:
|
||||
# virtual disk
|
||||
if f"({key_map['vdisk']}) to save to Virtual Disk" not in story:
|
||||
pytest.skip("Vdisk disabled")
|
||||
need_keypress(key_map['nfc'])
|
||||
time.sleep(0.2)
|
||||
if is_json:
|
||||
nfc_export = nfc_read_json()
|
||||
else:
|
||||
nfc_export = nfc_read_text()
|
||||
time.sleep(0.3)
|
||||
need_keypress("x") # exit NFC animation
|
||||
return nfc_export
|
||||
else:
|
||||
need_keypress(key_map['vdisk'])
|
||||
# virtual disk
|
||||
if f"({key_map['vdisk']}) to save to Virtual Disk" not in story:
|
||||
pytest.skip("Vdisk disabled")
|
||||
else:
|
||||
need_keypress(key_map['vdisk'])
|
||||
|
||||
time.sleep(0.2)
|
||||
title, story = cap_story()
|
||||
@ -1650,11 +1699,150 @@ def tapsigner_encrypted_backup(microsd_path, virtdisk_path):
|
||||
return fname, backup_key_hex, node
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def choose_by_word_length(need_keypress):
|
||||
# for use in seed XOR menu system
|
||||
def doit(num_words):
|
||||
if num_words == 12:
|
||||
need_keypress('1')
|
||||
elif num_words == 18:
|
||||
need_keypress("2")
|
||||
else:
|
||||
need_keypress("y")
|
||||
return doit
|
||||
|
||||
# useful fixtures related to multisig
|
||||
from test_multisig import (import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn,
|
||||
make_ms_address, clear_ms, make_myself_wallet)
|
||||
from test_bip39pw import set_bip39_pw, clear_bip39_pw
|
||||
# workaround: need these fixtures to be global so I can call test from a test
|
||||
from test_se2 import clear_all_tricks, new_trick_pin, new_pin_confirmed, goto_trick_menu, se2_gate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate_address():
|
||||
# Check whether an address is covered by the given subkey
|
||||
def doit(addr, sk):
|
||||
if addr[0] in '1mn':
|
||||
assert addr == sk.address(False)
|
||||
elif addr[0:4] in { 'bc1q', 'tb1q' } or addr[0:6] == "bcrt1q":
|
||||
h20 = sk.hash160()
|
||||
if addr[:4] == "bcrt":
|
||||
hrp = addr[:4]
|
||||
else:
|
||||
hrp = addr[:2]
|
||||
assert addr == sw_encode(hrp, 0, h20)
|
||||
elif addr[0:4] in {'bc1p', 'tb1p'} or addr[0:6] == "bcrt1p":
|
||||
if addr[:4] == "bcrt":
|
||||
hrp = addr[:4]
|
||||
else:
|
||||
hrp = addr[:2]
|
||||
tweaked_xonly = taptweak(sk.sec()[1:])
|
||||
decoded = bech32.decode(hrp, addr)
|
||||
assert tweaked_xonly == bytes(decoded[1])
|
||||
elif addr[0] in '23':
|
||||
h20 = hash160(b'\x00\x14' + sk.hash160())
|
||||
assert h20 == a2b_hashed_base58(addr)[1:]
|
||||
else:
|
||||
raise ValueError(addr)
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verify_backup_file(goto_home, pick_menu_item, cap_story, need_keypress):
|
||||
def doit(fn):
|
||||
# Check on-device verify UX works.
|
||||
goto_home()
|
||||
pick_menu_item('Advanced/Tools')
|
||||
pick_menu_item('Backup')
|
||||
pick_menu_item('Verify Backup')
|
||||
time.sleep(0.1)
|
||||
title, body = cap_story()
|
||||
assert "Select file" in body
|
||||
need_keypress('y')
|
||||
time.sleep(0.1)
|
||||
pick_menu_item(os.path.basename(fn))
|
||||
|
||||
time.sleep(0.1)
|
||||
title, body = cap_story()
|
||||
assert "Backup file CRC checks out okay" in body
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def check_and_decrypt_backup(microsd_path):
|
||||
def doit(fn, passphrase):
|
||||
# List contents using unix tools
|
||||
pn = microsd_path(fn)
|
||||
out = check_output(['7z', 'l', pn], encoding='utf8')
|
||||
xfname, = re.findall('[a-z0-9]{4,30}.txt', out)
|
||||
print(f"Filename inside 7z: {xfname}")
|
||||
assert xfname in out
|
||||
assert 'Method = 7zAES' in out
|
||||
|
||||
xfn_path = microsd_path(xfname)
|
||||
if os.path.exists(xfn_path):
|
||||
os.remove(xfn_path)
|
||||
|
||||
# does decryption; at least for CRC purposes
|
||||
args = ['7z', 'e', '-p' + ' '.join(passphrase), pn, xfname, '-o' + microsd_path("")]
|
||||
out = check_output(args, encoding='utf8')
|
||||
assert "Extracting archive" in out, out
|
||||
assert "Everything is Ok" in out, out
|
||||
|
||||
with open(xfn_path, "r") as f:
|
||||
res = f.read()
|
||||
|
||||
return res
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu,
|
||||
need_keypress, word_menu_entry, get_setting):
|
||||
# restore backup with clear seed as first step
|
||||
def doit(fn, passphrase, avail_settings=None):
|
||||
unit_test('devtest/clear_seed.py')
|
||||
|
||||
m = cap_menu()
|
||||
assert m[0] == 'New Seed Words'
|
||||
pick_menu_item('Import Existing')
|
||||
pick_menu_item('Restore Backup')
|
||||
|
||||
# skip
|
||||
title, body = cap_story()
|
||||
if ('files to pick from' in body) or ("only one file to pick from" in body):
|
||||
need_keypress('y')
|
||||
time.sleep(.01)
|
||||
|
||||
pick_menu_item(fn)
|
||||
|
||||
time.sleep(.1)
|
||||
word_menu_entry(passphrase)
|
||||
title, body = cap_story()
|
||||
assert title == 'Success!'
|
||||
assert 'has been successfully restored' in body
|
||||
|
||||
if avail_settings:
|
||||
for key in avail_settings:
|
||||
assert get_setting(key)
|
||||
|
||||
# avoid simulator reboot; restore normal state
|
||||
unit_test('devtest/abort_ux.py')
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
# useful fixtures
|
||||
from test_backup import backup_system
|
||||
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
|
||||
from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed
|
||||
from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable
|
||||
from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn
|
||||
from test_multisig import make_ms_address, clear_ms, make_myself_wallet
|
||||
from test_miniscript import offer_minsc_import
|
||||
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 enter_complex, pass_word_quiz, word_menu_entry
|
||||
from txn import fake_txn
|
||||
|
||||
# EOF
|
||||
|
||||
@ -4,9 +4,12 @@
|
||||
SIM_PATH = '/tmp/ckcc-simulator.sock'
|
||||
|
||||
# Simulator normally powers up with this 'wallet'
|
||||
simulator_fixed_xprv = "tprv8ZgxMBicQKsPeXJHL3vPPgTAEqQ5P2FD9qDeCQT4Cp1EMY5QkwMPWFxHdxHrxZhhcVRJ2m7BNWTz9Xre68y7mX5vCdMJ5qXMUfnrZ2si2X4"
|
||||
simulator_fixed_tprv = "tprv8ZgxMBicQKsPeXJHL3vPPgTAEqQ5P2FD9qDeCQT4Cp1EMY5QkwMPWFxHdxHrxZhhcVRJ2m7BNWTz9Xre68y7mX5vCdMJ5qXMUfnrZ2si2X4"
|
||||
simulator_fixed_tpub = "tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh"
|
||||
|
||||
simulator_fixed_xpub = "tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh"
|
||||
# same wallet but mainnet BTC
|
||||
simulator_fixed_xprv = "xprv9s21ZrQH143K3i4kfV4tE2qAvhys9WDCpHJXKz2biqWkZwLKma1dzWaqin8CxCKPF3tX2fVRD9tBggJtxvdAxTpKfz8zRUoJZa3S7MtMgwy"
|
||||
simulator_fixed_xpub = "xpub661MyMwAqRbcGC9DmWbtbAmuUjpMYxw4BWE88NSDHB3jSjfUK7KtYJuKa52GbowD3DVLkgsxH9QwPnTx5mjdHykYFEncnmAsNsCTbWzBhA7"
|
||||
|
||||
simulator_fixed_words = "wife shiver author away frog air rough vanish fantasy frozen noodle athlete pioneer citizen symptom firm much faith extend rare axis garment kiwi clarify"
|
||||
|
||||
@ -14,7 +17,7 @@ simulator_fixed_xfp = 0x4369050f
|
||||
|
||||
simulator_serial_number = 'F1F1F1F1F1F1'
|
||||
|
||||
from ckcc_protocol.constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
|
||||
from ckcc_protocol.constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
|
||||
|
||||
unmap_addr_fmt = {
|
||||
'p2sh': AF_P2SH,
|
||||
@ -28,6 +31,7 @@ msg_sign_unmap_addr_fmt = {
|
||||
'p2wpkh': AF_P2WPKH,
|
||||
'p2sh-p2wpkh': AF_P2WPKH_P2SH,
|
||||
'p2wpkh-p2sh': AF_P2WPKH_P2SH,
|
||||
'p2tr': AF_P2TR,
|
||||
}
|
||||
|
||||
addr_fmt_names = {
|
||||
@ -37,14 +41,15 @@ addr_fmt_names = {
|
||||
AF_P2WSH: 'p2wsh',
|
||||
AF_P2WPKH_P2SH: 'p2wpkh-p2sh',
|
||||
AF_P2WSH_P2SH: 'p2wsh-p2sh',
|
||||
AF_P2TR: 'p2tr',
|
||||
}
|
||||
|
||||
|
||||
# all possible addr types, including multisig/scripts
|
||||
ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh']
|
||||
ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh', 'p2tr']
|
||||
|
||||
# single-signer
|
||||
ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh']
|
||||
ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh', 'p2tr']
|
||||
|
||||
# multi signer
|
||||
ADDR_STYLES_MS = ['p2sh', 'p2wsh', 'p2wsh-p2sh']
|
||||
|
||||
1
testing/data/ccbk-start.json
Normal file
1
testing/data/ccbk-start.json
Normal file
@ -0,0 +1 @@
|
||||
{"pubkey": "038e96756bb520bc3fece6c663c61db10cd8c971dfdbf757f1602fa7eed3f83689"}
|
||||
0
testing/data/p2tr-wrong.psbt
Normal file
0
testing/data/p2tr-wrong.psbt
Normal file
BIN
testing/data/pwsave.tmp
Normal file
BIN
testing/data/pwsave.tmp
Normal file
Binary file not shown.
BIN
testing/data/taproot/in_internal_key_len.psbt
Normal file
BIN
testing/data/taproot/in_internal_key_len.psbt
Normal file
Binary file not shown.
1
testing/data/taproot/in_key_pth_sig_len.psbt
Normal file
1
testing/data/taproot/in_key_pth_sig_len.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a075701133f173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc3475000000
|
||||
1
testing/data/taproot/in_key_pth_sig_len1.psbt
Normal file
1
testing/data/taproot/in_key_pth_sig_len1.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757011342173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc34751701aa000000
|
||||
1
testing/data/taproot/in_leaf_script_cb_len.psbt
Normal file
1
testing/data/taproot/in_leaf_script_cb_len.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926315c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e1f80023202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000
|
||||
1
testing/data/taproot/in_leaf_script_cb_len1.psbt
Normal file
1
testing/data/taproot/in_leaf_script_cb_len1.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926115c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e123202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000
|
||||
1
testing/data/taproot/in_script_sig_key_len.psbt
Normal file
1
testing/data/taproot/in_script_sig_key_len.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6924214022cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094089756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb0000
|
||||
1
testing/data/taproot/in_script_sig_sig_len.psbt
Normal file
1
testing/data/taproot/in_script_sig_sig_len.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094289756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb01010000
|
||||
1
testing/data/taproot/in_script_sig_sig_len1.psbt
Normal file
1
testing/data/taproot/in_script_sig_sig_len1.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b093f89756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd430000
|
||||
1
testing/data/taproot/in_tr_deriv_key_len.psbt
Normal file
1
testing/data/taproot/in_tr_deriv_key_len.psbt
Normal file
@ -0,0 +1 @@
|
||||
70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757221602fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa2321900772b2da75600008001000080000000800100000000000000000000
|
||||
468
testing/descriptor.py
Normal file
468
testing/descriptor.py
Normal file
@ -0,0 +1,468 @@
|
||||
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# descriptor.py - Bitcoin Core's descriptors and their specialized checksums.
|
||||
#
|
||||
import struct
|
||||
from binascii import unhexlify as a2b_hex
|
||||
from binascii import hexlify as b2a_hex
|
||||
from constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR
|
||||
|
||||
MULTI_FMT_TO_SCRIPT = {
|
||||
AF_P2SH: "sh(%s)",
|
||||
AF_P2WSH_P2SH: "sh(wsh(%s))",
|
||||
AF_P2WSH: "wsh(%s)",
|
||||
AF_P2TR: "tr(%s)",
|
||||
None: "wsh(%s)",
|
||||
# hack for tests
|
||||
"p2sh": "sh(%s)",
|
||||
"p2sh-p2wsh": "sh(wsh(%s))",
|
||||
"p2wsh-p2sh": "sh(wsh(%s))",
|
||||
"p2wsh": "wsh(%s)",
|
||||
"p2tr": "tr(%s)"
|
||||
}
|
||||
|
||||
SINGLE_FMT_TO_SCRIPT = {
|
||||
AF_P2WPKH: "wpkh(%s)",
|
||||
AF_CLASSIC: "pkh(%s)",
|
||||
AF_P2WPKH_P2SH: "sh(wpkh(%s))",
|
||||
AF_P2TR: "tr(%s)",
|
||||
None: "wpkh(%s)",
|
||||
"p2pkh": "pkh(%s)",
|
||||
"p2wpkh": "wpkh(%s)",
|
||||
"p2sh-p2wpkh": "sh(wpkh(%s))",
|
||||
"p2wpkh-p2sh": "sh(wpkh(%s))",
|
||||
"p2tr": "tr(%s)",
|
||||
}
|
||||
|
||||
PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
|
||||
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
|
||||
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def xfp2str(xfp):
|
||||
# Standardized way to show an xpub's fingerprint... it's a 4-byte string
|
||||
# and not really an integer. Used to show as '0x%08x' but that's wrong endian.
|
||||
return b2a_hex(struct.pack('<I', xfp)).decode().upper()
|
||||
|
||||
def str2xfp(txt):
|
||||
# Inverse of xfp2str
|
||||
return struct.unpack('<I', a2b_hex(txt))[0]
|
||||
|
||||
|
||||
class WrongCheckSumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def polymod(c, val):
|
||||
c0 = c >> 35
|
||||
c = ((c & 0x7ffffffff) << 5) ^ val
|
||||
if (c0 & 1):
|
||||
c ^= 0xf5dee51989
|
||||
if (c0 & 2):
|
||||
c ^= 0xa9fdca3312
|
||||
if (c0 & 4):
|
||||
c ^= 0x1bab10e32d
|
||||
if (c0 & 8):
|
||||
c ^= 0x3706b1677a
|
||||
if (c0 & 16):
|
||||
c ^= 0x644d626ffd
|
||||
|
||||
return c
|
||||
|
||||
def descriptor_checksum(desc):
|
||||
c = 1
|
||||
cls = 0
|
||||
clscount = 0
|
||||
for ch in desc:
|
||||
pos = INPUT_CHARSET.find(ch)
|
||||
if pos == -1:
|
||||
raise ValueError(ch)
|
||||
|
||||
c = polymod(c, pos & 31)
|
||||
cls = cls * 3 + (pos >> 5)
|
||||
clscount += 1
|
||||
if clscount == 3:
|
||||
c = polymod(c, cls)
|
||||
cls = 0
|
||||
clscount = 0
|
||||
|
||||
if clscount > 0:
|
||||
c = polymod(c, cls)
|
||||
for j in range(0, 8):
|
||||
c = polymod(c, 0)
|
||||
c ^= 1
|
||||
|
||||
rv = ''
|
||||
for j in range(0, 8):
|
||||
rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
|
||||
|
||||
return rv
|
||||
|
||||
def append_checksum(desc):
|
||||
return desc + "#" + descriptor_checksum(desc)
|
||||
|
||||
|
||||
def parse_desc_str(string):
|
||||
"""Remove comments, empty lines and strip line. Produce single line string"""
|
||||
res = ""
|
||||
for l in string.split("\n"):
|
||||
strip_l = l.strip()
|
||||
if not strip_l:
|
||||
continue
|
||||
if strip_l.startswith("#"):
|
||||
continue
|
||||
res += strip_l
|
||||
return res
|
||||
|
||||
|
||||
def multisig_descriptor_template(xpub, path, xfp, addr_fmt):
|
||||
key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub)
|
||||
if addr_fmt == AF_P2WSH_P2SH:
|
||||
descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))"
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
descriptor_template = "wsh(sortedmulti(M,%s,...))"
|
||||
elif addr_fmt == AF_P2SH:
|
||||
descriptor_template = "sh(sortedmulti(M,%s,...))"
|
||||
elif addr_fmt == AF_P2TR:
|
||||
# provably unspendable BIP-0341
|
||||
descriptor_template = "tr(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))"
|
||||
else:
|
||||
return None
|
||||
descriptor_template = descriptor_template % key_exp
|
||||
return descriptor_template
|
||||
|
||||
|
||||
class Descriptor:
|
||||
__slots__ = (
|
||||
"keys",
|
||||
"addr_fmt",
|
||||
)
|
||||
|
||||
def __init__(self, keys, addr_fmt):
|
||||
self.keys = keys
|
||||
self.addr_fmt = addr_fmt
|
||||
|
||||
@staticmethod
|
||||
def checksum_check(desc_w_checksum: str, csum_required=False):
|
||||
try:
|
||||
desc, checksum = desc_w_checksum.split("#")
|
||||
except ValueError:
|
||||
if csum_required:
|
||||
raise ValueError("Missing descriptor checksum")
|
||||
return desc_w_checksum, None
|
||||
|
||||
calc_checksum = descriptor_checksum(desc)
|
||||
if calc_checksum != checksum:
|
||||
raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
|
||||
return desc, checksum
|
||||
|
||||
@staticmethod
|
||||
def parse_key_orig_info(key: str):
|
||||
# key origin info is required for our MultisigWallet
|
||||
close_index = key.find("]")
|
||||
if key[0] != "[" or close_index == -1:
|
||||
raise ValueError("Key origin info is required for %s" % (key))
|
||||
key_orig_info = key[1:close_index] # remove brackets
|
||||
key = key[close_index + 1:]
|
||||
assert "/" in key_orig_info, "Malformed key derivation info"
|
||||
return key_orig_info, key
|
||||
|
||||
@staticmethod
|
||||
def parse_key_derivation_info(key: str):
|
||||
invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed"
|
||||
slash_split = key.split("/")
|
||||
assert len(slash_split) > 1, invalid_subderiv_msg
|
||||
if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]):
|
||||
assert slash_split[-1] == "*", invalid_subderiv_msg
|
||||
assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg
|
||||
assert len(slash_split[1:]) == 2, invalid_subderiv_msg
|
||||
return slash_split[0]
|
||||
else:
|
||||
raise ValueError("Cannot use hardened sub derivation path")
|
||||
|
||||
def checksum(self):
|
||||
return descriptor_checksum(self._serialize())
|
||||
|
||||
def serialize_keys(self, internal=False, int_ext=False, keys=None):
|
||||
to_do = keys if keys is not None else self.keys
|
||||
result = []
|
||||
for xfp, deriv, xpub in to_do:
|
||||
if deriv[0] == "m":
|
||||
# get rid of 'm'
|
||||
deriv = deriv[1:]
|
||||
elif deriv[0] != "/":
|
||||
# input "84'/0'/0'" would lack slash separtor with xfp
|
||||
deriv = "/" + deriv
|
||||
if not isinstance(xfp, str):
|
||||
xfp = xfp2str(xfp)
|
||||
koi = xfp + deriv
|
||||
# normalize xpub to use h for hardened instead of '
|
||||
key_str = "[%s]%s" % (koi.lower(), xpub)
|
||||
if int_ext:
|
||||
key_str = key_str + "/" + "<0;1>" + "/" + "*"
|
||||
else:
|
||||
key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"])
|
||||
result.append(key_str.replace("'", "h"))
|
||||
return result
|
||||
|
||||
def _serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize without checksum"""
|
||||
assert len(self.keys) == 1, "Multiple keys for single signature script"
|
||||
desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt]
|
||||
inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0]
|
||||
return desc_base % (inner)
|
||||
|
||||
def serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize with checksum"""
|
||||
return append_checksum(self._serialize(internal=internal, int_ext=int_ext))
|
||||
|
||||
@classmethod
|
||||
def parse(cls, desc_w_checksum: str) -> "Descriptor":
|
||||
# remove garbage
|
||||
desc_w_checksum = parse_desc_str(desc_w_checksum)
|
||||
# check correct checksum
|
||||
desc, checksum = cls.checksum_check(desc_w_checksum)
|
||||
# legacy
|
||||
if desc.startswith("pkh("):
|
||||
addr_fmt = AF_CLASSIC
|
||||
tmp_desc = desc.replace("pkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
# native segwit
|
||||
elif desc.startswith("wpkh("):
|
||||
addr_fmt = AF_P2WPKH
|
||||
tmp_desc = desc.replace("wpkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
# wrapped segwit
|
||||
elif desc.startswith("sh(wpkh("):
|
||||
addr_fmt = AF_P2WPKH_P2SH
|
||||
tmp_desc = desc.replace("sh(wpkh(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
# wrapped segwit
|
||||
elif desc.startswith("tr("):
|
||||
addr_fmt = AF_P2TR
|
||||
tmp_desc = desc.replace("tr(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
else:
|
||||
raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.")
|
||||
|
||||
koi, key = cls.parse_key_orig_info(tmp_desc)
|
||||
if key[0:4] not in ["tpub", "xpub"]:
|
||||
raise ValueError("Only extended public keys are supported")
|
||||
|
||||
xpub = cls.parse_key_derivation_info(key)
|
||||
xfp = str2xfp(koi[:8])
|
||||
origin_deriv = "m" + koi[8:]
|
||||
|
||||
return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt)
|
||||
|
||||
@classmethod
|
||||
def is_descriptor(cls, desc_str):
|
||||
"""Quick method to guess whether this is a descriptor"""
|
||||
try:
|
||||
temp = parse_desc_str(desc_str)
|
||||
except:
|
||||
return False
|
||||
|
||||
for prefix in ("pk(", "pkh(", "wpkh(", "tr(", "addr(", "raw(", "rawtr(", "combo(",
|
||||
"sh(", "wsh(", "multi(", "sortedmulti(", "multi_a(", "sortedmulti_a("):
|
||||
if temp.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
def bitcoin_core_serialize(self, external_label=None):
|
||||
# this will become legacy one day
|
||||
# instead use <0;1> descriptor format
|
||||
res = []
|
||||
for internal in [False, True]:
|
||||
desc_obj = {
|
||||
"desc": self.serialize(internal=internal),
|
||||
"active": True,
|
||||
"timestamp": "now",
|
||||
"internal": internal,
|
||||
"range": [0, 100],
|
||||
}
|
||||
if internal is False and external_label:
|
||||
desc_obj["label"] = external_label
|
||||
res.append(desc_obj)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class MultisigDescriptor(Descriptor):
|
||||
# only supprt with key derivation info
|
||||
# only xpubs
|
||||
# can be extended when needed
|
||||
__slots__ = (
|
||||
"M",
|
||||
"N",
|
||||
"internal_key",
|
||||
"keys",
|
||||
"addr_fmt",
|
||||
)
|
||||
|
||||
def __init__(self, M, N, keys, addr_fmt, internal_key=None):
|
||||
self.M = M
|
||||
self.N = N
|
||||
self.internal_key = internal_key
|
||||
super().__init__(keys, addr_fmt)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, desc_w_checksum: str) -> "MultisigDescriptor":
|
||||
internal_key = None # taproot
|
||||
# remove garbage
|
||||
desc_w_checksum = parse_desc_str(desc_w_checksum)
|
||||
# check correct checksum
|
||||
desc, checksum = cls.checksum_check(desc_w_checksum)
|
||||
# legacy
|
||||
if desc.startswith("sh(sortedmulti("):
|
||||
addr_fmt = AF_P2SH
|
||||
tmp_desc = desc.replace("sh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
# native segwit
|
||||
elif desc.startswith("wsh(sortedmulti("):
|
||||
addr_fmt = AF_P2WSH
|
||||
tmp_desc = desc.replace("wsh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip("))")
|
||||
|
||||
# wrapped segwit
|
||||
elif desc.startswith("sh(wsh(sortedmulti("):
|
||||
addr_fmt = AF_P2WSH_P2SH
|
||||
tmp_desc = desc.replace("sh(wsh(sortedmulti(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")))")
|
||||
|
||||
elif desc.startswith("tr("):
|
||||
addr_fmt = AF_P2TR
|
||||
tmp_desc = desc.replace("tr(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
internal_key, tmp_desc = tmp_desc.split(",", 1)
|
||||
assert tmp_desc.startswith("sortedmulti_a("), "Only one sortedmulti_a allowed"
|
||||
tmp_desc = tmp_desc.replace("sortedmulti_a(", "")
|
||||
tmp_desc = tmp_desc.rstrip(")")
|
||||
|
||||
try:
|
||||
koi, key = cls.parse_key_orig_info(internal_key)
|
||||
if key[0:4] not in ["tpub", "xpub"]:
|
||||
raise ValueError("Only extended public keys are supported")
|
||||
xpub = cls.parse_key_derivation_info(key)
|
||||
xfp = str2xfp(koi[:8])
|
||||
origin_deriv = "m" + koi[8:]
|
||||
internal_key = (xfp, origin_deriv, xpub)
|
||||
except ValueError:
|
||||
# https://github.com/BlockstreamResearch/secp256k1-zkp/blob/11af7015de624b010424273be3d91f117f172c82/src/modules/rangeproof/main_impl.h#L16
|
||||
# H = lift_x(0x0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0)
|
||||
# if internal_key == PROVABLY_UNSPENDABLE:
|
||||
# # unspendable H as defined in BIP-0341
|
||||
# pass
|
||||
# else:
|
||||
# assert "r=" in internal_key
|
||||
# _, r = internal_key.split("=")
|
||||
# if r == "@":
|
||||
# # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG
|
||||
# kp = ngu.secp256k1.keypair()
|
||||
# else:
|
||||
# # H + rG where r is provided from user
|
||||
# r = a2b_hex(r)
|
||||
# assert len(r) == 32, "r != 32"
|
||||
# kp = ngu.secp256k1.keypair(r)
|
||||
#
|
||||
# H = a2b_hex(PROVABLY_UNSPENDABLE)
|
||||
# H_xo = ngu.secp256k1.xonly_pubkey(H)
|
||||
# internal_key = H_xo.tweak_add(kp.xonly_pubkey().to_bytes())
|
||||
# internal_key = b2a_hex(internal_key.to_bytes()).decode()
|
||||
pass
|
||||
|
||||
else:
|
||||
raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. All have to be sortedmulti.")
|
||||
|
||||
splitted = tmp_desc.split(",")
|
||||
M, keys = int(splitted[0]), splitted[1:]
|
||||
N = int(len(keys))
|
||||
if M > N:
|
||||
raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N))
|
||||
|
||||
res_keys = []
|
||||
for key in keys:
|
||||
koi, key = cls.parse_key_orig_info(key)
|
||||
if key[0:4] not in ["tpub", "xpub"]:
|
||||
raise ValueError("Only extended public keys are supported")
|
||||
|
||||
xpub = cls.parse_key_derivation_info(key)
|
||||
xfp = str2xfp(koi[:8])
|
||||
origin_deriv = "m" + koi[8:]
|
||||
res_keys.append((xfp, origin_deriv, xpub))
|
||||
|
||||
return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, internal_key=internal_key)
|
||||
|
||||
def _serialize(self, internal=False, int_ext=False) -> str:
|
||||
"""Serialize without checksum"""
|
||||
desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt]
|
||||
if self.addr_fmt == AF_P2TR:
|
||||
if isinstance(self.internal_key, str):
|
||||
desc_base = desc_base % (self.internal_key + ",sortedmulti_a(%s)")
|
||||
else:
|
||||
ik_ser = self.serialize_keys(keys=[self.internal_key])[0]
|
||||
desc_base = desc_base % (ik_ser + ",sortedmulti_a(%s)")
|
||||
else:
|
||||
desc_base = desc_base % "sortedmulti(%s)"
|
||||
assert len(self.keys) == self.N
|
||||
inner = str(self.M) + "," + ",".join(
|
||||
self.serialize_keys(internal=internal, int_ext=int_ext))
|
||||
|
||||
return desc_base % inner
|
||||
|
||||
def pretty_serialize(self):
|
||||
"""Serialize in pretty and human-readable format"""
|
||||
inner_ident = 1
|
||||
res = "# Coldcard descriptor export\n"
|
||||
res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n"
|
||||
if self.addr_fmt == AF_P2SH:
|
||||
res += "# bare multisig - p2sh\n"
|
||||
res += "sh(sortedmulti(\n%s\n))"
|
||||
# native segwit
|
||||
elif self.addr_fmt == AF_P2WSH:
|
||||
res += "# native segwit - p2wsh\n"
|
||||
res += "wsh(sortedmulti(\n%s\n))"
|
||||
|
||||
# wrapped segwit
|
||||
elif self.addr_fmt == AF_P2WSH_P2SH:
|
||||
res += "# wrapped segwit - p2sh-p2wsh\n"
|
||||
res += "sh(wsh(sortedmulti(\n%s\n)))"
|
||||
|
||||
elif self.addr_fmt == AF_P2TR:
|
||||
inner_ident = 2
|
||||
res += "# taproot multisig - p2tr\n"
|
||||
res += "tr(\n"
|
||||
if isinstance(self.internal_key, str):
|
||||
res += "\t" + "# internal key (provably unspendable)\n"
|
||||
res += "\t" + self.internal_key + ",\n"
|
||||
res += "\t" + "sortedmulti_a(\n%s\n))"
|
||||
else:
|
||||
ik_ser = self.serialize_keys(keys=[self.internal_key])[0]
|
||||
res += "\t" + "# internal key\n"
|
||||
res += "\t" + ik_ser + ",\n"
|
||||
res += "\t" + "sortedmulti_a(\n%s\n))"
|
||||
else:
|
||||
raise ValueError("Malformed descriptor")
|
||||
|
||||
assert len(self.keys) == self.N
|
||||
inner = ("\t" * inner_ident) + "# %d of %d (%s)\n" % (
|
||||
self.M, self.N,
|
||||
"requires all participants to sign" if self.M == self.N else "threshold")
|
||||
inner += ("\t" * inner_ident) + str(self.M) + ",\n"
|
||||
ser_keys = self.serialize_keys()
|
||||
for i, key_str in enumerate(ser_keys, start=1):
|
||||
if i == self.N:
|
||||
inner += ("\t" * inner_ident) + key_str
|
||||
else:
|
||||
inner += ("\t" * inner_ident) + key_str + ",\n"
|
||||
|
||||
checksum = self.serialize().split("#")[1]
|
||||
|
||||
return (res % inner) + "#" + checksum
|
||||
|
||||
# EOF
|
||||
@ -129,13 +129,8 @@ print("done")
|
||||
|
||||
|
||||
# test recovery/reset
|
||||
if version.mk_num <= 3:
|
||||
from sflash import SF
|
||||
SF.chip_erase()
|
||||
settings.load()
|
||||
else:
|
||||
settings.clear()
|
||||
settings.save()
|
||||
settings.clear()
|
||||
settings.save()
|
||||
|
||||
print("fully done")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user