Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1b932685d | ||
|
|
3fa25b2d09 | ||
|
|
3195efb547 | ||
|
|
2774ae5609 | ||
|
|
b788b23cf4 | ||
|
|
4320ffc599 | ||
|
|
4db1b10ee0 | ||
|
|
1581dc8b69 | ||
|
|
60254045e5 | ||
|
|
2716ce631e | ||
|
|
bb0b1b408f | ||
|
|
ac44b6b700 | ||
|
|
93b934c021 | ||
|
|
2d48ad744b | ||
|
|
6bbb05edaa | ||
|
|
45145ee0e9 | ||
|
|
9f285d8d5c | ||
|
|
ebc1f33c64 | ||
|
|
c24fa9f771 | ||
|
|
c493677595 | ||
|
|
d316fa0124 | ||
|
|
6213e997b6 | ||
|
|
ca5dd11219 | ||
|
|
8fea4aac93 | ||
|
|
ce5f31019f | ||
|
|
c0f964868f | ||
|
|
4397e4a4ce | ||
|
|
9528c429e9 | ||
|
|
411eb78f73 | ||
|
|
fe37c52657 | ||
|
|
62e8d3d4d2 | ||
|
|
b671183f11 | ||
|
|
99e66c7a9d | ||
|
|
5f835fa739 | ||
|
|
c6272161b7 | ||
|
|
c70d6559a7 | ||
|
|
a97eecc8a0 | ||
|
|
980bfd9b1c | ||
|
|
7d20f03639 | ||
|
|
0e7d65686a |
@ -30,6 +30,7 @@
|
||||
Tapsigner Backup
|
||||
Seed XOR
|
||||
Migrate Coldcard
|
||||
Key Teleport (start)
|
||||
Help
|
||||
Advanced/Tools
|
||||
View Identity
|
||||
@ -54,7 +55,6 @@
|
||||
From VirtDisk [IF VIRTDISK ENABLED]
|
||||
File Management
|
||||
Verify Backup
|
||||
Teleport Multisig PSBT [IF QR AND SECRET]
|
||||
List Files
|
||||
Verify Sig File
|
||||
NFC File Share [IF NFC ENABLED]
|
||||
@ -168,12 +168,12 @@
|
||||
[NORMAL OPERATION]
|
||||
Ready To Sign
|
||||
Passphrase [IF WORD BASED SEED]
|
||||
Restore Saved [MAYBE]
|
||||
A***********
|
||||
[0C52BAD4]
|
||||
Restore Saved
|
||||
c*******
|
||||
[3A14F788]
|
||||
Restore
|
||||
Delete
|
||||
Edit Phrase [MAYBE]
|
||||
Edit Phrase [IF QWERTY]
|
||||
Add Word [IF NOT QWERTY]
|
||||
[SEED WORD MENUS]
|
||||
Add Numbers [IF NOT QWERTY]
|
||||
@ -197,35 +197,44 @@
|
||||
Account Number
|
||||
Custom Path
|
||||
CC-2-of-4
|
||||
Secure Notes & Passwords [IF ENBALED]
|
||||
1: note1
|
||||
"note1"
|
||||
Secure Notes & Passwords [IF ENBALED] [MAYBE]
|
||||
1: note0
|
||||
"note0"
|
||||
View Note
|
||||
Edit
|
||||
Delete
|
||||
Export
|
||||
SHORTCUT
|
||||
SHORTCUT
|
||||
2: nostr
|
||||
"nostr"
|
||||
↳ scg
|
||||
↳ brb.io
|
||||
Sign Note Text
|
||||
2: secret-PWD
|
||||
"secret-PWD"
|
||||
↳ satoshi
|
||||
↳ abc.org
|
||||
View Password
|
||||
Send Password [MAYBE]
|
||||
Export
|
||||
Edit Metadata
|
||||
Delete
|
||||
Change Password
|
||||
SHORTCUT
|
||||
SHORTCUT
|
||||
Sign Note Text
|
||||
New Note
|
||||
New Password
|
||||
Export All
|
||||
Sort By Title
|
||||
Import
|
||||
Type Passwords [MAYBE]
|
||||
Seed Vault [MAYBE]
|
||||
1: [B14E9AE0]
|
||||
[B14E9AE0]
|
||||
1: [7126EB3C]
|
||||
[7126EB3C]
|
||||
Use This Seed
|
||||
Rename
|
||||
Delete
|
||||
2: [CCEE13B9]
|
||||
[CCEE13B9]
|
||||
Use This Seed
|
||||
Rename
|
||||
Delete
|
||||
3: [03EE9989]
|
||||
[03EE9989]
|
||||
Use This Seed
|
||||
Rename
|
||||
Delete
|
||||
@ -236,17 +245,18 @@
|
||||
Restore Backup
|
||||
Clone Coldcard
|
||||
Export Wallet
|
||||
Sparrow
|
||||
Cove
|
||||
Bitcoin Core
|
||||
Fully Noded
|
||||
Sparrow Wallet
|
||||
Nunchuk
|
||||
Bull Bitcoin
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Fully Noded
|
||||
Unchained
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Wasabi Wallet
|
||||
Unchained
|
||||
Lily Wallet
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
Descriptor
|
||||
@ -266,17 +276,18 @@
|
||||
Verify Backup
|
||||
Backup System
|
||||
Export Wallet
|
||||
Sparrow
|
||||
Cove
|
||||
Bitcoin Core
|
||||
Fully Noded
|
||||
Sparrow Wallet
|
||||
Nunchuk
|
||||
Bull Bitcoin
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Fully Noded
|
||||
Unchained
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Wasabi Wallet
|
||||
Unchained
|
||||
Lily Wallet
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
Descriptor
|
||||
@ -290,7 +301,7 @@
|
||||
Dump Summary
|
||||
Sign Text File
|
||||
Batch Sign PSBT
|
||||
Teleport Multisig PSBT [IF QR AND SECRET]
|
||||
Teleport Multisig PSBT
|
||||
List Files
|
||||
Verify Sig File
|
||||
NFC File Share [IF NFC ENABLED]
|
||||
@ -300,29 +311,28 @@
|
||||
Format SD Card
|
||||
Format RAM Disk [IF VIRTDISK ENABLED]
|
||||
Secure Notes & Passwords [IF QWERTY KEYBOARD]
|
||||
1: note1
|
||||
"note1"
|
||||
1: note0
|
||||
"note0"
|
||||
View Note
|
||||
Edit
|
||||
Delete
|
||||
Export
|
||||
SHORTCUT
|
||||
SHORTCUT
|
||||
2: nostr
|
||||
"nostr"
|
||||
↳ scg
|
||||
↳ brb.io
|
||||
Sign Note Text
|
||||
2: secret-PWD
|
||||
"secret-PWD"
|
||||
↳ satoshi
|
||||
↳ abc.org
|
||||
View Password
|
||||
Send Password [MAYBE]
|
||||
Export
|
||||
Edit Metadata
|
||||
Delete
|
||||
Change Password
|
||||
SHORTCUT
|
||||
SHORTCUT
|
||||
Sign Note Text
|
||||
New Note
|
||||
New Password
|
||||
Export All
|
||||
Sort By Title
|
||||
Import
|
||||
Derive Seeds (BIP-85)
|
||||
View Identity
|
||||
@ -342,13 +352,14 @@
|
||||
Tapsigner Backup
|
||||
Coldcard Backup
|
||||
Key Teleport (start)
|
||||
Spending Policy
|
||||
Single-Signer [IF SECRET AND NOT TMP SEED]
|
||||
Co-Sign Multisig (CCC) [IF NOT TMP SEED]
|
||||
HSM Mode [IF HSM AND SECRET]
|
||||
Default Off
|
||||
Enable
|
||||
User Management [MAYBE]
|
||||
Paper Wallets
|
||||
Enable HSM [IF HSM AND SECRET]
|
||||
Default Off
|
||||
Enable
|
||||
Coldcard Co-Signing [IF NOT TMP SEED]
|
||||
User Management [IF HSM AND SECRET]
|
||||
(no users yet)
|
||||
NFC Tools [IF NFC ENABLED]
|
||||
Sign PSBT
|
||||
Show Address
|
||||
@ -357,7 +368,7 @@
|
||||
Verify Address
|
||||
File Share
|
||||
Import Multisig
|
||||
Push Transaction [IF ENBALED]
|
||||
Push Transaction [IF PUSHTX ENABLED]
|
||||
Danger Zone
|
||||
Debug Functions
|
||||
Seed Functions
|
||||
@ -398,22 +409,32 @@
|
||||
Settings Space
|
||||
MCU Key Slots
|
||||
Bless Firmware
|
||||
Reflash GPU [IF QWERTY KEYBOARD]
|
||||
Wipe LFS
|
||||
Settings
|
||||
Login Settings
|
||||
Change Main PIN
|
||||
Trick PINs [IF SECRET AND NOT TMP SEED]
|
||||
Trick PINs:
|
||||
↳123-254
|
||||
PIN 123-254
|
||||
↳11-11
|
||||
PIN 11-11
|
||||
↳Bricks CC
|
||||
Hide Trick
|
||||
Delete Trick
|
||||
Change PIN
|
||||
↳333-3334
|
||||
PIN 333-3334
|
||||
↳Duress Wallet
|
||||
Activate Wallet
|
||||
Hide Trick
|
||||
Delete Trick
|
||||
Change PIN
|
||||
↳WRONG PIN
|
||||
After 3 wrong:
|
||||
↳Wipes seed
|
||||
↳Reboots
|
||||
Hide Trick
|
||||
Delete Trick
|
||||
Add New Trick
|
||||
Add If Wrong
|
||||
Delete All
|
||||
Set Nickname
|
||||
Scramble Keys
|
||||
@ -458,11 +479,11 @@
|
||||
View Details
|
||||
Delete
|
||||
Coldcard Export
|
||||
Electrum Wallet
|
||||
Descriptors
|
||||
View Descriptor
|
||||
Export
|
||||
Bitcoin Core
|
||||
Electrum Wallet
|
||||
Import from File
|
||||
Import from QR [IF QR SCANNER]
|
||||
Import via NFC [IF NFC ENABLED]
|
||||
@ -538,7 +559,7 @@
|
||||
Verify Address
|
||||
File Share
|
||||
Import Multisig
|
||||
Push Transaction [IF ENBALED]
|
||||
Push Transaction [IF PUSHTX ENABLED]
|
||||
---
|
||||
|
||||
[FACTORY MODE]
|
||||
@ -550,3 +571,144 @@
|
||||
Perform Selftest
|
||||
---
|
||||
|
||||
[SSSP]
|
||||
Ready To Sign
|
||||
Passphrase [IF WORD BASED SEED & SSSP RELATED KEYS ENABLED]
|
||||
Restore Saved
|
||||
c*******
|
||||
[3A14F788]
|
||||
Restore
|
||||
Delete
|
||||
Edit Phrase
|
||||
Scan Any QR Code [IF QR SCANNER]
|
||||
Address Explorer
|
||||
Classic P2PKH
|
||||
↳ mtHSVByP9EYZ⋯Vm19gvpecb5R
|
||||
P2SH-Segwit
|
||||
↳ 2NCAJ5wD4Gvm⋯NphNU8UYoEJv
|
||||
Segwit P2WPKH
|
||||
↳ tb1qupyd58nd⋯vu9jtdyws9n9
|
||||
Applications
|
||||
Samourai
|
||||
Post-mix
|
||||
Pre-mix
|
||||
Wasabi
|
||||
Account Number
|
||||
Custom Path
|
||||
CC-2-of-4
|
||||
Secure Notes & Passwords[IF ENABLED & SSSP ALLOW NOTES]
|
||||
1: note0
|
||||
"note0"
|
||||
View Note
|
||||
Sign Note Text
|
||||
2: secret-PWD
|
||||
"secret-PWD"
|
||||
↳ satoshi
|
||||
↳ abc.org
|
||||
View Password
|
||||
Send Password [MAYBE]
|
||||
Sign Note Text
|
||||
Type Passwords [MAYBE]
|
||||
Seed Vault[IF ENABLED & SSSP RELATED KEYS ENABLED]
|
||||
1: [7126EB3C]
|
||||
[7126EB3C]
|
||||
Use This Seed
|
||||
2: [CCEE13B9]
|
||||
[CCEE13B9]
|
||||
Use This Seed
|
||||
3: [03EE9989]
|
||||
[03EE9989]
|
||||
Use This Seed
|
||||
Advanced/Tools
|
||||
File Management
|
||||
Sign Text File
|
||||
Batch Sign PSBT
|
||||
List Files
|
||||
Export Wallet
|
||||
Sparrow
|
||||
Cove
|
||||
Bitcoin Core
|
||||
Nunchuk
|
||||
Bull Bitcoin
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Fully Noded
|
||||
Unchained
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
Descriptor
|
||||
Generic JSON
|
||||
Export XPUB
|
||||
Segwit (BIP-84)
|
||||
Classic (BIP-44)
|
||||
P2WPKH/P2SH (BIP-49)
|
||||
Master XPUB
|
||||
Current XFP
|
||||
Dump Summary
|
||||
Verify Sig File
|
||||
NFC File Share [IF NFC ENABLED]
|
||||
BBQr File Share [IF QR SCANNER]
|
||||
QR File Share [IF QR SCANNER]
|
||||
Format SD Card
|
||||
Format RAM Disk [IF VIRTDISK ENABLED]
|
||||
Export Wallet
|
||||
Sparrow
|
||||
Cove
|
||||
Bitcoin Core
|
||||
Nunchuk
|
||||
Bull Bitcoin
|
||||
Zeus
|
||||
Electrum Wallet
|
||||
Wasabi Wallet
|
||||
Fully Noded
|
||||
Unchained
|
||||
Theya
|
||||
Bitcoin Safe
|
||||
Samourai Postmix
|
||||
Samourai Premix
|
||||
Descriptor
|
||||
Generic JSON
|
||||
Export XPUB
|
||||
Segwit (BIP-84)
|
||||
Classic (BIP-44)
|
||||
P2WPKH/P2SH (BIP-49)
|
||||
Master XPUB
|
||||
Current XFP
|
||||
Dump Summary
|
||||
Teleport Multisig PSBT [MAYBE]
|
||||
View Identity
|
||||
Temporary Seed [IF SSSP RELATED KEYS ENABLED]
|
||||
Import from QR Scan [IF QR SCANNER]
|
||||
Import Words
|
||||
12 Words
|
||||
18 Words
|
||||
24 Words
|
||||
Import via NFC [IF NFC ENABLED]
|
||||
Import XPRV
|
||||
Tapsigner Backup
|
||||
Coldcard Backup
|
||||
Paper Wallets
|
||||
NFC Tools [IF NFC ENABLED]
|
||||
Sign PSBT
|
||||
Show Address
|
||||
Sign Message
|
||||
Verify Sig File
|
||||
Verify Address
|
||||
File Share
|
||||
Push Transaction [IF PUSHTX ENABLED]
|
||||
Destroy Seed
|
||||
Secure Logout
|
||||
EXIT TEST DRIVE [MAYBE]
|
||||
SHORTCUT [IF NFC ENABLED]
|
||||
Sign PSBT
|
||||
Show Address
|
||||
Sign Message
|
||||
Verify Sig File
|
||||
Verify Address
|
||||
File Share
|
||||
Push Transaction [IF PUSHTX ENABLED]
|
||||
---
|
||||
|
||||
|
||||
209
docs/spending-policy.md
Normal file
209
docs/spending-policy.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Spending Policy
|
||||
|
||||
This special mode will stop you from signing transactions if they
|
||||
exceed a spending policy you define beforehand. Once enabled, many
|
||||
features of the COLDCARD are disabled or inaccessible.
|
||||
|
||||
You might want to use this feature when traveling with your COLDCARD.
|
||||
|
||||
## Spending Policy: Multisig (formerly CCC)
|
||||
|
||||
We also support a mode where the COLDCARD is a multisig co-signer
|
||||
and only performs its signature when a spending policy is met. The
|
||||
other multisig signers are free to sign or not sign as appropriate.
|
||||
|
||||
Multisig mode is more advanced and requires use of multisig addresses,
|
||||
new UTXO, and cooperating multisig on-chain wallets.
|
||||
|
||||
This document will only discuss the "Single signer" version of
|
||||
Spending Policy. Both modes can be active at the same time, but if
|
||||
a transaction would be signed by Multisig policy, then we assume
|
||||
it's also okay to sign your main key as well.
|
||||
|
||||
# Before You Start
|
||||
|
||||
When a Spending Policy is in effect, there are limitations
|
||||
in effect:
|
||||
|
||||
- Firmware updates are blocked.
|
||||
- There is no way to backup the COLDCARD.
|
||||
- Seed vault and Secure Notes are read-only (and can also be hidden).
|
||||
- Settings menu is inaccessible.
|
||||
- BIP-39 passphrases may be blocked (optional).
|
||||
|
||||
We recommend getting the COLDCARD fully configured and setup
|
||||
for typical transactions before enabling the Spending Policy.
|
||||
|
||||
# Setup Spending Policy
|
||||
|
||||
Visit `Advanced / Tools > Spending Policy` menu and choose
|
||||
"Single-Signer". First some background information is shown,
|
||||
then you are prompted to define the "Bypass PIN". This PIN code
|
||||
is only used when you need to disable the spending policy, but is
|
||||
also the only way to do so once enabled... so don't loose it.
|
||||
|
||||
Once the "Bypass PIN" is confirmed, you will arrive at menu for
|
||||
related settings. Use "Edit Policy..." to change the spending policy
|
||||
and define a Max Magnitude (limit number of BTC per transaction),
|
||||
Velocity (minimum time gaps between signed transactions). You can
|
||||
define a whitelist of up to 25 destination addresses (leave empty
|
||||
for any). Finally you can enroll your phone in 2FA (second factor)
|
||||
so that you must open an Authenticator app on your phone before
|
||||
transactions are signed.
|
||||
|
||||
## Other Security Settings
|
||||
|
||||
In addition to policy itself, there are a number of on/off
|
||||
switches which affect operation of the COLDCARD while the Spending
|
||||
Policy is in effect:
|
||||
|
||||
### Word Check
|
||||
|
||||
If enabled, you will have to enter the first and last seed word
|
||||
after the Bypass PIN as an additional security check.
|
||||
|
||||
### Allow Notes
|
||||
|
||||
On the Q, secure notes and passwords may be visible or hidden
|
||||
using this setting. In either case they are strictly readonly.
|
||||
|
||||
### Related Keys
|
||||
|
||||
BIP-39 passphrase entry, Seed Vault usage will be blocked unless this
|
||||
setting is enabled. Even when enabled, the Seed Vault is always readonly
|
||||
and cannot be changed.
|
||||
|
||||
# Other Menu Items
|
||||
|
||||
## Last Violation
|
||||
|
||||
If you have recently tried and failed to sign a transaction, the
|
||||
reason for the transaction being rejected can be viewed and cleared,
|
||||
using menu item "Last Violation". It is shown only if a Spending
|
||||
Policy violation (attempt) has occurred since the last valid signing.
|
||||
|
||||
This is meant as a debugging tool, and the information stored is
|
||||
terse.
|
||||
|
||||
## Remove Policy
|
||||
|
||||
This will remove your spending policy completely and remove
|
||||
the Bypass PIN. Your COLDCARD will be back to normal.
|
||||
|
||||
## Test Drive
|
||||
|
||||
Experiment with how the COLDCARD will function if the Spending
|
||||
Policy was enabled. You can try to sign transactions that should
|
||||
be rejected and view the menus in the new mode without rebooting.
|
||||
|
||||
Choose "EXIT TEST DRIVE" on top menu to return to the Spending
|
||||
Policy menu. Reboot will also restore normal operation without
|
||||
any special challenges.
|
||||
|
||||
## ACTIVATE
|
||||
|
||||
This step will enable the Spending Policy and return to the
|
||||
main menu with it in effect. When you reboot the COLDCARD,
|
||||
the policy will still be in effect. You must use the
|
||||
Bypass PIN, followed by the normal main PIN, possibly
|
||||
followed by entering the first and last words of your seed
|
||||
phrase, before you can disable and change the policy.
|
||||
|
||||
We recommend test-driving the feature before doing that.
|
||||
|
||||
|
||||
# Tips and Tricks
|
||||
|
||||
## Money Manager Mode
|
||||
|
||||
You could setup a Coldcard for another person, perhaps a family member,
|
||||
and enable web 2FA authentication. There does not need to be any
|
||||
other spending policy limits (velocity could be unlimited).
|
||||
|
||||
Then enroll your own phone with the required 2FA values, and
|
||||
keep both that and the spending policy bypass PIN confidential.
|
||||
|
||||
The holder the the Coldcard will need a 2FA code from your phone
|
||||
when they want to spend. They can call you for the 6-digit code
|
||||
from the 2FA app on your phone. This is not hard to provide over a
|
||||
voice call.
|
||||
|
||||
Because a spending policy is in effect, they will not be able to
|
||||
see the seed words, other private key material, so regardless of
|
||||
any spoofing or phishing, they cannot move funds without your help.
|
||||
|
||||
You should record the bypass PIN, so it can be revealed somehow,
|
||||
should you die. You do not need to share the risks associated with
|
||||
holding a copy of the seed words.
|
||||
|
||||
## Passphrase Considerations
|
||||
|
||||
If you are using the same BIP-39 passphrase for everything, you should
|
||||
probably do a "Lock Down Seed" (Advanced/Tools > Danger Zone > Seed
|
||||
Functions) first. This takes your master seed and BIP-39 passphrase
|
||||
and cooks them together into an XPRV which then is stored as your
|
||||
master secret. (Replacing the master seed phrase.) This process
|
||||
cannot be reversed, so other funds you may have on the same seed
|
||||
words are protected. Once you are operating in XPRV mode, you can
|
||||
define a spending policy, and know that it is restricted to only
|
||||
that wallet.
|
||||
|
||||
When operating in XPRV mode, the "Passphrase" menu item is not shown
|
||||
because BIP-39 passwords cannot be applied to XPRV secrets.
|
||||
|
||||
## Trick PIN Thoughts
|
||||
|
||||
When doing your game theory w.r.t to bypass mode and this feature,
|
||||
remember that you should assume the attacker already has your main
|
||||
PIN. That's how they know they cannot spend all your coin, because
|
||||
they either tried to, or noticed the menus are very limited. They also
|
||||
have all your UTXO locations and total wallet balance (because they
|
||||
can export your xpubs to any wallet and load balance from there).
|
||||
|
||||
Therefore, a trick pin that leads to a duress wallet after giving up
|
||||
the bypass unlock PIN, will not fool them. Best would be to provide
|
||||
a false bypass PIN that is in fact a brick/wipe PIN.
|
||||
|
||||
|
||||
## Lock Out Changes to Policy
|
||||
|
||||
In the Trick Pin menu once Spending Policy has been enabled, you will
|
||||
find the Bypass PIN listed. You could delete or "hide" it. Hiding
|
||||
it is pointless since you cannot get to the trick PIN menu while
|
||||
the policy is in effect. Deleting the PIN however, is useful because
|
||||
it assures changes to spending policy are impossible. To recover
|
||||
the COLDCARD when this move is later regretted, under Advanced,
|
||||
there is "Destroy Seed" option which will clear the seed words and
|
||||
all settings, including the spending policy.
|
||||
|
||||
### Unlock Policy & Wipe
|
||||
|
||||
We've provided a new trick PIN that pretends to be the unlock
|
||||
spending policy pin, so the login sequence is correct... but it
|
||||
will wipe the seed in the process. It will be obvious to your
|
||||
attackers that you've wiped the seed because the main PIN will lead
|
||||
to blank wallet now (no seed loaded).
|
||||
|
||||
### Delta Mode and Spending Policy
|
||||
|
||||
If, from the start, you gave your "delta mode PIN" to the attackers,
|
||||
then when they bypass the policy (after also getting the bypass PIN
|
||||
from you), they will still be in Delta Mode.
|
||||
|
||||
They could attempt unlimited spending, but transactions signed will
|
||||
not be valid. If they try to view the seed words or generally export
|
||||
private key material, they will hit many of the "wipe seed if delta
|
||||
mode" cases.
|
||||
|
||||
## Forgotten Bypass PIN Code
|
||||
|
||||
If you've enabled a spending policy and still remember the main PIN,
|
||||
but cannot disable the feature because you've forgotten the Bypass
|
||||
PIN, your only option is to use `Advanced > Destroy Seed`. After
|
||||
some confirmations, this erases the master seed, all settings, seed
|
||||
vault items, secure notes, and trick pins. It's basically a factory
|
||||
reset except for the main PIN code which is unchanged. Once you've
|
||||
done that, you can enter your seed words from backup (or restore a
|
||||
backup file) and continue to use the COLDCARD again.
|
||||
|
||||
|
||||
@ -18,8 +18,11 @@ for the COLDCARD Q, it is a QR code to be scanned.
|
||||
The HSM feature uses HOTP tokens, which do not require a backend,
|
||||
but are not as robust as time-based tokens.
|
||||
|
||||
For now, Web2FA is only being used as part of CCC spending policy (opt-in),
|
||||
but we may find other uses for it.
|
||||
Web2FA is available to be enabled as part of a Spending Policy,
|
||||
both in Multisig and Single Signer modes. When enabled, you will be
|
||||
prompted complete 2FA authentication after viewing the details of
|
||||
the transaction to be signed. You will not be able to sign without
|
||||
the correct code.
|
||||
|
||||
## How It Works
|
||||
|
||||
@ -62,7 +65,7 @@ but we may find other uses for it.
|
||||
- multiplies that private key by server's known public key
|
||||
- apply sha256(resulting coordinate) => the session key
|
||||
- apply AES-256-CTR over URL contents (ascii text)
|
||||
- prepend 33 bytes of pubkey, and base64url encode all of it
|
||||
- prepend 33 bytes of pubkey, and then base64url encode all of it
|
||||
- full url is: `https://coldcard.com/2fa?{base64 encoded binary}`
|
||||
|
||||
## Trust Issues
|
||||
|
||||
@ -19,7 +19,7 @@ class Graphics:
|
||||
|
||||
scroll = (3, 61, 1, 0, b'@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@\x00\x00@@\xe0@')
|
||||
|
||||
selected = (15, 12, 2, 0, b'\x00\x00\x00\x00\x00\x06\x00\x0c\x00\x18\x0000`\x18\xc0\r\x80\x07\x00\x02\x00\x00\x00')
|
||||
selected = (9, 12, 2, 0, b'\x00\x00\x00\x00\x00\x80\x01\x80\x01\x00\x03\x00\x82\x00\xc6\x00d\x00<\x00\x18\x00\x00\x00')
|
||||
|
||||
sm_box = (11, 17, 2, 0, b'\xe4\xe0\x80 \x80 \x80 \x00\x00\x00\x00\x80 \x00\x00\x00\x00\x00\x00\x80 \x00\x00\x00\x00\x80 \x80 \x80 \xe4\xe0')
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
|
||||
|
||||
xx
|
||||
xx
|
||||
xx
|
||||
xx
|
||||
xx xx
|
||||
xx xx
|
||||
xx xx
|
||||
xxx
|
||||
x
|
||||
X
|
||||
XX
|
||||
X
|
||||
XX
|
||||
X X
|
||||
XX XX
|
||||
XX X
|
||||
XXXX
|
||||
XX
|
||||
|
||||
|
||||
@ -11,6 +11,12 @@ This lists the new changes that have not yet been published in a normal release.
|
||||
- Bugfix: If all change outputs have `nValue=0` they're not shown in UX.
|
||||
- Bugfix: Disallow negative input/output amounts in PSBT.
|
||||
|
||||
# Spending Policy Feature
|
||||
- "Enable HSM" and "User Management" have moved into Advanced > Spending Policy
|
||||
- old "CCC" feature has been renamed and moved into that menu
|
||||
- new feature: Spending policies for "Single Signer" added:
|
||||
- power new stuff
|
||||
|
||||
# Mk4 Specific Changes
|
||||
|
||||
## 5.4.? - 2025-08-xx
|
||||
|
||||
@ -319,7 +319,7 @@ Press (6) to prove you read to the end of this message.''', title='WARNING', esc
|
||||
if ch == '6': break
|
||||
|
||||
# do the actual picking
|
||||
pin = await lll.get_new_pin(title)
|
||||
pin = await lll.get_new_pin()
|
||||
del lll
|
||||
|
||||
if pin is None: return
|
||||
@ -573,8 +573,11 @@ async def clear_seed(*a):
|
||||
# This is super dangerous for the customer's money.
|
||||
import seed
|
||||
|
||||
if await any_active_duress_ux():
|
||||
return await ux_aborted()
|
||||
# in hobble mode, they cannot reach duress wallets and/or maybe we don't
|
||||
# want to reveal them? So don't block them based on that.
|
||||
if not pa.hobbled_mode:
|
||||
if await any_active_duress_ux():
|
||||
return await ux_aborted()
|
||||
|
||||
if not await ux_confirm('Wipe seed words and reset wallet. '
|
||||
'All funds will be lost. '
|
||||
@ -587,7 +590,7 @@ async def clear_seed(*a):
|
||||
if not await ux_confirm('''Are you REALLY sure though???\n\n\
|
||||
This action will certainly cause you to lose all funds associated with this wallet, \
|
||||
unless you have a backup of the seed words and know how to import them into a \
|
||||
new wallet.''', confirm_key='4'):
|
||||
new wallet.''', 'AGAIN...', confirm_key='4'):
|
||||
return await ux_aborted()
|
||||
|
||||
# clear all trick PINs from SE2
|
||||
@ -800,26 +803,37 @@ async def start_login_sequence():
|
||||
|
||||
# If that didn't work, or no skip defined, force
|
||||
# them to login successfully.
|
||||
|
||||
sp_unlock = False
|
||||
try:
|
||||
from trick_pins import tp
|
||||
|
||||
# Get a PIN and try to use it to login
|
||||
# - does warnings about attempt usage counts
|
||||
await block_until_login()
|
||||
|
||||
sp_unlock = tp.was_sp_unlock()
|
||||
if sp_unlock:
|
||||
# Trying to unlock spending policy: ask for main PIN next.
|
||||
await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.")
|
||||
pa.reset()
|
||||
await block_until_login()
|
||||
|
||||
# we don't really know if that was the Main PIN (could easily be the bypass
|
||||
# PIN again) and if it's a duress wallet, that's cool...
|
||||
|
||||
# Do we need to do countdown delay? (real or otherwise)
|
||||
# Q/Mk4 approach:
|
||||
# - wiping has already occured if that was picked
|
||||
# - wiping has already occured if that was selected by trick details
|
||||
# - delay is variable, stored in tc_arg
|
||||
from trick_pins import tp
|
||||
delay = tp.was_countdown_pin()
|
||||
|
||||
# Maybe they do know the right PIN, but do a delay anyway, because they wanted that
|
||||
# Maybe they do know the right PIN, but always do a delay anyway, because they wanted that
|
||||
if not delay:
|
||||
delay = settings.get('lgto', 0)
|
||||
|
||||
if delay:
|
||||
# kill some time, with countdown, and get "the" PIN again for real login
|
||||
pa.reset()
|
||||
|
||||
await ux_login_countdown(delay * (60 if not version.is_devmode else 1))
|
||||
|
||||
# keep it simple for Mk4+: just challenge again for any PIN
|
||||
@ -847,14 +861,32 @@ async def start_login_sequence():
|
||||
# handle upgrades/downgrade issues
|
||||
try:
|
||||
await version_migration()
|
||||
except:
|
||||
pass
|
||||
except: pass
|
||||
|
||||
# Maybe insist on the "right" microSD being already installed?
|
||||
try:
|
||||
from pwsave import MicroSD2FA
|
||||
MicroSD2FA.enforce_policy()
|
||||
except: pass # robustness: keep going!
|
||||
except: pass
|
||||
|
||||
# apply the hobbling for the spending policy, if appropriate
|
||||
try:
|
||||
from ccc import sssp_spending_policy, sssp_word_challenge
|
||||
|
||||
if sp_unlock and sssp_spending_policy('words'):
|
||||
# challenge them also for first and last seed word! (will reboot on fail)
|
||||
await sssp_word_challenge()
|
||||
dis.fullscreen("Startup...")
|
||||
|
||||
if sp_unlock:
|
||||
# Disable spending policy going forward; user has to re-enable.
|
||||
pa.hobbled_mode = False
|
||||
sssp_spending_policy('en', change=False)
|
||||
else:
|
||||
# normal entry mode, but might have policy enabled, if so enable it now.
|
||||
pa.hobbled_mode = sssp_spending_policy('en')
|
||||
|
||||
except: pass
|
||||
|
||||
# implement idle timeout now that we are logged-in
|
||||
IMPT.start_task('idle', idle_logout())
|
||||
@ -943,7 +975,7 @@ async def restore_main_secret(*a):
|
||||
goto_top_menu()
|
||||
|
||||
def make_top_menu():
|
||||
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu
|
||||
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu, HobbledTopMenu
|
||||
from glob import hsm_active, settings
|
||||
from pincodes import pa
|
||||
|
||||
@ -959,7 +991,9 @@ def make_top_menu():
|
||||
assert pa.is_successful(), "nonblank but wrong pin"
|
||||
|
||||
if pa.has_secrets():
|
||||
_cls = NormalSystem[:]
|
||||
# let them do a few things, but not all the things, when "hobbled"
|
||||
_cls = HobbledTopMenu[:] if pa.hobbled_mode else NormalSystem[:]
|
||||
|
||||
if pa.tmp_value or settings.get("hmx", False):
|
||||
active_xfp = settings.get("xfp", 0)
|
||||
sl, sr = ("[", "]") if pa.tmp_value else ("<", ">")
|
||||
@ -2013,7 +2047,7 @@ Write it down.'''
|
||||
while 1:
|
||||
lll.reset()
|
||||
lll.subtitle = "New " + title
|
||||
pin = await lll.get_new_pin(title)
|
||||
pin = await lll.get_new_pin()
|
||||
|
||||
if pin is None:
|
||||
return await ux_aborted()
|
||||
|
||||
@ -327,7 +327,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
async def interact(self):
|
||||
# Prompt user w/ details and get approval
|
||||
from glob import dis, hsm_active
|
||||
from ccc import CCCFeature
|
||||
from ccc import CCCFeature, SSSPFeature
|
||||
|
||||
# step 1: parse PSBT from PSRAM into in-memory objects.
|
||||
|
||||
@ -387,7 +387,13 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
|
||||
# early test for spending policy; not an error if violates policy
|
||||
# - might add warnings
|
||||
could_ccc_sign, needs_2fa = CCCFeature.could_sign(self.psbt)
|
||||
could_ccc_sign, ccc_needs_2fa = CCCFeature.could_cosign(self.psbt)
|
||||
|
||||
# test for allowing any signature when in single-signer mode
|
||||
# - but CCC will override it.
|
||||
should_block, ss_needs_2fa = SSSPFeature.can_allow(self.psbt)
|
||||
if should_block and not could_ccc_sign:
|
||||
return await self.failure('Spending Policy violation.')
|
||||
|
||||
# step 2: figure out what we are approving, so we can get sign-off
|
||||
# - outputs, amounts
|
||||
@ -500,7 +506,7 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
self.done()
|
||||
return
|
||||
|
||||
if needs_2fa and could_ccc_sign:
|
||||
if ccc_needs_2fa and could_ccc_sign:
|
||||
# They still need to pass web2fa challenge (but it meets other specs ok)
|
||||
try:
|
||||
await CCCFeature.web2fa_challenge()
|
||||
@ -510,6 +516,13 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
if ch2 != 'y':
|
||||
return await self.failure("2FA Failed")
|
||||
|
||||
elif ss_needs_2fa:
|
||||
# Need 2FA for single-sig case .. refuse to sign if it fails.
|
||||
try:
|
||||
await SSSPFeature.web2fa_challenge()
|
||||
except:
|
||||
return await self.failure("2FA Failed")
|
||||
|
||||
# do the actual signing.
|
||||
try:
|
||||
dis.fullscreen('Wait...')
|
||||
@ -517,9 +530,13 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
self.psbt.sign_it()
|
||||
|
||||
if could_ccc_sign:
|
||||
dis.fullscreen('CCC Sign...')
|
||||
# this is where the CCC co-signing happens.
|
||||
dis.fullscreen('Co-Signing...')
|
||||
gc.collect()
|
||||
CCCFeature.sign_psbt(self.psbt)
|
||||
else:
|
||||
# maybe capture new min-height for velocity limit
|
||||
SSSPFeature.update_last_signed(self.psbt)
|
||||
|
||||
except FraudulentChangeOutput as exc:
|
||||
return await self.failure(exc.args[0], title='Change Fraud')
|
||||
@ -530,8 +547,9 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
return await self.failure("Signing failed late", exc)
|
||||
|
||||
try:
|
||||
await done_signing(self.psbt, self, self.input_method, self.filename, self.output_encoder,
|
||||
slot_b=True if ch == "b" else False, finalize=self.do_finalize)
|
||||
await done_signing(self.psbt, self, self.input_method,
|
||||
self.filename, self.output_encoder,
|
||||
slot_b=(ch == "b"), finalize=self.do_finalize)
|
||||
self.done()
|
||||
except AbortInteraction:
|
||||
# user might have sent new sign cmd, while we still at export prompt
|
||||
|
||||
@ -101,6 +101,7 @@ def render_backup_contents(bypass_tmp=False):
|
||||
if k == 'words': continue # words length is recalculated from secret
|
||||
if k == 'ccc': continue # not supported, security issue
|
||||
if k == 'ktrx': continue # not useful after the fact
|
||||
if k == 'lfr': continue # temporary error msg value
|
||||
if k == 'seedvault' and not v: continue
|
||||
if k == 'seeds' and not v: continue
|
||||
ADD('setting.' + k, v)
|
||||
|
||||
719
shared/ccc.py
719
shared/ccc.py
@ -2,6 +2,13 @@
|
||||
#
|
||||
# ccc.py - ColdCard Co-sign feature. Be a leg in a 2-of-3 that is signed based on a policy.
|
||||
#
|
||||
# Rebranding/single-signer additions:
|
||||
#
|
||||
# - "CCC" (was "ColdCard Cosigning") will now be branded as "Spending Policy: Multisig"
|
||||
# - single singer policies will be called "Spending Policy: Single Sig"
|
||||
# - internally: CCC is the multisig stuff, vs SSSP: Single Signer Spending Policy
|
||||
# - "hobbled" refers to less-than full control over Coldcard, even though you have main PIN
|
||||
#
|
||||
import gc, chains, version, ngu, web2fa, bip39, re
|
||||
from chains import NLOCK_IS_TIME
|
||||
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address
|
||||
@ -11,18 +18,209 @@ from menu import MenuSystem, MenuItem, start_chooser
|
||||
from seed import seed_words_to_encoded_secret
|
||||
from stash import SecretStash
|
||||
from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC
|
||||
from exceptions import CCCPolicyViolationError
|
||||
from exceptions import SpendPolicyViolation
|
||||
|
||||
|
||||
# limit to number of addresses in list
|
||||
MAX_WHITELIST = const(25)
|
||||
|
||||
class CCCFeature:
|
||||
|
||||
# we don't show the user the reason for policy fail (by design, so attacker
|
||||
class LastFailReason:
|
||||
# We don't show the user the reason for policy fail (by design, so attacker
|
||||
# cannot maximize their take against the policy), but during setup/experiments
|
||||
# we offer to show the reason in the menu
|
||||
last_fail_reason = ""
|
||||
# we offer to show the reason in the menu. Includes both SS and MS cases.
|
||||
# - now holding this in a setting so they can power-cycle and bypass to view
|
||||
|
||||
@classmethod
|
||||
def record(cls, msg):
|
||||
settings.put('lfr', msg)
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
return settings.get('lfr', None)
|
||||
|
||||
@classmethod
|
||||
def clear(cls):
|
||||
settings.remove_key('lfr')
|
||||
|
||||
class SpendingPolicy(dict):
|
||||
# Details of what is allowed or not. Same for single vs. multisig signing.
|
||||
# - a dict() but with write-thru to setting value
|
||||
|
||||
def __init__(self, nvkey, pol_dict=None):
|
||||
# deserialize and construct
|
||||
#assert nvkey in { 'ccc', 'sssp' }
|
||||
self.nvkey = nvkey
|
||||
super().__init__()
|
||||
|
||||
if pol_dict is not None:
|
||||
self.clear()
|
||||
self.update(pol_dict.items())
|
||||
else:
|
||||
v = dict(settings.get(self.nvkey, {})).get('pol', None)
|
||||
if v is not None:
|
||||
self.update(v.items()) # mpy bugfix, when called with SpendingPolicy
|
||||
|
||||
|
||||
def _update_policy(self):
|
||||
# serialize the spending policy, save it
|
||||
v = dict(settings.get(self.nvkey, {}))
|
||||
v['pol'] = self.copy()
|
||||
settings.set(self.nvkey, v)
|
||||
|
||||
def update_policy_key(self, **kws):
|
||||
# update a few elements of the spending policy
|
||||
# - all settings "saved" as they are changed.
|
||||
# - return updated policy
|
||||
self.update(kws)
|
||||
self._update_policy()
|
||||
|
||||
def meets_policy(self, psbt):
|
||||
# Does policy allow signing this? Else raise why. Return T if web2fa required.
|
||||
pol = self
|
||||
|
||||
# not safe to sign any txn w/ warnings: might be complaining about
|
||||
# massive miner fees, or weird OP_RETURN stuff
|
||||
if psbt.warnings:
|
||||
raise SpendPolicyViolation("has warnings")
|
||||
|
||||
# Magnitude: size limits for output side (non change)
|
||||
magnitude = pol.get("mag", None)
|
||||
if magnitude is not None:
|
||||
if magnitude < 1000:
|
||||
# it is a BTC, convert to sats
|
||||
magnitude = magnitude * 100000000
|
||||
|
||||
outgoing = psbt.total_value_out - psbt.total_change_value
|
||||
if outgoing > magnitude:
|
||||
raise SpendPolicyViolation("magnitude")
|
||||
|
||||
# Velocity: if zero => no velocity checks
|
||||
velocity = pol.get("vel", None)
|
||||
if velocity:
|
||||
if not psbt.lock_time:
|
||||
raise SpendPolicyViolation("no nLockTime")
|
||||
|
||||
if psbt.lock_time >= NLOCK_IS_TIME:
|
||||
# this is unix timestamp - not allowed - fail
|
||||
raise SpendPolicyViolation("nLockTime not height")
|
||||
|
||||
block_h = pol.get("block_h", chains.current_chain().ccc_min_block)
|
||||
if psbt.lock_time <= block_h:
|
||||
raise SpendPolicyViolation("rewound (%d)" % psbt.lock_time)
|
||||
|
||||
# we won't sign txn unless old height + velocity >= new height
|
||||
if psbt.lock_time < (block_h + velocity):
|
||||
raise SpendPolicyViolation("velocity (%d)" % psbt.lock_time)
|
||||
|
||||
# Whitelist of outputs addresses
|
||||
wl = pol.get("addrs", None)
|
||||
if wl:
|
||||
c = chains.current_chain()
|
||||
wl = set(wl)
|
||||
for idx, txo in psbt.output_iter():
|
||||
out = psbt.outputs[idx]
|
||||
if not out.is_change: # ignore change
|
||||
addr = c.render_address(txo.scriptPubKey)
|
||||
if addr not in wl:
|
||||
raise SpendPolicyViolation("whitelist: " + addr)
|
||||
|
||||
# Web 2FA
|
||||
# - slow, requires UX, and they might not acheive it...
|
||||
# - wait until about to do signature
|
||||
if pol.get('web2fa', False):
|
||||
psbt.warnings.append(('CCC', 'Web 2FA required.'))
|
||||
return True
|
||||
|
||||
async def web2fa_challenge(self, msg):
|
||||
# they are trying to sign something, so make them get out their phone
|
||||
# - at this point they have already ok'ed the details of the txn
|
||||
# - and we have approved other elements of the spending policy.
|
||||
# - could show MS wallet name, or txn details but will not because that is
|
||||
# an info leak to Coinkite... and we just don't want to know.
|
||||
assert self.get('web2fa')
|
||||
|
||||
ok = await web2fa.perform_web2fa(msg, self.get('web2fa'))
|
||||
if not ok:
|
||||
LastFailReason.record('2FA Fail')
|
||||
raise SpendPolicyViolation
|
||||
|
||||
def update_last_signed(self, psbt):
|
||||
# Call after successfully signing a PSBT ... notes the height involved.
|
||||
# - might add other things besides height here someday
|
||||
LastFailReason.clear()
|
||||
|
||||
old_h = self.get('block_h', 1)
|
||||
|
||||
if old_h < psbt.lock_time < NLOCK_IS_TIME:
|
||||
# always update last block height, even if velocity isn't enabled yet
|
||||
# - attacker might have changed to testnet, but there is no
|
||||
# reason to ever lower block height. strictly ascending
|
||||
self.update_policy_key(block_h=psbt.lock_time)
|
||||
settings.save()
|
||||
|
||||
class SSSPFeature:
|
||||
# Using setting value "sssp"
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return sssp_spending_policy('en')
|
||||
|
||||
@classmethod
|
||||
def update_last_signed(cls, psbt):
|
||||
# new PSBT has been completely signed successfully.
|
||||
if not cls.is_enabled():
|
||||
return
|
||||
pol = cls.get_policy()
|
||||
pol.update_last_signed(psbt)
|
||||
|
||||
@classmethod
|
||||
def default_policy(cls):
|
||||
# a very basic and permissive policy, but non-zero too.
|
||||
# - 1BTC per day
|
||||
chain = chains.current_chain()
|
||||
return SpendingPolicy('sssp', dict(mag=1, vel=144,
|
||||
block_h=chain.ccc_min_block, web2fa='', addrs=[]))
|
||||
|
||||
@classmethod
|
||||
def get_policy(cls):
|
||||
# de-serialize just the spending policy
|
||||
return SpendingPolicy('sssp')
|
||||
|
||||
@classmethod
|
||||
def can_allow(cls, psbt):
|
||||
# We are looking at a PSBT: should we let user sign it, or block?
|
||||
# - return (block_signing, needs_2fa_step)
|
||||
if not cls.is_enabled():
|
||||
exists = bool(settings.get('sssp', False))
|
||||
if exists:
|
||||
# this will not block CCC co-signing, because that test is already
|
||||
# done before this call.
|
||||
psbt.warnings.append(('SP', "Spending Policy defined but disabled."))
|
||||
return False, False
|
||||
|
||||
try:
|
||||
# check policy
|
||||
pol = cls.get_policy()
|
||||
needs_2fa = pol.meets_policy(psbt)
|
||||
except SpendPolicyViolation as e:
|
||||
LastFailReason.record(str(e))
|
||||
# caller will show msg
|
||||
return True, False
|
||||
|
||||
return False, needs_2fa
|
||||
|
||||
@classmethod
|
||||
async def web2fa_challenge(cls):
|
||||
# they are trying to sign something, so make them get out their phone
|
||||
# - at this point they have already ok'ed the details of the txn
|
||||
# - and we have approved other elements of the spending policy.
|
||||
# - could show MS wallet name, or txn details but will not because that is
|
||||
# an info leak to Coinkite... and we just don't want to know.
|
||||
await cls.get_policy().perform_web2fa('Approve Transaction')
|
||||
|
||||
|
||||
class CCCFeature:
|
||||
# Using setting value "ccc"
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
@ -85,29 +283,13 @@ class CCCFeature:
|
||||
# a very basic and permissive policy, but non-zero too.
|
||||
# - 1BTC per day
|
||||
chain = chains.current_chain()
|
||||
return dict(mag=1, vel=144, block_h=chain.ccc_min_block, web2fa='', addrs=[])
|
||||
return SpendingPolicy('ccc', dict(mag=1, vel=144,
|
||||
block_h=chain.ccc_min_block, web2fa='', addrs=[]))
|
||||
|
||||
@classmethod
|
||||
def get_policy(cls):
|
||||
# de-serialize just the spending policy
|
||||
return dict(settings.get('ccc', dict(pol={})).get('pol'))
|
||||
|
||||
@classmethod
|
||||
def update_policy(cls, pol):
|
||||
# serialize the spending policy, save it
|
||||
v = dict(settings.get('ccc', {}))
|
||||
v['pol'] = dict(pol)
|
||||
settings.set('ccc', v)
|
||||
return v['pol']
|
||||
|
||||
@classmethod
|
||||
def update_policy_key(cls, **kws):
|
||||
# update a few elements of the spending policy
|
||||
# - all settings "saved" as they are changed.
|
||||
# - return updated policy
|
||||
p = cls.get_policy()
|
||||
p.update(kws)
|
||||
return cls.update_policy(p)
|
||||
return SpendingPolicy('ccc')
|
||||
|
||||
@classmethod
|
||||
def remove_ccc(cls):
|
||||
@ -117,75 +299,16 @@ class CCCFeature:
|
||||
settings.save()
|
||||
|
||||
@classmethod
|
||||
def meets_policy(cls, psbt):
|
||||
# Does policy allow signing this? Else raise why
|
||||
pol = cls.get_policy()
|
||||
|
||||
# not safe to sign any txn w/ warnings: might be complaining about
|
||||
# massive miner fees, or weird OP_RETURN stuff
|
||||
if psbt.warnings:
|
||||
raise CCCPolicyViolationError("has warnings")
|
||||
|
||||
# Magnitude: size limits for output side (non change)
|
||||
magnitude = pol.get("mag", None)
|
||||
if magnitude is not None:
|
||||
if magnitude < 1000:
|
||||
# it is a BTC, convert to sats
|
||||
magnitude = magnitude * 100000000
|
||||
|
||||
outgoing = psbt.total_value_out - psbt.total_change_value
|
||||
if outgoing > magnitude:
|
||||
raise CCCPolicyViolationError("magnitude")
|
||||
|
||||
# Velocity: if zero => no velocity checks
|
||||
velocity = pol.get("vel", None)
|
||||
if velocity:
|
||||
if not psbt.lock_time:
|
||||
raise CCCPolicyViolationError("no nLockTime")
|
||||
|
||||
if psbt.lock_time >= NLOCK_IS_TIME:
|
||||
# this is unix timestamp - not allowed - fail
|
||||
raise CCCPolicyViolationError("nLockTime not height")
|
||||
|
||||
block_h = pol.get("block_h", chains.current_chain().ccc_min_block)
|
||||
if psbt.lock_time <= block_h:
|
||||
raise CCCPolicyViolationError("rewound (%d)" % psbt.lock_time)
|
||||
|
||||
# we won't sign txn unless old height + velocity >= new height
|
||||
if psbt.lock_time < (block_h + velocity):
|
||||
raise CCCPolicyViolationError("velocity (%d)" % psbt.lock_time)
|
||||
|
||||
# Whitelist of outputs addresses
|
||||
wl = pol.get("addrs", None)
|
||||
if wl:
|
||||
c = chains.current_chain()
|
||||
wl = set(wl)
|
||||
for idx, txo in psbt.output_iter():
|
||||
out = psbt.outputs[idx]
|
||||
if not out.is_change: # ignore change
|
||||
addr = c.render_address(txo.scriptPubKey)
|
||||
if addr not in wl:
|
||||
raise CCCPolicyViolationError("whitelist")
|
||||
|
||||
# Web 2FA
|
||||
# - slow, requires UX, and they might not acheive it...
|
||||
# - wait until about to do signature
|
||||
if pol.get('web2fa', False):
|
||||
psbt.warnings.append(('CCC', 'Web 2FA required.'))
|
||||
return True
|
||||
|
||||
|
||||
@classmethod
|
||||
def could_sign(cls, psbt):
|
||||
def could_cosign(cls, psbt):
|
||||
# We are looking at a PSBT: can we sign it, and would we?
|
||||
# - if we **could** but will not, due to policy, add warning msg
|
||||
# - return (we could sign, needs 2fa step)
|
||||
if not cls.is_enabled:
|
||||
if not cls.is_enabled():
|
||||
return False, False
|
||||
|
||||
ms = psbt.active_multisig
|
||||
if not ms:
|
||||
# single-sig CCC not supported
|
||||
# not multisig, so ignore/permit
|
||||
return False, False
|
||||
|
||||
# TODO: if key B has already signed the PSBT, and so we don't need key C,
|
||||
@ -198,41 +321,29 @@ class CCCFeature:
|
||||
|
||||
try:
|
||||
# check policy
|
||||
needs_2fa = cls.meets_policy(psbt)
|
||||
except CCCPolicyViolationError as e:
|
||||
cls.last_fail_reason = str(e)
|
||||
pol = cls.get_policy()
|
||||
needs_2fa = pol.meets_policy(psbt)
|
||||
except SpendPolicyViolation as e:
|
||||
LastFailReason.record(str(e))
|
||||
psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
|
||||
return False, False
|
||||
|
||||
return True, needs_2fa
|
||||
|
||||
@classmethod
|
||||
async def web2fa_challenge(cls):
|
||||
# they are trying to sign something, so make them get out their phone
|
||||
# - at this point they have already ok'ed the details of the txn
|
||||
# - and we have approved other elements of the spending policy.
|
||||
# - could show MS wallet name, or txn details but will not because that is
|
||||
# an info leak to Coinkite... and we just don't want to know.
|
||||
pol = cls.get_policy()
|
||||
|
||||
ok = await web2fa.perform_web2fa('Approve CCC Transaction', pol.get('web2fa'))
|
||||
if not ok:
|
||||
cls.last_fail_reason = '2FA Fail'
|
||||
raise CCCPolicyViolationError
|
||||
|
||||
@classmethod
|
||||
def sign_psbt(cls, psbt):
|
||||
# do the math
|
||||
psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp())
|
||||
cls.last_fail_reason = ""
|
||||
LastFailReason.clear()
|
||||
|
||||
old_h = cls.get_policy().get('block_h', 1)
|
||||
if old_h < psbt.lock_time < NLOCK_IS_TIME:
|
||||
# always update last block height, even if velocity isn't enabled yet
|
||||
# - attacker might have changed to testnet, but there is no
|
||||
# reason to ever lower block height. strictly ascending
|
||||
cls.update_policy_key(block_h=psbt.lock_time)
|
||||
settings.save()
|
||||
pol = cls.get_policy()
|
||||
pol.update_last_signed(psbt)
|
||||
|
||||
@classmethod
|
||||
async def web2fa_challenge(cls):
|
||||
# do UX for web2fa; user is given option to proceed even if it fails
|
||||
# (without the co-signing)
|
||||
await cls.get_policy().web2fa_challenge('Approve Transaction: Co-Sign')
|
||||
|
||||
|
||||
def render_mag_value(mag):
|
||||
@ -257,9 +368,10 @@ class CCCConfigMenu(MenuSystem):
|
||||
|
||||
my_xfp = CCCFeature.get_xfp()
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('CCC [%s]' % xfp2str(my_xfp), f=self.show_ident),
|
||||
MenuItem('Spending Policy', menu=CCCPolicyMenu.be_a_submenu),
|
||||
MenuItem(('[%s] Co-Signing' if version.has_qwerty else '[%s]')
|
||||
% xfp2str(my_xfp), f=self.show_ident),
|
||||
MenuItem('Spending Policy',
|
||||
menu=lambda *a: SpendingPolicyMenu.be_a_submenu(CCCFeature.get_policy())),
|
||||
MenuItem('Export CCC XPUBs', f=self.export_xpub_c),
|
||||
MenuItem('Multisig Wallets'),
|
||||
]
|
||||
@ -274,7 +386,7 @@ class CCCConfigMenu(MenuSystem):
|
||||
|
||||
items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
|
||||
|
||||
if CCCFeature.last_fail_reason:
|
||||
if LastFailReason.get():
|
||||
# xxxxxxxxxxxxxxxx
|
||||
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
|
||||
|
||||
@ -291,12 +403,13 @@ class CCCConfigMenu(MenuSystem):
|
||||
if bh:
|
||||
msg += "CCC height:\n\n%s\n\n" % bh
|
||||
|
||||
lfr = LastFailReason.get()
|
||||
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
|
||||
% CCCFeature.last_fail_reason
|
||||
% lfr
|
||||
ch = await ux_show_story(msg, escape='4')
|
||||
|
||||
if ch == '4':
|
||||
CCCFeature.last_fail_reason = ''
|
||||
LastFailReason.clear()
|
||||
self.update_contents()
|
||||
|
||||
async def remove_ccc(self, *a):
|
||||
@ -379,23 +492,11 @@ be ready to show it as a QR, before proceeding.'''
|
||||
|
||||
goto_top_menu()
|
||||
|
||||
class PolCheckedMenuItem(MenuItem):
|
||||
# Show a checkmark if **policy** setting is defined and not the default
|
||||
# - only works inside CCCPolicyMenu
|
||||
def __init__(self, label, polkey, **kws):
|
||||
super().__init__(label, **kws)
|
||||
self.polkey = polkey
|
||||
|
||||
def is_chosen(self):
|
||||
# should we show a check in parent menu? check the policy
|
||||
m = the_ux.top_of_stack()
|
||||
#assert isinstance(m, CCCPolicyMenu)
|
||||
return bool(m.policy.get(self.polkey, False))
|
||||
|
||||
|
||||
class CCCAddrWhitelist(MenuSystem):
|
||||
class SPAddrWhitelist(MenuSystem):
|
||||
# simulator arg: --seq tcENTERENTERsENTERwENTER
|
||||
def __init__(self):
|
||||
def __init__(self, pol):
|
||||
self.policy = pol
|
||||
items = self.construct()
|
||||
super().__init__(items)
|
||||
|
||||
@ -404,12 +505,12 @@ class CCCAddrWhitelist(MenuSystem):
|
||||
self.replace_items(tmp)
|
||||
|
||||
@classmethod
|
||||
async def be_a_submenu(cls, *a):
|
||||
return cls()
|
||||
async def be_a_submenu(cls, pol, *a):
|
||||
return cls(pol)
|
||||
|
||||
def construct(self):
|
||||
# list of addresses
|
||||
addrs = CCCFeature.get_policy().get('addrs', [])
|
||||
addrs = self.policy.get('addrs', [])
|
||||
maxxed = (len(addrs) >= MAX_WHITELIST)
|
||||
|
||||
items = []
|
||||
@ -444,15 +545,14 @@ class CCCAddrWhitelist(MenuSystem):
|
||||
|
||||
def delete_addr(self, addr):
|
||||
# no confirm, stakes are low
|
||||
addrs = CCCFeature.get_policy().get('addrs', [])
|
||||
addrs = self.policy.get('addrs', [])
|
||||
addrs.remove(addr)
|
||||
CCCFeature.update_policy_key(addrs=addrs)
|
||||
self.policy.update_policy_key(addrs=addrs)
|
||||
self.update_contents()
|
||||
|
||||
async def clear_all(self, *a):
|
||||
if await ux_confirm("Irreversibly remove all addresses from the whitelist?",
|
||||
confirm_key='4'):
|
||||
CCCFeature.update_policy_key(addrs=[])
|
||||
if await ux_confirm("Remove all addresses from the whitelist?", confirm_key='4'):
|
||||
self.policy.update_policy_key(addrs=[])
|
||||
self.update_contents()
|
||||
|
||||
async def import_file(self, *a):
|
||||
@ -537,7 +637,7 @@ class CCCAddrWhitelist(MenuSystem):
|
||||
|
||||
async def add_addresses(self, more_addrs):
|
||||
# add new entries, if unique; preserve ordering
|
||||
addrs = CCCFeature.get_policy().get('addrs', [])
|
||||
addrs = self.policy.get('addrs', [])
|
||||
new = []
|
||||
for a in more_addrs:
|
||||
if a not in addrs:
|
||||
@ -552,23 +652,39 @@ class CCCAddrWhitelist(MenuSystem):
|
||||
if len(addrs) > MAX_WHITELIST:
|
||||
return await self.maxed_out()
|
||||
|
||||
CCCFeature.update_policy_key(addrs=addrs)
|
||||
self.policy.update_policy_key(addrs=addrs)
|
||||
self.update_contents()
|
||||
|
||||
if len(new) > 1:
|
||||
await ux_show_story("Added %d new addresses to whitelist:\n\n%s" %
|
||||
(len(new), '\n\n'.join(show_single_address(a) for a in new)))
|
||||
else:
|
||||
await ux_show_story("Added new address to whitelist:\n\n%s" % show_single_address(new[0]))
|
||||
await ux_show_story("Added new address to whitelist:\n\n%s" %
|
||||
show_single_address(new[0]))
|
||||
|
||||
class CCCPolicyMenu(MenuSystem):
|
||||
class SPCheckedMenuItem(MenuItem):
|
||||
# Show a checkmark if **policy** setting is defined and not the default
|
||||
# - only works inside SpendingPolicyMenu
|
||||
def __init__(self, label, polkey, **kws):
|
||||
super().__init__(label, **kws)
|
||||
self.polkey = polkey
|
||||
|
||||
def is_chosen(self):
|
||||
# should we show a check in parent menu? check the policy
|
||||
m = the_ux.top_of_stack()
|
||||
#assert isinstance(m, SpendingPolicyMenu)
|
||||
return bool(m.policy.get(self.polkey, False))
|
||||
|
||||
class SpendingPolicyMenu(MenuSystem):
|
||||
# Build menu stack that allows edit of all features of the spending
|
||||
# policy. Key C is set already at this point.
|
||||
# policy.
|
||||
# - supports both CCC and SSSP modes w/ same policies
|
||||
# - Key C is set already at this point.
|
||||
# - and delete/cancel CCC (clears setting?)
|
||||
# - be a sticky menu that's hard to exit (ie. SAVE choice and no cancel out)
|
||||
|
||||
def __init__(self):
|
||||
self.policy = CCCFeature.get_policy()
|
||||
def __init__(self, pol):
|
||||
self.policy = pol
|
||||
items = self.construct()
|
||||
super().__init__(items)
|
||||
|
||||
@ -577,17 +693,18 @@ class CCCPolicyMenu(MenuSystem):
|
||||
self.replace_items(tmp)
|
||||
|
||||
@classmethod
|
||||
async def be_a_submenu(cls, *a):
|
||||
return cls()
|
||||
async def be_a_submenu(cls, pol, *a):
|
||||
return cls(pol)
|
||||
|
||||
def construct(self):
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
PolCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude),
|
||||
PolCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity),
|
||||
PolCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''),
|
||||
'addrs', menu=CCCAddrWhitelist.be_a_submenu),
|
||||
PolCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa),
|
||||
SPCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude),
|
||||
SPCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity),
|
||||
SPCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''),
|
||||
'addrs',
|
||||
menu=lambda *a: SPAddrWhitelist.be_a_submenu(self.policy)),
|
||||
SPCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa),
|
||||
]
|
||||
|
||||
if self.policy.get('web2fa'):
|
||||
@ -601,15 +718,15 @@ class CCCPolicyMenu(MenuSystem):
|
||||
async def test_2fa(self, *a):
|
||||
ss = self.policy.get('web2fa')
|
||||
assert ss
|
||||
ok = await web2fa.perform_web2fa('CCC Test', ss)
|
||||
ok = await web2fa.perform_web2fa('Testing Only', ss)
|
||||
|
||||
await ux_show_story('Correct code was given.' if ok else 'Failed or aborted.')
|
||||
|
||||
async def enroll_more_2fa(self, *a):
|
||||
# let more phones in on the party
|
||||
# let more phones in on the party, but they get same shared secret
|
||||
ss = self.policy.get('web2fa')
|
||||
assert ss
|
||||
await web2fa.web2fa_enroll('CCC', ss)
|
||||
await web2fa.web2fa_enroll(ss)
|
||||
|
||||
async def set_magnitude(self, *a):
|
||||
# Looks decent on both Q and Mk4...
|
||||
@ -633,7 +750,7 @@ class CCCPolicyMenu(MenuSystem):
|
||||
else:
|
||||
msg += " maximum per-transaction: \n\n %s" % render_mag_value(val)
|
||||
|
||||
self.policy = CCCFeature.update_policy_key(**args)
|
||||
self.policy.update_policy_key(**args)
|
||||
|
||||
await ux_show_story(msg, title="TX Magnitude")
|
||||
|
||||
@ -643,7 +760,7 @@ class CCCPolicyMenu(MenuSystem):
|
||||
if not mag:
|
||||
msg = 'Velocity limit requires a per-transaction magnitude to be set.'\
|
||||
' This has been set to 1BTC as a starting value.'
|
||||
self.policy = CCCFeature.update_policy_key(mag=1)
|
||||
self.policy.update_policy_key(mag=1)
|
||||
|
||||
await ux_show_story(msg)
|
||||
|
||||
@ -678,7 +795,7 @@ class CCCPolicyMenu(MenuSystem):
|
||||
which = 0
|
||||
|
||||
def set(idx, text):
|
||||
self.policy = CCCFeature.update_policy_key(vel=va[idx])
|
||||
self.policy.update_policy_key(vel=va[idx])
|
||||
|
||||
return which, ch, set
|
||||
|
||||
@ -689,7 +806,7 @@ class CCCPolicyMenu(MenuSystem):
|
||||
if not await ux_confirm("Disable web 2FA check? Effect is immediate."):
|
||||
return
|
||||
|
||||
self.policy = CCCFeature.update_policy_key(web2fa='')
|
||||
self.policy.update_policy_key(web2fa='')
|
||||
self.update_contents()
|
||||
|
||||
await ux_show_story("Web 2FA has been disabled. If you re-enable it, a new "
|
||||
@ -709,12 +826,12 @@ phone with Internet access and 2FA app holding correct shared-secret.''',
|
||||
return
|
||||
|
||||
# challenge them, and don't set unless it works
|
||||
ss = await web2fa.web2fa_enroll('CCC')
|
||||
ss = await web2fa.web2fa_enroll()
|
||||
if not ss:
|
||||
return
|
||||
|
||||
# update state
|
||||
self.policy = CCCFeature.update_policy_key(web2fa=ss)
|
||||
self.policy.update_policy_key(web2fa=ss)
|
||||
self.update_contents()
|
||||
|
||||
async def gen_or_import():
|
||||
@ -886,4 +1003,278 @@ async def key_c_challenge(words):
|
||||
m = CCCConfigMenu()
|
||||
the_ux.push(m)
|
||||
|
||||
def sssp_spending_policy(key, default=False, change=None):
|
||||
# This function can be used to check if feature(s) are enabled in
|
||||
# the single-signer policy settings. Might be used while hobbled.
|
||||
# keys:
|
||||
# 'en' = feature enabled; hobble on next boot
|
||||
# 'notes' = allow access to knows
|
||||
# 'words' = add first/last seed words to challenge to unlock
|
||||
# 'okeys' = allow BIP-39 and/or seed vault
|
||||
|
||||
v = settings.get('sssp', dict())
|
||||
|
||||
if key in { 'en', 'notes', 'words', 'okeys' }:
|
||||
# booleans: present or removed from dict
|
||||
if change is not None:
|
||||
if change:
|
||||
v[key] = True
|
||||
else:
|
||||
v.pop(key, None)
|
||||
|
||||
settings.put('sssp', v)
|
||||
settings.save()
|
||||
|
||||
return (key in v) or default
|
||||
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
async def sssp_feature_menu(*a):
|
||||
# Show the top menu for SSSP feature, or enable access first time.
|
||||
from pincodes import pa
|
||||
from actions import goto_top_menu
|
||||
|
||||
if pa.hobbled_mode == 2:
|
||||
# allow exit from test-drive mode, directly into editing settings
|
||||
pa.hobbled_mode = False
|
||||
goto_top_menu()
|
||||
elif settings.get('sssp'):
|
||||
# normal entry into menu system, after the first time
|
||||
assert not pa.hobbled_mode
|
||||
else:
|
||||
# tell them a story, and maybe enable feature
|
||||
en = await sssp_enable()
|
||||
if not en: return
|
||||
|
||||
m = SSSPConfigMenu()
|
||||
the_ux.push(m)
|
||||
|
||||
async def sssp_enable():
|
||||
# enabling the feature
|
||||
# - collect and setup a new trick pin
|
||||
# - set sssp settings w/ something non-empty but still disabled.
|
||||
# - return T if they completed enabling process
|
||||
|
||||
from login import LoginUX
|
||||
from trick_pins import tp
|
||||
from pincodes import pa
|
||||
|
||||
# enable the feature -- not simple!
|
||||
# - pick new (trick pin) that lets you back here.
|
||||
# - collect a policy setup, maybe 2FA enrol too
|
||||
# - lock that down
|
||||
ch = await ux_show_story('''\
|
||||
You can define a "spending policy" which stops you from signing \
|
||||
transactions unless conditions are met.
|
||||
Spending policies can restrict: magnitude (BTC out), \
|
||||
velocity (blocks between txn), address whitelisting, \
|
||||
and/or require confirmation by 2FA phone app.
|
||||
|
||||
When active, your COLDCARD \
|
||||
is locked into a special mode that restricts seed access, backups, settings and other features.
|
||||
|
||||
First step is to define a new PIN code that is used when you want to bypass or \
|
||||
disable this feature.
|
||||
''',
|
||||
title="Spending Policy")
|
||||
|
||||
if ch != 'y':
|
||||
# just a tourist
|
||||
return
|
||||
|
||||
# re-use existing PIN if there for some reason
|
||||
new_pin = tp.has_sp_unlock()
|
||||
|
||||
if not new_pin:
|
||||
have = tp.all_tricks()
|
||||
main_pin = pa.pin.decode()
|
||||
while 1:
|
||||
lll = LoginUX()
|
||||
lll.is_setting = True
|
||||
lll.subtitle = "Spending Policy" + (" Unlock" if version.has_qwerty else '')
|
||||
|
||||
new_pin = await lll.get_new_pin()
|
||||
if new_pin is None:
|
||||
return
|
||||
|
||||
# weak check - does not spot hidden trick pins
|
||||
if (new_pin != main_pin) and (new_pin not in have):
|
||||
# verify uniqueness with SE2
|
||||
b, slot = tp.get_by_pin(new_pin)
|
||||
if slot is None:
|
||||
tp.define_unlock_pin(new_pin)
|
||||
break
|
||||
|
||||
await tp.err_unique_pin(new_pin)
|
||||
|
||||
# all features disabled to start
|
||||
settings.set('sssp', dict(en=False, pol={}))
|
||||
settings.save()
|
||||
|
||||
# continue into config menu
|
||||
return True
|
||||
|
||||
async def sssp_word_challenge(*a):
|
||||
# Ask for first/last seed word and verify. Return if correct answers given.
|
||||
# Reboots on failure.
|
||||
from stash import SensitiveValues
|
||||
|
||||
with SensitiveValues() as sv:
|
||||
if sv.mode != 'words':
|
||||
# they are using XPRV or something, skip test entirely
|
||||
return
|
||||
|
||||
words = bip39.b2a_words(sv.raw).split(' ')
|
||||
want_words = words[:1] + words[-1:]
|
||||
assert len(want_words) == 2
|
||||
|
||||
for retry in range(2):
|
||||
if version.has_qwerty:
|
||||
# see special rendering code for this case in ux_q1.py:ux_draw_words(num_words=2)
|
||||
from ux_q1 import seed_word_entry
|
||||
got_words = await seed_word_entry('First and Last Seed Words', 2, has_checksum=False)
|
||||
else:
|
||||
from seed import WordNestMenu
|
||||
got_words = await WordNestMenu.get_n_words(2)
|
||||
|
||||
if got_words == want_words:
|
||||
# success - done
|
||||
return
|
||||
|
||||
await ux_show_story("Sorry, those words are incorrect.")
|
||||
|
||||
# they failed; log them out ... they can just try login again
|
||||
from actions import login_now
|
||||
await login_now()
|
||||
|
||||
# NOT-REACHED
|
||||
|
||||
class SSSPCheckedMenuItem(MenuItem):
|
||||
# Show a checkmark if **top level** security setting is defined and not the default
|
||||
# - only works inside SSSPPolicyMenu?
|
||||
# - similar to menu.py:ToggleMenuItem
|
||||
|
||||
def __init__(self, label, polkey, story, **kws):
|
||||
super().__init__(label, **kws)
|
||||
self.polkey = polkey
|
||||
self.story = story
|
||||
|
||||
def is_chosen(self):
|
||||
# should we show a check in menu? check the current SSSP settings
|
||||
return sssp_spending_policy(self.polkey)
|
||||
|
||||
async def activate(self, menu, idx):
|
||||
# do simple toggle on request
|
||||
was = sssp_spending_policy(self.polkey)
|
||||
|
||||
msg = self.story + "\n\n%s?" % ('Disable' if was else 'Enable')
|
||||
|
||||
ch = await ux_show_story(msg)
|
||||
if ch == 'x': return
|
||||
|
||||
sssp_spending_policy(self.polkey, change=(not was))
|
||||
|
||||
|
||||
class SSSPConfigMenu(MenuSystem):
|
||||
def __init__(self):
|
||||
items = self.construct()
|
||||
super().__init__(items)
|
||||
|
||||
def update_contents(self):
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
def construct(self):
|
||||
from multisig import MultisigWallet, make_ms_wallet_menu
|
||||
|
||||
items = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Edit Policy...',
|
||||
menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())),
|
||||
SSSPCheckedMenuItem('Word Check', 'words', 'To change Spending Policy, in addition to special PIN, you must provide the first and last seed words.'),
|
||||
SSSPCheckedMenuItem('Allow Notes', 'notes', 'Allow (read-only) access to secure notes and passwords? Otherwise, they are inaccessible.'),
|
||||
SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, and Seed Vault (read-only). Single Spending Policy applies to all.'),
|
||||
#MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
|
||||
]
|
||||
|
||||
if LastFailReason.get():
|
||||
# xxxxxxxxxxxxxxxx
|
||||
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
|
||||
|
||||
items.append(MenuItem('Remove Policy', f=self.remove_sssp))
|
||||
items.append(MenuItem('Test Drive', f=self.test_drive))
|
||||
items.append(MenuItem('ACTIVATE', f=self.activate_feature))
|
||||
|
||||
return items
|
||||
|
||||
async def activate_feature(self, *a):
|
||||
# Policy is being set in stone now; confirm and switch to hobble mode, etc.
|
||||
from trick_pins import tp
|
||||
|
||||
bypass_pin = tp.has_sp_unlock()
|
||||
|
||||
if not bypass_pin:
|
||||
msg = "You have no Spending Policy bypass PIN defined, so changes to this COLDCARD cannot be made past this point. Only option will be to destroy seed and reload everything."
|
||||
else:
|
||||
msg = "To return to normal unlimited spending mode, you will need to enter the special pin (%s), then the Main PIN" % bypass_pin
|
||||
if sssp_spending_policy('words'):
|
||||
msg += ', followed by the first and last seed words'
|
||||
msg += '.'
|
||||
|
||||
if not await ux_confirm(msg, 'CONTINUE?'):
|
||||
return
|
||||
|
||||
# set it for next login
|
||||
sssp_spending_policy('en', change=True)
|
||||
|
||||
# make it real ... could reboot here instead, but no need.
|
||||
from pincodes import pa
|
||||
from actions import goto_top_menu
|
||||
|
||||
pa.hobbled_mode = True
|
||||
goto_top_menu()
|
||||
|
||||
async def test_drive(self, *a):
|
||||
# allow test drive of feature
|
||||
if not await ux_confirm("See what COLDCARD operation will look like with Spending Policy enabled.", 'CONTINUE?'):
|
||||
return
|
||||
|
||||
from pincodes import pa
|
||||
from actions import goto_top_menu
|
||||
|
||||
pa.hobbled_mode = 2 # Truthy value to indicate they can escape easily
|
||||
goto_top_menu()
|
||||
|
||||
async def debug_last_fail(self, *a):
|
||||
# debug for customers: why did we reject that last txn?
|
||||
pol = SSSPFeature.get_policy()
|
||||
bh = pol.get('block_h', None)
|
||||
msg = ''
|
||||
if bh:
|
||||
msg += "Last height:\n\n%s\n\n" % bh
|
||||
|
||||
lfr = LastFailReason.get()
|
||||
msg += 'The most recent policy check failed because of:\n\n%s\n\nPress (4) to clear.' \
|
||||
% lfr
|
||||
ch = await ux_show_story(msg, escape='4')
|
||||
|
||||
if ch == '4':
|
||||
LastFailReason.clear()
|
||||
self.update_contents()
|
||||
|
||||
async def remove_sssp(self, *a):
|
||||
# disable and remove feature
|
||||
if not await ux_confirm('Bypass PIN will be removed, and all spending policy settings forgotten.'):
|
||||
return
|
||||
|
||||
settings.remove_key('sssp')
|
||||
settings.save()
|
||||
|
||||
from trick_pins import tp
|
||||
tp.delete_sp_unlock_pins()
|
||||
|
||||
the_ux.pop()
|
||||
|
||||
|
||||
# EOF
|
||||
|
||||
@ -139,6 +139,11 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
|
||||
|
||||
elif ty in 'RSE':
|
||||
# key-teleport related
|
||||
|
||||
from pincodes import pa
|
||||
if pa.hobbled_mode and ty != 'E':
|
||||
raise QRDecodeExplained("KT Blocked")
|
||||
|
||||
if ty == 'R' and len(got) != 33:
|
||||
raise QRDecodeExplained("Truncated KT RX")
|
||||
|
||||
|
||||
@ -266,17 +266,18 @@ class Display:
|
||||
if is_sel:
|
||||
self.dis.fill_rect(0, y, Display.WIDTH, h-1, 1)
|
||||
self.icon(2, y, 'wedge', invert=1)
|
||||
self.text(x, y, msg, invert=1)
|
||||
nx = self.text(x, y, msg, invert=1)
|
||||
else:
|
||||
self.text(x, y, msg)
|
||||
nx = self.text(x, y, msg)
|
||||
|
||||
# LATER: removed because caused confusion w/ underscore
|
||||
#if msg[0] == ' ' and space_indicators:
|
||||
# see also graphics/mono/space.txt
|
||||
#self.icon(x-2, y+9, 'space', invert=is_sel)
|
||||
|
||||
if is_checked:
|
||||
self.icon(108, y, 'selected', invert=is_sel)
|
||||
if is_checked and nx <= 113:
|
||||
# omit checkmark if it doesn't fit
|
||||
self.icon(113, y, 'selected', invert=is_sel)
|
||||
|
||||
def menu_show(self, *a):
|
||||
self.show()
|
||||
|
||||
@ -19,10 +19,10 @@ class CCBusyError(RuntimeError):
|
||||
# HSM is blocking your action
|
||||
class HSMDenied(RuntimeError):
|
||||
pass
|
||||
|
||||
class HSMCMDDisabled(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# PSBT / transaction related
|
||||
class FatalPSBTIssue(RuntimeError):
|
||||
pass
|
||||
@ -51,8 +51,8 @@ class QRDecodeExplained(ValueError):
|
||||
class UnknownAddressExplained(ValueError):
|
||||
pass
|
||||
|
||||
# We're not going to co-sign using CCC feature
|
||||
class CCCPolicyViolationError(RuntimeError):
|
||||
# We're not going to (co-)sign using spending policy features
|
||||
class SpendPolicyViolation(RuntimeError):
|
||||
pass
|
||||
|
||||
# EOF
|
||||
|
||||
119
shared/flow.py
119
shared/flow.py
@ -19,7 +19,7 @@ from countdowns import countdown_chooser
|
||||
from paper import make_paper_wallet
|
||||
from trick_pins import TrickPinMenu
|
||||
from tapsigner import import_tapsigner_backup_file
|
||||
from ccc import toggle_ccc_feature
|
||||
from ccc import toggle_ccc_feature, sssp_spending_policy, sssp_feature_menu
|
||||
|
||||
# useful shortcut keys
|
||||
from charcodes import KEY_QR, KEY_NFC
|
||||
@ -100,6 +100,33 @@ def hsm_available():
|
||||
# contains hsm feature + can it be used (needs se2 secret and no tmp active)
|
||||
return version.supports_hsm and has_real_secret()
|
||||
|
||||
def qr_and_ms():
|
||||
# has QR scanner, and at least one MS wallet
|
||||
if not version.has_qr: return False
|
||||
return bool(settings.get('multisig', False))
|
||||
|
||||
def has_pushtx_url():
|
||||
# they want to use PushTX feature
|
||||
return bool(settings.get("ptxurl", False))
|
||||
|
||||
# Spending Policy (Hobbled mode) predicates.
|
||||
#
|
||||
def is_hobble_testdrive():
|
||||
from pincodes import pa
|
||||
return (pa.hobbled_mode == 2)
|
||||
|
||||
def sssp_related_keys():
|
||||
return sssp_spending_policy('okeys')
|
||||
|
||||
def sssp_allow_passphrase():
|
||||
return word_based_seed() and sssp_related_keys()
|
||||
|
||||
def sssp_allow_notes():
|
||||
return settings.get("secnap", False) and sssp_spending_policy('notes')
|
||||
|
||||
def sssp_allow_vault():
|
||||
return settings.master_get('seedvault') and sssp_related_keys()
|
||||
|
||||
async def goto_home(*a):
|
||||
goto_top_menu()
|
||||
|
||||
@ -355,7 +382,20 @@ NFCToolsMenu = [
|
||||
MenuItem('Verify Address', f=nfc_address_verify),
|
||||
MenuItem('File Share', f=nfc_share_file),
|
||||
MenuItem('Import Multisig', f=import_multisig_nfc),
|
||||
MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=lambda: settings.get("ptxurl", False)),
|
||||
MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=has_pushtx_url),
|
||||
]
|
||||
|
||||
|
||||
SpendingPolicySubMenu = [
|
||||
NonDefaultMenuItem('Single-Signer', 'sssp', f=sssp_feature_menu, predicate=has_real_secret),
|
||||
NonDefaultMenuItem('Co-Sign Multi.' if not version.has_qwerty else 'Co-Sign Multisig (CCC)',
|
||||
'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
|
||||
ToggleMenuItem('HSM Mode', 'hsmcmd', ['Default Off', 'Enable'],
|
||||
story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
|
||||
"By default these commands are disabled."),
|
||||
predicate=hsm_available),
|
||||
MenuItem('User Management', menu=make_users_menu,
|
||||
predicate=lambda: hsm_available() and settings.get('hsmcmd', False)),
|
||||
]
|
||||
|
||||
AdvancedNormalMenu = [
|
||||
@ -371,14 +411,8 @@ AdvancedNormalMenu = [
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
|
||||
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
|
||||
MenuItem("Spending Policy", menu=SpendingPolicySubMenu, shortcut='s'),
|
||||
MenuItem('Paper Wallets', f=make_paper_wallet),
|
||||
ToggleMenuItem('Enable HSM', 'hsmcmd', ['Default Off', 'Enable'],
|
||||
story=("Enable HSM? Enables all user management commands, and other HSM-only USB commands. "
|
||||
"By default these commands are disabled."),
|
||||
predicate=hsm_available),
|
||||
NonDefaultMenuItem('Coldcard Co-Signing', 'ccc', f=toggle_ccc_feature, predicate=is_not_tmp),
|
||||
MenuItem('User Management', menu=make_users_menu,
|
||||
predicate=hsm_available),
|
||||
MenuItem('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
|
||||
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
|
||||
]
|
||||
@ -460,3 +494,70 @@ FactoryMenu = [
|
||||
MenuItem("Debug Functions", menu=DebugFunctionsMenu, shortcut='f'),
|
||||
MenuItem("Perform Selftest", f=start_selftest, shortcut='s'),
|
||||
]
|
||||
|
||||
# Special menus for hobbled mode where we have a (single signer) spending policy in effect.
|
||||
# - no access to secrets, backups, firmware up/downgrades.
|
||||
# - secure notes, but readonly; can be disabled completely.
|
||||
# - key teleport, but only for PSBT & multisig purposes.
|
||||
# - can only be enabled after we have secrets, so no need for has_secrets tests here
|
||||
#
|
||||
|
||||
# Slightly limited file menu when hobbled.
|
||||
# - no backup/restore
|
||||
HobbledFileMgmtMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Sign Text File', f=sign_message_on_sd),
|
||||
MenuItem('Batch Sign PSBT', f=batch_sign),
|
||||
MenuItem('List Files', f=list_files),
|
||||
MenuItem('Export Wallet', menu=WalletExportMenu), # dup under Adv/Tools
|
||||
MenuItem('Verify Sig File', f=verify_sig_file),
|
||||
MenuItem('NFC File Share', predicate=nfc_enabled, f=nfc_share_file, shortcut=KEY_NFC),
|
||||
MenuItem('BBQr File Share', predicate=version.has_qr, f=qr_share_file, arg=True),
|
||||
MenuItem('QR File Share', predicate=version.has_qr, f=qr_share_file, shortcut=KEY_QR),
|
||||
MenuItem('Format SD Card', f=wipe_sd_card),
|
||||
MenuItem('Format RAM Disk', predicate=vdisk_enabled, f=wipe_vdisk),
|
||||
]
|
||||
|
||||
# NFC tools when hobbled: not much different.
|
||||
HobbledNFCToolsMenu = [
|
||||
MenuItem('Sign PSBT', f=nfc_sign_psbt),
|
||||
MenuItem('Show Address', f=nfc_show_address),
|
||||
MenuItem('Sign Message', f=nfc_sign_msg),
|
||||
MenuItem('Verify Sig File', f=nfc_sign_verify),
|
||||
MenuItem('Verify Address', f=nfc_address_verify),
|
||||
MenuItem('File Share', f=nfc_share_file),
|
||||
MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=has_pushtx_url),
|
||||
]
|
||||
|
||||
# Very limited advanced menu when hobbled.
|
||||
HobbledAdvancedMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem("File Management", menu=HobbledFileMgmtMenu),
|
||||
MenuItem('Export Wallet', menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt
|
||||
MenuItem('Teleport Multisig PSBT', predicate=qr_and_ms, f=kt_send_file_psbt),
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu, predicate=sssp_related_keys),
|
||||
MenuItem('Paper Wallets', f=make_paper_wallet),
|
||||
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
|
||||
MenuItem("Destroy Seed", f=clear_seed),
|
||||
]
|
||||
|
||||
# Main menu when a spending policy (hobbled) is in effect.
|
||||
HobbledTopMenu = [
|
||||
# xxxxxxxxxxxxxxxx
|
||||
MenuItem('Ready To Sign', f=ready2sign, shortcut='r'),
|
||||
MenuItem('Passphrase', menu=start_b39_pw, predicate=sssp_allow_passphrase, shortcut='p'),
|
||||
MenuItem('Scan Any QR Code', predicate=version.has_qr, f=scan_any_qr, arg=(False, True),
|
||||
shortcut=KEY_QR),
|
||||
MenuItem("Address Explorer", menu=address_explore, shortcut='x'),
|
||||
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, predicate=sssp_allow_notes,
|
||||
shortcut='n'),
|
||||
MenuItem('Type Passwords', f=password_entry, shortcut='t',
|
||||
predicate=lambda: settings.get("emu", False)),
|
||||
MenuItem('Seed Vault', menu=make_seed_vault_menu, predicate=sssp_allow_vault,
|
||||
shortcut='v'),
|
||||
MenuItem('Advanced/Tools', menu=HobbledAdvancedMenu, shortcut='t'),
|
||||
MenuItem('Secure Logout', f=logout_now, predicate=not version.has_battery),
|
||||
MenuItem('EXIT TEST DRIVE', f=sssp_feature_menu, predicate=is_hobble_testdrive),
|
||||
ShortcutItem(KEY_NFC, predicate=nfc_enabled, menu=HobbledNFCToolsMenu),
|
||||
]
|
||||
|
||||
@ -58,8 +58,8 @@ class ImportantTask:
|
||||
else:
|
||||
# uncaught exception in an unnamed (and unimportant) task
|
||||
print("UNNAMED: " + context["message"])
|
||||
# sys.print_exception(context["exception"])
|
||||
print("... future: %r" % context.get("future", '?'))
|
||||
sys.print_exception(context["exception"]) # VERY USEFUL on sim
|
||||
#print("... future: %r" % context.get("future", '?'))
|
||||
|
||||
def start_task(self, name, awaitable):
|
||||
# start a critical task and watch for it to never die
|
||||
|
||||
@ -270,7 +270,7 @@ suffix break point is correct.\n\n'''
|
||||
return await self.interact()
|
||||
|
||||
|
||||
async def get_new_pin(self, title, story=None):
|
||||
async def get_new_pin(self, title=None, story=None):
|
||||
# Do UX flow to get new (or change) PIN. Always does the double-entry thing
|
||||
self.is_setting = True
|
||||
|
||||
|
||||
@ -119,7 +119,7 @@ class ShortcutItem(MenuItem):
|
||||
super().__init__('SHORTCUT', shortcut=key, **kws)
|
||||
|
||||
class NonDefaultMenuItem(MenuItem):
|
||||
# Show a checkmark if setting is defined and not the default ... so know know it's set
|
||||
# Show a checkmark if setting is defined and not the default
|
||||
def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
|
||||
super().__init__(label, **kws)
|
||||
self.nvkey = nvkey
|
||||
@ -306,10 +306,6 @@ class MenuSystem:
|
||||
if fcn and fcn():
|
||||
checked = True
|
||||
|
||||
if not has_qwerty and checked and (len(msg) > 14):
|
||||
# on mk4 every label longer than 14 will overlap with checkmark
|
||||
checked = False
|
||||
|
||||
if self.multi_selected is not None and (real_idx in self.multi_selected):
|
||||
# ignore length constraint above, we need to visually show that
|
||||
# smthg is selected - in any case
|
||||
|
||||
@ -21,6 +21,16 @@ from utils import problem_file_line, url_unquote, wipe_if_deltamode
|
||||
ONE_LINE = CHARS_W-2
|
||||
|
||||
async def make_notes_menu(*a):
|
||||
from pincodes import pa
|
||||
|
||||
if pa.hobbled_mode:
|
||||
# Read only version of menu system
|
||||
# - used when spending policy in effect
|
||||
# - must have some notes already, or unreachable
|
||||
assert NoteContent.count()
|
||||
rv = NotesMenu(NotesMenu.construct_readonly())
|
||||
rv.readonly = True
|
||||
return rv
|
||||
|
||||
if not settings.get('secnap', False):
|
||||
# Explain feature, and then enable if interested. Drop them into menu.
|
||||
@ -105,6 +115,8 @@ async def get_a_password(old_value, min_len=0, max_len=128):
|
||||
|
||||
class NotesMenu(MenuSystem):
|
||||
|
||||
readonly = False
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# Dynamic menu with user-defined names of notes shown
|
||||
@ -134,6 +146,18 @@ class NotesMenu(MenuSystem):
|
||||
|
||||
return rv
|
||||
|
||||
@classmethod
|
||||
def construct_readonly(cls):
|
||||
# When only allowed to view, no export/add new/delete.
|
||||
wipe_if_deltamode()
|
||||
|
||||
rv = []
|
||||
for note in NoteContent.get_all():
|
||||
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title),
|
||||
menu=note.make_menu, arg=True)) # readonly=True
|
||||
|
||||
return rv
|
||||
|
||||
@classmethod
|
||||
async def export_all(cls, *a):
|
||||
await start_export(NoteContent.get_all())
|
||||
@ -205,8 +229,8 @@ class NotesMenu(MenuSystem):
|
||||
async def drill_to(cls, menu, item):
|
||||
# make it so looks like we drilled down into the new note
|
||||
menu.goto_idx(item.idx)
|
||||
m = MenuSystem(await item.make_menu())
|
||||
the_ux.push(m)
|
||||
m = await item._make_menu()
|
||||
the_ux.push(MenuSystem(m))
|
||||
|
||||
|
||||
class NoteContentBase:
|
||||
@ -302,7 +326,8 @@ class NoteContentBase:
|
||||
|
||||
if not is_new:
|
||||
# change our own menu contents
|
||||
menu.replace_items(await self.make_menu())
|
||||
mi = await self._make_menu()
|
||||
menu.replace_items(mi)
|
||||
|
||||
# update parent
|
||||
parent = the_ux.parent_of(menu)
|
||||
@ -344,25 +369,36 @@ class PasswordContent(NoteContentBase):
|
||||
flds = ['title', 'user', 'password', 'site', 'misc' ]
|
||||
type_label = 'password'
|
||||
|
||||
async def make_menu(self, *a):
|
||||
async def _make_menu(self, readonly=False):
|
||||
rv = [MenuItem('"%s"' % self.title, f=self.view)]
|
||||
if self.user:
|
||||
rv.append(MenuItem('↳ %s' % self.user, f=self.view))
|
||||
if self.site:
|
||||
rv.append(MenuItem('↳ %s' % self.site, f=self.view))
|
||||
#if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
|
||||
return rv + [
|
||||
# if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
|
||||
rv += [
|
||||
MenuItem('View Password', f=self.view_pw),
|
||||
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
|
||||
MenuItem('Export', f=self.export),
|
||||
MenuItem('Edit Metadata', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Change Password', f=self.change_pw),
|
||||
]
|
||||
if not readonly:
|
||||
rv += [
|
||||
MenuItem('Export', f=self.export),
|
||||
MenuItem('Edit Metadata', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Change Password', f=self.change_pw),
|
||||
]
|
||||
rv += [
|
||||
self.sign_misc_menu_item(),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label),
|
||||
ShortcutItem(KEY_NFC, f=self.share_nfc, arg=self.type_label),
|
||||
]
|
||||
|
||||
return rv
|
||||
|
||||
async def make_menu(self, a, b, item):
|
||||
items = await self._make_menu(readonly=item.arg)
|
||||
return MenuSystem(items)
|
||||
|
||||
async def view(self, *a):
|
||||
pl = len(self.password)
|
||||
m = ''
|
||||
@ -476,18 +512,28 @@ class NoteContent(NoteContentBase):
|
||||
flds = ['title', 'misc']
|
||||
type_label = 'note'
|
||||
|
||||
async def make_menu(self, *a):
|
||||
async def _make_menu(self, readonly=False):
|
||||
# Details and actions for this Note
|
||||
return [
|
||||
rv = [
|
||||
MenuItem('"%s"' % self.title, f=self.view),
|
||||
MenuItem('View Note', f=self.view),
|
||||
MenuItem('Edit', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Export', f=self.export),
|
||||
]
|
||||
if not readonly:
|
||||
rv += [
|
||||
MenuItem('Edit', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Export', f=self.export),
|
||||
]
|
||||
rv += [
|
||||
self.sign_misc_menu_item(),
|
||||
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
|
||||
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
|
||||
]
|
||||
return rv
|
||||
|
||||
async def make_menu(self, a, b, item):
|
||||
items = await self._make_menu(readonly=item.arg)
|
||||
return MenuSystem(items)
|
||||
|
||||
async def view(self, *a):
|
||||
ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR,
|
||||
|
||||
@ -67,6 +67,8 @@ from utils import call_later_ms
|
||||
# msas = multisig address show (do not censor multisig addresses)
|
||||
# ccc = (complex) If present, CCC feature is enabled and key details stored here.
|
||||
# ktrx = (privkey) Key teleport Rx has been started, this will be our keypair
|
||||
# sssp = (complex) If present, a (single signer) spending-policy is defined (maybe disabled)
|
||||
# lfr = (string) If present, the reason why Spending Policy blocked last transaction
|
||||
|
||||
# Stored w/ key=00 for access before login
|
||||
# _skip_pin = hard code a PIN value (dangerous, only for debug)
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
# pincodes.py - manage PIN code (which map to wallet seeds)
|
||||
#
|
||||
import ustruct, ckcc, version, chains, stash
|
||||
# from ubinascii import hexlify as b2a_hex
|
||||
from callgate import enter_dfu
|
||||
from bip39 import wordlist_en
|
||||
|
||||
@ -127,6 +126,9 @@ class PinAttempt:
|
||||
self.private_state = 0 # opaque data, but preserve
|
||||
self.cached_main_pin = bytearray(32)
|
||||
|
||||
# If set, a spending policy is in effect, and so even tho we know the master
|
||||
# seed, we are not going to let them see it, nor sign things we dont like, etc.
|
||||
self.hobbled_mode = False
|
||||
|
||||
assert MAX_PIN_LEN == 32 # update FMT otherwise
|
||||
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
|
||||
@ -339,10 +341,6 @@ class PinAttempt:
|
||||
|
||||
return self.state_flags
|
||||
|
||||
def delay(self):
|
||||
# obsolete since Mk3, but called from login.py
|
||||
self.roundtrip(1)
|
||||
|
||||
def login(self):
|
||||
# test we have the PIN code right, and unlock access if so.
|
||||
chk = self.roundtrip(2)
|
||||
@ -533,6 +531,7 @@ class PinAttempt:
|
||||
from trick_pins import TC_DELTA_MODE
|
||||
return bool(self.delay_required & TC_DELTA_MODE)
|
||||
|
||||
|
||||
def get_tc_values(self):
|
||||
# Mk4 only
|
||||
# return (tc_flags, tc_arg)
|
||||
|
||||
@ -40,6 +40,10 @@ _PREFIX_MARKER = const(1<<26)
|
||||
# - 'encoded' is hex, and has is trimmed of right side zeros
|
||||
VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin')
|
||||
|
||||
def not_hobbled_mode():
|
||||
# used as menu predicate and similar
|
||||
return not pa.hobbled_mode
|
||||
|
||||
def seed_vault_iter():
|
||||
# iterate over all seeds in the vault; returns VaultEntry instances.
|
||||
# raw vault entries are list type when json.loaded from flash
|
||||
@ -150,23 +154,53 @@ class WordNestMenu(MenuSystem):
|
||||
done_cb = None
|
||||
|
||||
def __init__(self, num_words=None, has_checksum=True, done_cb=commit_new_words,
|
||||
items=None, is_commit=False):
|
||||
items=None, is_commit=False, menu_cbf=None, prefix=""):
|
||||
|
||||
if num_words is not None:
|
||||
WordNestMenu.target_words = num_words
|
||||
WordNestMenu.has_checksum = has_checksum
|
||||
WordNestMenu.words = []
|
||||
assert done_cb
|
||||
WordNestMenu.done_cb = done_cb
|
||||
is_commit = True
|
||||
|
||||
if not items:
|
||||
items = [MenuItem(i, menu=self.next_menu) for i in letter_choices()]
|
||||
ch = letter_choices(prefix)
|
||||
if menu_cbf:
|
||||
items = [MenuItem(i, f=menu_cbf) for i in ch]
|
||||
else:
|
||||
items = [MenuItem(i, menu=self.next_menu) for i in ch]
|
||||
|
||||
self.is_commit = is_commit
|
||||
|
||||
super(WordNestMenu, self).__init__(items)
|
||||
|
||||
@classmethod
|
||||
async def get_n_words(cls, nwords):
|
||||
# Just block until N words are provided. May only work before menus start?
|
||||
from glob import numpad
|
||||
|
||||
async def menu_done_cbf(menu, b, c):
|
||||
# duplicates some of the logic of next_menu
|
||||
if c.label[-1] == '-':
|
||||
lc = c.label[0:-1]
|
||||
else:
|
||||
lc = ""
|
||||
cls.words.append(c.label)
|
||||
if len(cls.words) >= nwords:
|
||||
numpad.abort_ux()
|
||||
return
|
||||
|
||||
m = cls(prefix=lc, menu_cbf=menu_done_cbf)
|
||||
the_ux.push(m)
|
||||
await m.interact()
|
||||
|
||||
m = cls(num_words=nwords, menu_cbf=menu_done_cbf, has_checksum=False)
|
||||
|
||||
the_ux.push(m)
|
||||
await the_ux.interact()
|
||||
|
||||
return cls.words
|
||||
|
||||
@staticmethod
|
||||
async def next_menu(self, idx, choice):
|
||||
|
||||
@ -463,6 +497,10 @@ async def add_seed_to_vault(encoded, origin=None, label=None):
|
||||
if in_seed_vault(encoded):
|
||||
return
|
||||
|
||||
# stay "read only" in hobbled mode
|
||||
if pa.hobbled_mode:
|
||||
return
|
||||
|
||||
main_xfp = settings.master_get("xfp", 0)
|
||||
|
||||
# parse encoded
|
||||
@ -501,7 +539,7 @@ async def add_seed_to_vault(encoded, origin=None, label=None):
|
||||
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
|
||||
is_restore=False, origin=None, label=None):
|
||||
# Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp.
|
||||
if not is_restore:
|
||||
if not is_restore and not_hobbled_mode():
|
||||
await add_seed_to_vault(encoded, origin=origin, label=label)
|
||||
dis.fullscreen("Wait...")
|
||||
|
||||
@ -879,6 +917,8 @@ class SeedVaultMenu(MenuSystem):
|
||||
ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
|
||||
if ch == "x": return
|
||||
|
||||
assert not_hobbled_mode()
|
||||
|
||||
dis.fullscreen("Saving...")
|
||||
|
||||
wipe_slot = not current_active and (ch != "1")
|
||||
@ -890,6 +930,7 @@ class SeedVaultMenu(MenuSystem):
|
||||
xs.blank()
|
||||
del xs
|
||||
|
||||
|
||||
# CAUTION: will get shadow copy if in tmp seed mode already
|
||||
seeds = settings.master_get("seeds", [])
|
||||
try:
|
||||
@ -926,6 +967,8 @@ class SeedVaultMenu(MenuSystem):
|
||||
from glob import dis
|
||||
from ux import ux_input_text
|
||||
|
||||
assert not_hobbled_mode()
|
||||
|
||||
idx, old = item.arg
|
||||
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
|
||||
|
||||
@ -956,6 +999,8 @@ class SeedVaultMenu(MenuSystem):
|
||||
async def _add_current_tmp(*a):
|
||||
from pincodes import pa
|
||||
|
||||
assert not_hobbled_mode()
|
||||
|
||||
assert pa.tmp_value
|
||||
main_xfp = settings.master_get("xfp", 0)
|
||||
|
||||
@ -998,9 +1043,10 @@ class SeedVaultMenu(MenuSystem):
|
||||
|
||||
if not seeds:
|
||||
rv.append(MenuItem('(none saved yet)'))
|
||||
if pa.tmp_value:
|
||||
rv.append(add_current_tmp)
|
||||
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
|
||||
if not_hobbled_mode():
|
||||
if pa.tmp_value:
|
||||
rv.append(add_current_tmp)
|
||||
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
|
||||
else:
|
||||
wipe_if_deltamode()
|
||||
|
||||
@ -1016,8 +1062,10 @@ class SeedVaultMenu(MenuSystem):
|
||||
submenu = [
|
||||
MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
|
||||
MenuItem('Use This Seed', f=cls._set, arg=encoded),
|
||||
MenuItem('Rename', f=cls._rename, arg=(i, rec)),
|
||||
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)),
|
||||
MenuItem('Rename', f=cls._rename, arg=(i, rec),
|
||||
predicate=not_hobbled_mode),
|
||||
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded),
|
||||
predicate=not_hobbled_mode),
|
||||
]
|
||||
if is_active:
|
||||
submenu[1] = MenuItem("Seed In Use")
|
||||
@ -1035,7 +1083,7 @@ class SeedVaultMenu(MenuSystem):
|
||||
rv.append(item)
|
||||
|
||||
if pa.tmp_value:
|
||||
if seeds and (not tmp_in_sv):
|
||||
if seeds and (not tmp_in_sv) and not_hobbled_mode():
|
||||
# give em chance to store current active
|
||||
rv.append(add_current_tmp)
|
||||
|
||||
@ -1124,7 +1172,7 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
]
|
||||
|
||||
rv = [
|
||||
MenuItem("Generate Words", menu=gen_ephemeral_menu),
|
||||
MenuItem("Generate Words", menu=gen_ephemeral_menu, predicate=not_hobbled_mode),
|
||||
MenuItem('Import from QR Scan', predicate=version.has_qr,
|
||||
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
|
||||
MenuItem("Import Words", menu=import_ephemeral_menu),
|
||||
@ -1137,6 +1185,7 @@ class EphemeralSeedMenu(MenuSystem):
|
||||
|
||||
|
||||
async def make_ephemeral_seed_menu(*a):
|
||||
|
||||
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
|
||||
# force a warning on them, unless they are already doing it.
|
||||
if not await ux_confirm(
|
||||
|
||||
@ -307,11 +307,17 @@ async def kt_accept_values(dtype, raw):
|
||||
- `b` - complete system backup file (text, internal format)
|
||||
'''
|
||||
from flow import has_se_secrets, goto_top_menu
|
||||
from pincodes import pa
|
||||
|
||||
enc = None
|
||||
origin = 'Teleported'
|
||||
label = None
|
||||
|
||||
if pa.hobbled_mode and dtype != 'p':
|
||||
await ux_show_story('Only PSBT for multisig accepted in this mode.', title='FAILED')
|
||||
return
|
||||
|
||||
|
||||
if dtype == 's':
|
||||
# words / bip 32 master / xprv, etc
|
||||
enc = bytearray(72)
|
||||
@ -475,6 +481,12 @@ def decode_step2(session_key, noid_key, body):
|
||||
async def kt_incoming(type_code, payload):
|
||||
# incoming BBQr was scanned (via main menu, etc)
|
||||
|
||||
from pincodes import pa
|
||||
if pa.hobbled_mode and type_code != 'E':
|
||||
# only PSBT rx is supported in hobbled mode
|
||||
# fail silently, this is second check, see decoders.py
|
||||
return
|
||||
|
||||
if type_code == 'R':
|
||||
# they want to send to this guy
|
||||
return await kt_start_send(payload)
|
||||
@ -495,6 +507,10 @@ class SecretPickerMenu(MenuSystem):
|
||||
def __init__(self, rx_pubkey):
|
||||
self.rx_pubkey = rx_pubkey
|
||||
|
||||
# this menu should be unreachable in hobbled mode.
|
||||
from pincodes import pa
|
||||
assert not pa.hobbled_mode
|
||||
|
||||
from flow import word_based_seed, is_tmp, has_se_secrets
|
||||
has_notes = bool(NoteContentBase.count())
|
||||
has_sv = bool(settings.get('seedvault', False))
|
||||
|
||||
@ -32,7 +32,7 @@ TC_WORD_WALLET = const(0x1000)
|
||||
TC_XPRV_WALLET = const(0x0800)
|
||||
TC_DELTA_MODE = const(0x0400)
|
||||
TC_REBOOT = const(0x0200)
|
||||
TC_RFU = const(0x0100)
|
||||
TC_FW_DEFINED = const(0x0100)
|
||||
# for our use, not implemented in bootrom
|
||||
TC_BLANK_WALLET = const(0x0080)
|
||||
TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay
|
||||
@ -40,6 +40,10 @@ TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay
|
||||
# tc_args encoding:
|
||||
# TC_WORD_WALLET -> BIP-85 index, 1001..1003 for 24 words, 2001..2003 for 12-words
|
||||
|
||||
# If TC_FW_DEFINED is true, then we can do anything with this PIN at the firmware
|
||||
# level. First application is to unlock spending stuff.
|
||||
TCA_SP_UNLOCK = const(0x0001) # spending policy unlock
|
||||
|
||||
# special "pin" used as catch-all for wrong pins
|
||||
WRONG_PIN_CODE = '!p'
|
||||
|
||||
@ -274,6 +278,10 @@ class TrickPinMgmt:
|
||||
# put them in order, with "wrong" last
|
||||
return sorted(self.tp.keys(), key=lambda i: i if (i != WRONG_PIN_CODE) else 'Z')
|
||||
|
||||
def define_unlock_pin(self, new_pin):
|
||||
# user is setting the bypass PIN for first time.
|
||||
self.update_slot(new_pin.encode(), new=True, tc_flags=TC_FW_DEFINED, tc_arg=TCA_SP_UNLOCK)
|
||||
|
||||
def was_countdown_pin(self):
|
||||
# was the trick pin just used? if so how much delay needed (or zero if not)
|
||||
from pincodes import pa
|
||||
@ -284,6 +292,30 @@ class TrickPinMgmt:
|
||||
else:
|
||||
return 0
|
||||
|
||||
def was_sp_unlock(self):
|
||||
# was a trick pin just used that enables acess to spending policy?
|
||||
# - ok if it's also a trick PIN .. a wiping bypass for example
|
||||
from pincodes import pa
|
||||
tc_flags, tc_arg = pa.get_tc_values()
|
||||
return bool(tc_flags & TC_FW_DEFINED) and (tc_arg == TCA_SP_UNLOCK)
|
||||
|
||||
def has_sp_unlock(self):
|
||||
# if spending policy defined, this PIN allows adjustment
|
||||
# - not TRICK bypass choices, like ones that wipe
|
||||
# - could be multiple, but only first returned.
|
||||
for k, (sn,flags,arg) in self.tp.items():
|
||||
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
|
||||
return k
|
||||
return None
|
||||
|
||||
def delete_sp_unlock_pins(self):
|
||||
# remove all bypass pins, they are done w/ feature
|
||||
for k, (sn,flags,arg) in self.tp.items():
|
||||
if (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
|
||||
self.clear_slots([sn])
|
||||
self.forget_pin(k)
|
||||
|
||||
|
||||
def get_deltamode_pins(self):
|
||||
# iterate over all delta-mode PIN's defined.
|
||||
for k, (sn,flags,args) in self.tp.items():
|
||||
@ -375,6 +407,12 @@ class TrickPinMgmt:
|
||||
b, slot = tp.update_slot(pin.encode(), new=True,
|
||||
tc_flags=flags, tc_arg=arg, secret=new_secret)
|
||||
except: pass
|
||||
|
||||
@staticmethod
|
||||
async def err_unique_pin(pin):
|
||||
# standardized error UX
|
||||
return await ux_show_story(
|
||||
"That PIN (%s) is already in use. All PIN codes must be unique." % pin)
|
||||
|
||||
|
||||
tp = TrickPinMgmt()
|
||||
@ -520,8 +558,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)
|
||||
return
|
||||
return await tp.err_unique_pin(new_pin)
|
||||
|
||||
# check if we "forgot" this pin, and read it back if we did.
|
||||
# - important this is after the above checks so we don't reveal any trick pin used
|
||||
@ -606,6 +643,9 @@ the seed phrase, but still a somewhat riskier mode.
|
||||
For this mode only, trick PIN must be same length as true PIN and \
|
||||
differ only in final 4 positions (ignoring dash).\
|
||||
''', flags=TC_DELTA_MODE),
|
||||
StoryMenuItem('Policy Unlock', "Adds (another?) Spending Policy unlock PIN.", flags=TC_FW_DEFINED, arg=TCA_SP_UNLOCK),
|
||||
StoryMenuItem('Policy Unlock & Wipe' if version.has_qwerty else 'P.U. & Wipe',
|
||||
"Pretends correct Spending Policy unlock PIN given, but silently wipes seed before asking for main PIN.", flags=TC_FW_DEFINED|TC_WIPE, arg=TCA_SP_UNLOCK),
|
||||
]
|
||||
m = MenuSystem(FirstMenu)
|
||||
m.goto_idx(1)
|
||||
@ -651,9 +691,14 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
|
||||
the_ux.push(m)
|
||||
|
||||
async def clear_all(self, m,l,item):
|
||||
|
||||
if not await ux_confirm("Remove ALL TRICK PIN codes and special wrong-pin handling?"):
|
||||
return
|
||||
|
||||
if tp.has_sp_unlock():
|
||||
if not await ux_confirm("You will not be able to bypass spending policy anymore."):
|
||||
return
|
||||
|
||||
if any(tp.get_duress_pins()):
|
||||
if not await ux_confirm("Any funds on the duress wallet(s) have been moved already?"):
|
||||
return
|
||||
@ -662,7 +707,7 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
|
||||
m.update_contents()
|
||||
|
||||
async def hide_pin(self, m,l, item):
|
||||
pin, slot_num, flags = item.arg
|
||||
pin, slot_num, flags, arg = item.arg
|
||||
|
||||
if flags & TC_DELTA_MODE:
|
||||
await ux_show_story('''Delta mode PIN will be hidden if trick PIN menu is shown \
|
||||
@ -670,12 +715,14 @@ to attacker, and we need to update this record if the main PIN is changed, so we
|
||||
hiding this item.''')
|
||||
return
|
||||
|
||||
if pin != WRONG_PIN_CODE:
|
||||
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
|
||||
msg = "It will still be possible to change or disable the spending policy if this PIN is known."
|
||||
elif pin == WRONG_PIN_CODE:
|
||||
msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
|
||||
else:
|
||||
msg = '''This will hide the PIN from the menus but it will still be in effect.
|
||||
|
||||
You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
|
||||
else:
|
||||
msg = "This will hide what happens with wrong PINs from the menus but it will still be in effect."
|
||||
|
||||
if not await ux_confirm(msg): return
|
||||
|
||||
@ -715,12 +762,16 @@ You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
|
||||
await ux_show_story("Failed: %s" % exc)
|
||||
|
||||
async def delete_pin(self, m,l, item):
|
||||
pin, slot_num, flags = item.arg
|
||||
pin, slot_num, flags, arg = item.arg
|
||||
|
||||
if flags & (TC_WORD_WALLET | TC_XPRV_WALLET):
|
||||
if not await ux_confirm("Any funds on this duress wallet have been moved already?"):
|
||||
return
|
||||
|
||||
if (flags == TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
|
||||
if not await ux_confirm("Changes to the spending policy will not be possible anymore."):
|
||||
return
|
||||
|
||||
if pin == WRONG_PIN_CODE:
|
||||
msg = "Remove special handling of wrong PINs?"
|
||||
else:
|
||||
@ -748,8 +799,7 @@ You can restore it by trying to re-add the same PIN (%s) again later.''' % pin
|
||||
|
||||
ch = await ux_show_story('''\
|
||||
This will temporarily load the secrets associated with this trick wallet \
|
||||
so you may perform transactions with it. Reboot the Coldcard to restore \
|
||||
normal operation.''')
|
||||
so you may perform transactions with it.''')
|
||||
if ch != 'y': return
|
||||
|
||||
b, slot = tp.get_by_pin(pin)
|
||||
@ -882,6 +932,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
|
||||
rv.append(MenuItem("↳Pretends Wrong"))
|
||||
elif flags & TC_DELTA_MODE:
|
||||
rv.append(MenuItem("↳Delta Mode"))
|
||||
elif (flags & TC_FW_DEFINED) and (arg == TCA_SP_UNLOCK):
|
||||
rv.append(MenuItem("↳Unlock Policy")) # width issues on Mk4
|
||||
|
||||
for m, msg in [
|
||||
(TC_WIPE, '↳Wipes seed'),
|
||||
@ -895,8 +947,8 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
|
||||
rv.append(MenuItem("Activate Wallet", f=self.activate_wallet, arg=(pin, flags, arg)))
|
||||
|
||||
rv.extend([
|
||||
MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags)),
|
||||
MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags)),
|
||||
MenuItem('Hide Trick', f=self.hide_pin, arg=(pin, slot_num, flags, arg)),
|
||||
MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags, arg)),
|
||||
])
|
||||
if pin != WRONG_PIN_CODE:
|
||||
rv.append(
|
||||
@ -907,6 +959,7 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
|
||||
|
||||
class StoryMenuItem(MenuItem):
|
||||
def __init__(self, label, story, flags=0, **kws):
|
||||
# arg= .. handled by super
|
||||
self.story = story
|
||||
self.flags = flags
|
||||
super().__init__(label, **kws)
|
||||
|
||||
@ -11,7 +11,8 @@ from ustruct import pack, unpack_from
|
||||
from ckcc import watchpoint, is_simulator
|
||||
from utils import problem_file_line, call_later_ms
|
||||
from version import supports_hsm, is_devmode, MAX_TXN_LEN, MAX_UPLOAD_LEN
|
||||
from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled
|
||||
from exceptions import FramingError, CCBusyError, HSMDenied, HSMCMDDisabled, SpendPolicyViolation
|
||||
from pincodes import pa
|
||||
|
||||
# Unofficial, unpermissioned... numbers
|
||||
COINKITE_VID = 0xd13e
|
||||
@ -68,6 +69,21 @@ HSM_DISABLE_CMDS = frozenset({
|
||||
"hsms",
|
||||
})
|
||||
|
||||
# spending policy active: blacklist some commands
|
||||
# - 'pass' may be allowed if 'okeys' is enabled
|
||||
HOBBLED_CMDS = frozenset({
|
||||
'enrl', # no new multisigs during policy enforcement
|
||||
'back', # no backups
|
||||
'bagi', 'dfu_', # just in case
|
||||
|
||||
"user", # same as HSM_DISABLE_CMDS
|
||||
"rmur",
|
||||
"nwur",
|
||||
"gslr",
|
||||
"hsts",
|
||||
"hsms",
|
||||
})
|
||||
|
||||
# singleton instance of USBHandler()
|
||||
handler = None
|
||||
|
||||
@ -217,6 +233,8 @@ class USBHandler:
|
||||
except CCBusyError:
|
||||
# auth UX is doing something else
|
||||
resp = b'busy'
|
||||
except SpendPolicyViolation:
|
||||
resp = b'err_Spending policy in effect'
|
||||
except HSMDenied:
|
||||
resp = b'err_Not allowed in HSM mode'
|
||||
except HSMCMDDisabled:
|
||||
@ -345,7 +363,7 @@ class USBHandler:
|
||||
except:
|
||||
raise FramingError('decode')
|
||||
|
||||
if cmd[0].isupper() and is_devmode:
|
||||
if is_devmode and cmd[0].isupper():
|
||||
# special hacky commands to support testing w/ the simulator
|
||||
try:
|
||||
from usb_test_commands import do_usb_command
|
||||
@ -358,7 +376,18 @@ class USBHandler:
|
||||
if cmd not in HSM_WHITELIST:
|
||||
raise HSMDenied
|
||||
|
||||
if not settings.get('hsmcmd', False):
|
||||
if pa.hobbled_mode:
|
||||
# block some commands when we are hobbled.
|
||||
if cmd in HOBBLED_CMDS:
|
||||
raise SpendPolicyViolation
|
||||
|
||||
if cmd in {'pwok', 'pass'}:
|
||||
from ccc import sssp_spending_policy
|
||||
if not sssp_spending_policy('okeys'):
|
||||
raise SpendPolicyViolation
|
||||
|
||||
elif not settings.get('hsmcmd', False):
|
||||
# block these HSM-related command if not using feature
|
||||
if cmd in HSM_DISABLE_CMDS:
|
||||
raise HSMCMDDisabled
|
||||
|
||||
@ -741,7 +770,6 @@ class USBHandler:
|
||||
from glob import dis, hsm_active
|
||||
from utils import check_firmware_hdr
|
||||
from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC
|
||||
from pincodes import pa
|
||||
|
||||
# maintain a running SHA256 over what's received
|
||||
if offset == 0:
|
||||
@ -754,8 +782,8 @@ class USBHandler:
|
||||
assert offset % 256 == 0, 'alignment'
|
||||
assert offset+len(data) <= total_size <= MAX_UPLOAD_LEN, 'long'
|
||||
|
||||
if hsm_active:
|
||||
# additional restrictions in HSM mode
|
||||
if hsm_active or pa.hobbled_mode:
|
||||
# additional restriction in HSM mode or hobbled: must be PSBT
|
||||
assert offset+len(data) <= total_size <= MAX_TXN_LEN, 'psbt'
|
||||
if offset == 0:
|
||||
assert data[0:5] == b'psbt\xff', 'psbt'
|
||||
@ -834,7 +862,6 @@ class USBHandler:
|
||||
def handle_bag_number(self, bag_num):
|
||||
import version, callgate
|
||||
from glob import dis, settings
|
||||
from pincodes import pa
|
||||
|
||||
if bag_num and version.is_factory_mode and not version.has_qr:
|
||||
# check state first
|
||||
|
||||
@ -429,6 +429,8 @@ def clean_shutdown(style=0):
|
||||
# wipe SPI flash and shutdown (wiping main memory)
|
||||
# - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom)
|
||||
# - bootrom wipes every byte of SRAM, so no need to repeat here
|
||||
# - style=2 => reboot and try login again
|
||||
# - default is logout and (if applicable) power down.
|
||||
import callgate
|
||||
|
||||
# save if anything pending
|
||||
|
||||
@ -553,6 +553,16 @@ def ux_draw_words(y, num_words, words):
|
||||
# Draw seed words on single screen (hard) and return x/y position of start of each
|
||||
from glob import dis
|
||||
|
||||
if num_words == 2:
|
||||
# simple version for first & last words, used only during login to spending policy
|
||||
X = 14
|
||||
Y = y+1
|
||||
dis.text(X-7, Y, 'FIRST: %s' % words[0])
|
||||
dis.text(X-4, Y+1, '⋯')
|
||||
dis.text(X-6, Y+2, 'LAST: %s' % words[-1])
|
||||
|
||||
return [ (X, Y), (X, Y+2) ]
|
||||
|
||||
if num_words == 12:
|
||||
cols = 2
|
||||
xpos = [2, 18]
|
||||
@ -902,6 +912,8 @@ class QRScannerInteraction:
|
||||
async def scan_anything(self, expect_secret=False, tmp=False):
|
||||
# start a QR scan, and act on what we find, whatever it may be.
|
||||
from ux import ux_show_story
|
||||
from pincodes import pa
|
||||
|
||||
problem = None
|
||||
while 1:
|
||||
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
|
||||
@ -923,6 +935,21 @@ class QRScannerInteraction:
|
||||
problem = "Unable to decode QR"
|
||||
continue
|
||||
|
||||
if pa.hobbled_mode:
|
||||
# block most imports in hobbled mode.
|
||||
# - specific checks in place for teleport (PSBT is okay)
|
||||
from ccc import sssp_spending_policy
|
||||
whitelist = {'psbt', 'addr', 'vmsg', 'text', 'xpub', 'teleport' }
|
||||
|
||||
sv_ok = sssp_spending_policy('okeys')
|
||||
if sv_ok:
|
||||
# seed vault, and tmp seeds are okay with user, even in hobble mode
|
||||
whitelist.update({'xprv', 'words'})
|
||||
|
||||
if what not in whitelist:
|
||||
await ux_show_story("Blocked when Spending Policy is in force.", title='Sorry')
|
||||
return
|
||||
|
||||
if what == 'xprv':
|
||||
from actions import import_extended_key_as_secret
|
||||
text_xprv, = vals
|
||||
@ -1104,6 +1131,7 @@ async def ux_visualize_bip21(proto, addr, args):
|
||||
await OWNERSHIP.search_ux(addr)
|
||||
|
||||
async def ux_visualize_wif(wif_str, kp, compressed, testnet):
|
||||
# TODO: remove until we support signing w/ WIF keys IMHO
|
||||
from ux import ux_show_story
|
||||
msg = wif_str + "\n\n"
|
||||
msg += "chain: %s\n\n" % ("XTN" if testnet else "BTC")
|
||||
|
||||
@ -95,7 +95,7 @@ async def perform_web2fa(label, shared_secret):
|
||||
return False
|
||||
|
||||
|
||||
async def web2fa_enroll(label, ss=None):
|
||||
async def web2fa_enroll(ss=None):
|
||||
#
|
||||
# Enroll: Pick a secret and test they have loaded it into their phone.
|
||||
#
|
||||
@ -115,22 +115,21 @@ async def web2fa_enroll(label, ss=None):
|
||||
# - can't fit any metadata, like username or our serial # in there
|
||||
# - better on Q1 where no limitations for this size of QR
|
||||
|
||||
qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss,
|
||||
nm=url_quote(label if has_qr else label[0:4]))
|
||||
nm = 'COLDCARD' if has_qr else 'CC' # must be url-safe
|
||||
qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, nm=nm)
|
||||
|
||||
while 1:
|
||||
# show QR for enroll
|
||||
await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App",
|
||||
force_msg=True)
|
||||
|
||||
# important: force them to prove they store it correctly
|
||||
ok = await perform_web2fa('Enroll: ' + label, ss)
|
||||
# important: force them to prove they stored it correctly
|
||||
ok = await perform_web2fa('Enroll: COLDCARD', ss)
|
||||
if ok: break
|
||||
|
||||
ch = await ux_show_story("That isn't correct. Please re-import and/or "
|
||||
"try again or %s to give up." % X)
|
||||
if ch == 'x':
|
||||
# mk4 only?
|
||||
return None
|
||||
|
||||
return ss
|
||||
|
||||
@ -1726,6 +1726,7 @@ ae_read_config_byte(int offset)
|
||||
uint8_t tmp[4];
|
||||
|
||||
ae_read_config_word(offset, tmp);
|
||||
// BUG: didnt check for failure, in which case we will return un-inited values
|
||||
|
||||
return tmp[offset % 4];
|
||||
}
|
||||
|
||||
@ -1827,6 +1827,16 @@ def nfc_block4rf(sim_eval):
|
||||
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def nfc_is_enabled(sim_eval):
|
||||
# NFC is disabled by default in real product, and simulator w/o args
|
||||
# - but some tests don't need to fail if it's off
|
||||
# - or maybe your test can use some other method when it's off
|
||||
# - use this to see if disabled at present and choose the right path
|
||||
def doit():
|
||||
return eval(sim_eval('bool(glob.NFC)'))
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def load_shared_mod():
|
||||
# load indicated file.py as a module
|
||||
@ -2617,11 +2627,33 @@ def build_test_seed_vault():
|
||||
return sv
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def get_deltamode(sim_exec):
|
||||
# get current "deltamode" status: T or F
|
||||
def doit():
|
||||
return eval(sim_exec('RV.write(repr(pa.is_deltamode()))'))
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def set_deltamode(sim_exec):
|
||||
# control current "deltamode" status: T or F
|
||||
def doit(val):
|
||||
# TC_DELTA_MODE = const(0x0400)
|
||||
if val:
|
||||
sim_exec('pa.delay_required |= 0x400')
|
||||
else:
|
||||
sim_exec('pa.delay_required &= ~0x400')
|
||||
|
||||
yield doit
|
||||
|
||||
doit(False)
|
||||
|
||||
|
||||
# useful fixtures
|
||||
from test_backup import backup_system
|
||||
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
|
||||
from test_bip39pw import set_bip39_pw
|
||||
from test_ccc import get_last_violation
|
||||
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
|
||||
|
||||
@ -44,6 +44,10 @@ def _press_select(device, is_Q, timeout=None):
|
||||
btn = KEY_ENTER if is_Q else "y"
|
||||
_need_keypress(device, btn, timeout=timeout)
|
||||
|
||||
def _press_cancel(device, is_Q, timeout=None):
|
||||
btn = KEY_CANCEL if is_Q else "x"
|
||||
_need_keypress(device, btn, timeout=timeout)
|
||||
|
||||
def _dev_hw_label(device):
|
||||
# gets a short string that labels product: mk4 / q1, etc
|
||||
v = device.send_recv(CCProtocolPacker.version()).split()
|
||||
|
||||
@ -10,12 +10,13 @@ async def doit():
|
||||
import version
|
||||
async def dump_menu(fd, m, label, indent, menu_item=None, menu_idx=0, whs=False):
|
||||
from menu import MenuItem, ToggleMenuItem, MenuSystem, NonDefaultMenuItem
|
||||
from seed import WordNestMenu, EphemeralSeedMenu, SeedVaultMenu
|
||||
from seed import WordNestMenu, EphemeralSeedMenu, SeedVaultMenu, not_hobbled_mode
|
||||
from trick_pins import TrickPinMenu
|
||||
from users import UsersMenu
|
||||
from flow import has_secrets, nfc_enabled, vdisk_enabled, word_based_seed
|
||||
from flow import hsm_policy_available, is_not_tmp, has_real_secret
|
||||
from flow import has_se_secrets, hsm_available, qr_and_has_secrets
|
||||
from flow import has_se_secrets, hsm_available, qr_and_has_secrets, has_pushtx_url
|
||||
from flow import sssp_related_keys, sssp_allow_passphrase, sssp_allow_notes, sssp_allow_vault
|
||||
|
||||
print("%s%s"% (indent, label), file=fd)
|
||||
|
||||
@ -32,8 +33,9 @@ async def doit():
|
||||
if version.has_qwerty and m.__name__ == "start_seed_import":
|
||||
print('%s[SEED WORD ENTRY]' % indent, file=fd)
|
||||
return
|
||||
if m.__name__ == "make_custom":
|
||||
if m.__name__ in ("make_custom", "bkpw_override"):
|
||||
# address explorer custom path menu
|
||||
# bkpw override = dev thing
|
||||
return
|
||||
|
||||
print("Calling: %r (%s)" % (m.__name__, label))
|
||||
@ -99,9 +101,24 @@ async def doit():
|
||||
here += ' [IF HSM AND SECRET]'
|
||||
elif pred == qr_and_has_secrets:
|
||||
here += ' [IF QR AND SECRET]'
|
||||
# do nothing, only in NormalOps menu, but SSSP has different menu dump
|
||||
elif pred == not_hobbled_mode: pass
|
||||
# here += ' [IF SSSP DISABLED]'
|
||||
elif pred == has_pushtx_url:
|
||||
here += ' [IF PUSHTX ENABLED]'
|
||||
elif pred == sssp_related_keys:
|
||||
here += ' [IF SSSP RELATED KEYS ENABLED]'
|
||||
elif pred == sssp_allow_passphrase:
|
||||
here += ' [IF WORD BASED SEED & SSSP RELATED KEYS ENABLED]'
|
||||
elif pred == sssp_allow_notes:
|
||||
here += '[IF ENABLED & SSSP ALLOW NOTES]'
|
||||
elif pred == sssp_allow_vault:
|
||||
here += '[IF ENABLED & SSSP RELATED KEYS ENABLED]'
|
||||
elif pred:
|
||||
if here in ("Secure Notes & Passwords", "Push Transaction"):
|
||||
here += ' [IF ENBALED]'
|
||||
if here == "Secure Logout":
|
||||
here += ' [IF NOT BATTERIES]'
|
||||
else:
|
||||
here += ' [MAYBE]'
|
||||
|
||||
@ -133,16 +150,25 @@ async def doit():
|
||||
print('%s%s' % (indent, here), file=fd)
|
||||
|
||||
|
||||
from flow import EmptyWallet, NormalSystem, FactoryMenu, VirginSystem
|
||||
from flow import EmptyWallet, NormalSystem, FactoryMenu, VirginSystem, HobbledTopMenu
|
||||
from glob import settings
|
||||
|
||||
# need these to supress warnings and info messages
|
||||
# that need user interaction nad/or show hidden items
|
||||
settings.put("seedvault", 1)
|
||||
settings.put("seeds", [["7126EB3C", "808ae37a2d3d3d0f9db5ca98c8300e3818", "[7126EB3C]", "TRNG Words"],
|
||||
["CCEE13B9", "018d669ed0fddccd7f34ef6dac86864e75fc4036d7dd3992c985ba0e625d8da83ac33b64d371a6d0d1a4a5200f00080ef5e2b341251b30a8b665be42c43fb4c5f3", "[CCEE13B9]", "BIP-39 Passphrase on [0F056943]"],
|
||||
["03EE9989", "01a00f4ecbfb55b186bae4486e0e292a34e1afb0c1f64ad4a9a3f378bdeefb7296abce50461838f76979a695d6b4f6ac329661c227f1137400520cbbb1294333a7", "[03EE9989]", "BIP85 Derived from [0F056943], index=543"]])
|
||||
settings.put("secnap", 1)
|
||||
settings.put("notes", [{"misc": "some random notes", "title": "note0"},
|
||||
{"password": "AnnounceHalf+~^99891", "site": "abc.org", "misc": "never disclose!!!!!", "user": "satoshi", "title": "secret-PWD"}])
|
||||
settings.put("axskip", 1)
|
||||
settings.put("b39skip", 1)
|
||||
settings.put("sd2fa", ["a"])
|
||||
settings.put("ptxurl", 'https://coldcard.com/pushtx#')
|
||||
settings.put("multisig", [["CC-2-of-4", [2, 4], [[1130956047, "tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP"], [3503269483, "tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm"], [2389277556, "tpubDExj5FnaUnPAn7sHGUeBqD3buoNH5dqmjAT6884vbDpH1iDYWigb7kFo2cA97dc8EHb54u13TRcZxC4kgRS9gc3Ey2xc8c5urytEzTcp3ac"], [3190206587, "tpubDFiuHYSJhNbHcbLJoxWdbjtUcbKR6PvLq53qC1Xq6t93CrRx78W3wcng8vJyQnY3giMJZEgNCRVzTojLb8RqPFpW5Ms2dYpjcJYofN1joyu"]], {"pp": "m/48h/1h/0h/2h", "ch": "XTN", "ft": 14}]])
|
||||
settings.put("tp", {"11-11": [0, 16384, 0], "333-3334": [1, 4096, 1001], "!p": [2, 33280, 3]})
|
||||
|
||||
|
||||
# saved passphrase on MicroSD
|
||||
with open("MicroSD/.tmp.tmp", "wb") as f:
|
||||
@ -154,8 +180,13 @@ async def doit():
|
||||
('[IF BLANK WALLET]', EmptyWallet),
|
||||
('[NORMAL OPERATION]', NormalSystem),
|
||||
('[FACTORY MODE]', FactoryMenu),
|
||||
('[SSSP]', HobbledTopMenu),
|
||||
]:
|
||||
await dump_menu(fd, m, nm, '', whs=(m == NormalSystem))
|
||||
if "SSSP" in nm:
|
||||
from pincodes import pa
|
||||
pa.hobbled_mode = True
|
||||
|
||||
await dump_menu(fd, m, nm, '', whs=(m in (NormalSystem,HobbledTopMenu)))
|
||||
print('---\n', file=fd)
|
||||
|
||||
print("DONE: check menudump.txt file")
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
#
|
||||
import pytest, time, pdb
|
||||
from core_fixtures import _pick_menu_item, _cap_menu, _cap_story, _cap_screen
|
||||
from core_fixtures import _need_keypress, _enter_complex, _press_select
|
||||
from core_fixtures import _need_keypress, _enter_complex, _press_select, _press_cancel
|
||||
from ckcc_protocol.client import ColdcardDevice
|
||||
from run_sim_tests import ColdcardSimulator, clean_sim_data
|
||||
|
||||
@ -530,5 +530,293 @@ def test_calc_login(request):
|
||||
assert "Ready To Sign" in m
|
||||
sim.stop()
|
||||
|
||||
@pytest.mark.parametrize("word_check", [True, False])
|
||||
@pytest.mark.parametrize("randomize", [True, False])
|
||||
def test_sssp_bypass_pin(request, word_check, randomize):
|
||||
main_pin = "22-22"
|
||||
bypass_pin = "111-111"
|
||||
is_Q = request.config.getoption('--Q')
|
||||
clean_sim_data() # remove all from previous
|
||||
sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
|
||||
if randomize:
|
||||
_pick_menu_item(device, is_Q, "Settings")
|
||||
_pick_menu_item(device, is_Q, "Login Settings")
|
||||
_set_scramble_pin_entry(device, is_Q)
|
||||
time.sleep(1)
|
||||
|
||||
for _ in range(2):
|
||||
_press_cancel(device, is_Q)
|
||||
|
||||
time.sleep(.1)
|
||||
|
||||
_pick_menu_item(device, is_Q, "Advanced/Tools")
|
||||
_pick_menu_item(device, is_Q, "Spending Policy")
|
||||
_pick_menu_item(device, is_Q, "Single-Signer")
|
||||
_press_select(device, is_Q) # confirm story
|
||||
# now create bypass PIN
|
||||
# 1st entry
|
||||
_login(device, is_Q, bypass_pin)
|
||||
# 2nd confirmation entry
|
||||
_login(device, is_Q, bypass_pin)
|
||||
|
||||
if word_check:
|
||||
_pick_menu_item(device, is_Q, "Word Check")
|
||||
title, story = _cap_story(device)
|
||||
assert "Enable?" in story
|
||||
assert "must provide the first and last seed words" in story
|
||||
_press_select(device, is_Q)
|
||||
|
||||
time.sleep(2) # needed here to actually save to settings
|
||||
sim.stop()
|
||||
|
||||
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
|
||||
# first login, but with main PIN, ends up in SSSP
|
||||
_login(device, is_Q, main_pin, scrambled=randomize)
|
||||
time.sleep(.1)
|
||||
menu = _cap_menu(device)
|
||||
assert "Settings" not in menu
|
||||
|
||||
sim.stop()
|
||||
|
||||
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", main_pin, "--early-usb"])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
|
||||
# now bypass PIN, normal operation
|
||||
time.sleep(.1)
|
||||
_login(device, is_Q, bypass_pin, scrambled=randomize)
|
||||
time.sleep(.1)
|
||||
title, story = _cap_story(device)
|
||||
assert "Spending Policy Unlock" in story
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
_login(device, is_Q, main_pin, scrambled=randomize)
|
||||
time.sleep(.1)
|
||||
|
||||
if word_check:
|
||||
# first do incorrect words
|
||||
if is_Q:
|
||||
assert "First and Last Seed Word" in _cap_screen(device)
|
||||
# just because of auto-fil feature
|
||||
_enter_complex(device, is_Q, "wif")
|
||||
_enter_complex(device, is_Q, "kic")
|
||||
else:
|
||||
# this is not a text input field - but word nest menu
|
||||
# wife -> 3xUP, 3xDOWN, DOWN
|
||||
for _ in range(3):
|
||||
_need_keypress(device, "5")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
for _ in range(3):
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
|
||||
time.sleep(.1)
|
||||
# abandon -> 3xOK
|
||||
for _ in range(3):
|
||||
_press_select(device, is_Q)
|
||||
|
||||
time.sleep(.1)
|
||||
title, story = _cap_story(device)
|
||||
assert "Sorry, those words are incorrect" in story
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
# now insert correct words
|
||||
if is_Q:
|
||||
# just because of auto-fil feature
|
||||
_enter_complex(device, is_Q, "wif")
|
||||
_enter_complex(device, is_Q, "clar")
|
||||
else:
|
||||
# wife -> 3xUP, 3xDOWN, DOWN
|
||||
for _ in range(3):
|
||||
_need_keypress(device, "5")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
for _ in range(3):
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
|
||||
# clarify 2xDOWN, 4xDOWN, 2xDOWN
|
||||
for _ in range(2):
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
for _ in range(4):
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
for _ in range(2):
|
||||
_need_keypress(device, "8")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
menu = _cap_menu(device)
|
||||
assert "Settings" in menu # not in SSSP
|
||||
|
||||
sim.stop()
|
||||
|
||||
|
||||
def test_sssp_login_countdown(request):
|
||||
bypass_pin = "236-156"
|
||||
is_Q = request.config.getoption('--Q')
|
||||
clean_sim_data() # remove all from previous
|
||||
sim = ColdcardSimulator(args=["--q1"] if is_Q else [])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
|
||||
_pick_menu_item(device, is_Q, "Settings")
|
||||
_pick_menu_item(device, is_Q, "Login Settings")
|
||||
_set_login_countdown(device, is_Q, " 5 minutes")
|
||||
|
||||
time.sleep(.2)
|
||||
for _ in range(2): # go back
|
||||
_press_cancel(device, is_Q)
|
||||
|
||||
time.sleep(.1)
|
||||
_pick_menu_item(device, is_Q, "Advanced/Tools")
|
||||
_pick_menu_item(device, is_Q, "Spending Policy")
|
||||
_pick_menu_item(device, is_Q, "Single-Signer")
|
||||
_press_select(device, is_Q) # confirm story
|
||||
# now create bypass PIN
|
||||
# 1st entry
|
||||
time.sleep(.1)
|
||||
_login(device, is_Q, bypass_pin)
|
||||
# 2nd confirmation entry
|
||||
_login(device, is_Q, bypass_pin)
|
||||
|
||||
time.sleep(2)
|
||||
sim.stop() # power off
|
||||
|
||||
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
secs = 5
|
||||
|
||||
_login(device, is_Q, bypass_pin)
|
||||
time.sleep(.1)
|
||||
title, story = _cap_story(device)
|
||||
assert "Spending Policy Unlock" in story
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
_login(device, is_Q, "22-22")
|
||||
|
||||
time.sleep(.15)
|
||||
scr = " ".join(_cap_screen(device).split("\n"))
|
||||
assert "Login countdown in effect" in scr
|
||||
assert "Must wait:" in scr
|
||||
assert f"{secs}s" in scr
|
||||
time.sleep(secs + 1)
|
||||
_login(device, is_Q, "22-22")
|
||||
time.sleep(3)
|
||||
m = _cap_menu(device)
|
||||
assert "Ready To Sign" in m
|
||||
sim.stop()
|
||||
|
||||
|
||||
def test_sssp_trick_pins(request):
|
||||
# only testing countdown TP
|
||||
ct_pin = "89-89"
|
||||
bypass_pin = "15-16"
|
||||
is_Q = request.config.getoption('--Q')
|
||||
clean_sim_data() # remove all from previous
|
||||
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
_login(device, is_Q, "22-22")
|
||||
|
||||
_pick_menu_item(device, is_Q, "Settings")
|
||||
_pick_menu_item(device, is_Q, "Login Settings")
|
||||
_pick_menu_item(device, is_Q, "Trick PINs")
|
||||
|
||||
# now countdown TP
|
||||
_pick_menu_item(device, is_Q, "Add New Trick")
|
||||
time.sleep(.1)
|
||||
|
||||
for ch in ct_pin[:2]:
|
||||
_need_keypress(device, ch)
|
||||
time.sleep(.1)
|
||||
_press_select(device, is_Q)
|
||||
|
||||
if not is_Q:
|
||||
# anti-phishing words
|
||||
_press_select(device, is_Q)
|
||||
|
||||
for ch in ct_pin[-2:]:
|
||||
_need_keypress(device, ch)
|
||||
time.sleep(.1)
|
||||
_press_select(device, is_Q)
|
||||
|
||||
_pick_menu_item(device, is_Q, "Login Countdown")
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
_pick_menu_item(device, is_Q, "Just Countdown")
|
||||
for _ in range(2):
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
|
||||
# adjust countdown to lowest possible value
|
||||
_pick_menu_item(device, is_Q, f'↳{ct_pin}')
|
||||
_pick_menu_item(device, is_Q, '↳Countdown')
|
||||
_need_keypress(device, "4")
|
||||
_pick_menu_item(device, is_Q, " 5 minutes")
|
||||
|
||||
for _ in range(10):
|
||||
_press_cancel(device, is_Q)
|
||||
|
||||
time.sleep(.1)
|
||||
_pick_menu_item(device, is_Q, "Advanced/Tools")
|
||||
_pick_menu_item(device, is_Q, "Spending Policy")
|
||||
_pick_menu_item(device, is_Q, "Single-Signer")
|
||||
_press_select(device, is_Q) # confirm story
|
||||
# now create bypass PIN
|
||||
# 1st entry
|
||||
time.sleep(.1)
|
||||
_login(device, is_Q, bypass_pin)
|
||||
# 2nd confirmation entry
|
||||
_login(device, is_Q, bypass_pin)
|
||||
|
||||
time.sleep(2)
|
||||
sim.stop()
|
||||
|
||||
sim = ColdcardSimulator(args=["--q1" if is_Q else "", "--pin", "22-22", "--early-usb"])
|
||||
sim.start(start_wait=6)
|
||||
device = ColdcardDevice(is_simulator=True)
|
||||
|
||||
_login(device, is_Q, bypass_pin)
|
||||
time.sleep(.1)
|
||||
title, story = _cap_story(device)
|
||||
assert "Spending Policy Unlock" in story
|
||||
_press_select(device, is_Q)
|
||||
time.sleep(.1)
|
||||
# try to log in with countdown TP instead of main
|
||||
# send you directly into countdown
|
||||
_login(device, is_Q, ct_pin)
|
||||
time.sleep(.15)
|
||||
scr = " ".join(_cap_screen(device).split("\n"))
|
||||
assert "Login countdown in effect" in scr
|
||||
assert "Must wait:" in scr
|
||||
assert "5s" in scr
|
||||
time.sleep(6)
|
||||
|
||||
sim.stop()
|
||||
|
||||
# EOF
|
||||
|
||||
@ -486,7 +486,6 @@ def test_tmp_on_xprv_master(generate_ephemeral_words, cap_menu, go_to_passphrase
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
|
||||
|
||||
assert parent_fp in title # no choice story
|
||||
assert "current active temporary seed" in story
|
||||
press_select()
|
||||
|
||||
@ -21,21 +21,18 @@ from psbt import BasicPSBT
|
||||
# pubkey for production server.
|
||||
SERVER_PUBKEY = '0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd'
|
||||
|
||||
def py_ckcc_hashfp(output, x, y, data=None):
|
||||
try:
|
||||
m = hashlib.sha256()
|
||||
m.update(x.contents.raw)
|
||||
m.update(y.contents.raw)
|
||||
output.contents.raw = m.digest()
|
||||
return 1
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
ckcc_hashfp = ECDH_HASHFP_CLS(py_ckcc_hashfp)
|
||||
@pytest.fixture
|
||||
def goto_ccc_menu(goto_home, pick_menu_item, is_mark4):
|
||||
def doit():
|
||||
goto_home()
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item("Spending Policy")
|
||||
pick_menu_item("Co-Sign Multi." if is_mark4 else "Co-Sign Multisig (CCC)")
|
||||
|
||||
return doit
|
||||
|
||||
def make_session_key(his_pubkey=None):
|
||||
|
||||
# - second call: given the pubkey of far side, calculate the shared pt on curve
|
||||
# - creates session key based on that
|
||||
while True:
|
||||
@ -50,6 +47,19 @@ def make_session_key(his_pubkey=None):
|
||||
his_pubkey = ec_pubkey_parse(bytes.fromhex(SERVER_PUBKEY))
|
||||
|
||||
# do the D-H thing
|
||||
|
||||
def _py_ckcc_hashfp(output, x, y, data=None):
|
||||
try:
|
||||
m = hashlib.sha256()
|
||||
m.update(x.contents.raw)
|
||||
m.update(y.contents.raw)
|
||||
output.contents.raw = m.digest()
|
||||
return 1
|
||||
except:
|
||||
return 0
|
||||
|
||||
ckcc_hashfp = ECDH_HASHFP_CLS(_py_ckcc_hashfp)
|
||||
|
||||
shared_key = ecdh(my_seckey, his_pubkey, hashfp=ckcc_hashfp)
|
||||
|
||||
return shared_key, ec_pubkey_serialize(my_pubkey)
|
||||
@ -168,24 +178,22 @@ def test_2fa_links(shared_secret, label_len, q_mode, roundtrip_2fa, sim_exec, re
|
||||
assert ans == f'CCC-AUTH:{nonce}'.upper() if q_mode else nonce
|
||||
|
||||
@pytest.fixture
|
||||
def get_last_violation(sim_exec):
|
||||
def get_last_violation(settings_get):
|
||||
def doit():
|
||||
return sim_exec('from ccc import CCCFeature; RV.write(CCCFeature.last_fail_reason)')
|
||||
return settings_get('lfr')
|
||||
return doit
|
||||
|
||||
_skip_quiz = False
|
||||
|
||||
@pytest.fixture
|
||||
def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz, is_q1,
|
||||
def setup_ccc(goto_ccc_menu, pick_menu_item, cap_story, press_select, pass_word_quiz, is_q1,
|
||||
seed_story_to_words, cap_menu, OK, word_menu_entry, press_cancel, press_delete,
|
||||
enter_number, scan_a_qr, cap_screen, settings_get, need_keypress, microsd_path,
|
||||
master_settings_get):
|
||||
|
||||
def doit(c_words=None, mag=None, vel=None, whitelist=None, w2fa=None, first_time=True):
|
||||
if first_time:
|
||||
goto_home()
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item("Coldcard Co-Signing")
|
||||
goto_ccc_menu()
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert title == ("Coldcard Co-Signing" if is_q1 else "CC Co-Sign")
|
||||
@ -242,7 +250,7 @@ def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz
|
||||
|
||||
m = cap_menu()
|
||||
|
||||
assert m[0] == f"CCC [{xfp}]"
|
||||
assert f"[{xfp}]" in m[0]
|
||||
assert "Spending Policy" in m
|
||||
assert "Export CCC XPUBs" in m
|
||||
assert "Multisig Wallets" in m
|
||||
@ -274,6 +282,7 @@ def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz
|
||||
assert f"{mag} {'BTC' if int(mag) < 1000 else 'SATS'}" in story
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
assert settings_get("ccc")["pol"]["mag"] == mag
|
||||
|
||||
if vel:
|
||||
@ -362,12 +371,10 @@ def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def enter_enabled_ccc(goto_home, pick_menu_item, cap_story, press_select, is_q1,
|
||||
def enter_enabled_ccc(goto_ccc_menu, pick_menu_item, cap_story, press_select, is_q1,
|
||||
word_menu_entry, cap_menu):
|
||||
def doit(c_words, seed_vault=False):
|
||||
goto_home()
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item("Coldcard Co-Signing")
|
||||
goto_ccc_menu()
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
if seed_vault:
|
||||
@ -578,13 +585,19 @@ def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert 'OK TO SEND?' == title
|
||||
if violation:
|
||||
if violation and num_warn:
|
||||
# assume CCC cases
|
||||
assert ("(%d warning%s below)"% (num_warn, "s" if num_warn > 1 else "")) in story
|
||||
assert "CCC: Violates spending policy. Won't sign." in story
|
||||
assert get_last_violation().startswith(violation)
|
||||
if warn_list:
|
||||
for w in warn_list:
|
||||
assert w in story
|
||||
elif violation and num_warn == 0:
|
||||
# assume SSSP cases
|
||||
assert 'warning' not in story
|
||||
assert "Spending Policy violation." in story
|
||||
assert ccc_disabled
|
||||
else:
|
||||
assert "warning" not in story
|
||||
|
||||
@ -1153,8 +1166,8 @@ def test_remove_ccc(settings_set, setup_ccc, ccc_ms_setup, settings_get, policy_
|
||||
|
||||
@pytest.mark.parametrize("has_candidates", [True, False])
|
||||
def test_c_key_from_seed_vault(has_candidates, setup_ccc, build_test_seed_vault, settings_set,
|
||||
goto_home, pick_menu_item, press_select, need_keypress, cap_menu,
|
||||
cap_story, press_cancel, enter_enabled_ccc):
|
||||
goto_ccc_menu, pick_menu_item, press_select, need_keypress, cap_menu,
|
||||
cap_story, press_cancel, enter_enabled_ccc, goto_home):
|
||||
goto_home()
|
||||
settings_set("ccc", None)
|
||||
settings_set("multisig", [])
|
||||
@ -1167,9 +1180,7 @@ def test_c_key_from_seed_vault(has_candidates, setup_ccc, build_test_seed_vault,
|
||||
|
||||
settings_set("seeds", sv)
|
||||
|
||||
goto_home()
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item("Coldcard Co-Signing")
|
||||
goto_ccc_menu()
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
@ -1236,4 +1247,4 @@ def test_ms_setup_cosigner_import(way, ftype, is_bbqr, N, goto_home, settings_se
|
||||
for _, obj in keys:
|
||||
assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc
|
||||
|
||||
# EOF
|
||||
# EOF
|
||||
|
||||
@ -221,10 +221,14 @@ def restore_main_seed(goto_home, pick_menu_item, cap_story, cap_menu,
|
||||
|
||||
@pytest.fixture
|
||||
def confirm_tmp_seed(need_keypress, cap_story, press_select):
|
||||
def doit(seedvault=False, expect_xfp=None):
|
||||
def doit(seedvault=False, expect_xfp=None, check_sv_not_offered=False):
|
||||
|
||||
time.sleep(0.3)
|
||||
title, story = cap_story()
|
||||
|
||||
if check_sv_not_offered:
|
||||
assert "to store temporary seed into Seed Vault" not in story
|
||||
|
||||
if "Press (1) to store temporary seed into Seed Vault" in story:
|
||||
if seedvault:
|
||||
need_keypress("1") # store it
|
||||
@ -332,7 +336,7 @@ def verify_ephemeral_secret_ui(cap_story, cap_menu, dev, fake_txn, goto_home,
|
||||
|
||||
assert "Seed In Use" in m
|
||||
pick_menu_item("Seed In Use") # noop
|
||||
else:
|
||||
elif seed_vault is False:
|
||||
# Seed Vault disabled
|
||||
m = cap_menu()
|
||||
assert "Seed Vault" not in m
|
||||
@ -1541,26 +1545,30 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m
|
||||
pick_menu_item("Always Show")
|
||||
time.sleep(.3)
|
||||
m = cap_menu()
|
||||
assert m[1] == "Ready To Sign"
|
||||
assert m[0] == "<" + xfp2str(settings_get("xfp")) + ">"
|
||||
assert m[1] == "Ready To Sign"
|
||||
|
||||
goto_eph_seed_menu()
|
||||
pick_menu_item("Generate Words")
|
||||
pick_menu_item(f"12 Words")
|
||||
time.sleep(0.1)
|
||||
need_keypress("6") # skip words
|
||||
need_keypress("6") # skip quiz
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
if "Press (1) to store temporary seed" in story:
|
||||
# seed vault enabled
|
||||
press_select() # do not save
|
||||
press_select() # new tmp seed
|
||||
|
||||
time.sleep(.2)
|
||||
m = cap_menu()
|
||||
assert m[1] == "Ready To Sign"
|
||||
assert m[0] == "[" + xfp2str(settings_get("xfp")) + "]"
|
||||
pick_menu_item("Restore Master")
|
||||
press_select()
|
||||
|
||||
time.sleep(.3)
|
||||
m = cap_menu()
|
||||
assert m[1] == "Ready To Sign"
|
||||
@ -1568,11 +1576,13 @@ def test_home_menu_xfp(goto_home, pick_menu_item, press_select, cap_story, cap_m
|
||||
# disable now
|
||||
pick_menu_item("Settings")
|
||||
pick_menu_item("Home Menu XFP")
|
||||
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
if "Forces display of XFP" in story:
|
||||
press_select()
|
||||
pick_menu_item("Only Tmp")
|
||||
|
||||
time.sleep(.3)
|
||||
m = cap_menu()
|
||||
assert m[0] == "Ready To Sign"
|
||||
|
||||
441
testing/test_hobble.py
Normal file
441
testing/test_hobble.py
Normal file
@ -0,0 +1,441 @@
|
||||
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# Verify hobble works: a restricted access mode, without export/view of seed and more.
|
||||
#
|
||||
# - spending policy menu and txn checks should not be in this file, instead expand
|
||||
# test_ccc.py or create test_sssp.py
|
||||
#
|
||||
# Additional tests, elsewhere:
|
||||
#
|
||||
# - test_teleport.py::test_teleport_ms_sign
|
||||
# - verifies: MS psbt KT should still work in hobbled mode
|
||||
#
|
||||
# - test_teleport.py::test_hobble_limited
|
||||
# - verifies: scan a KT and have it rejected if not PSBT type: so R and E types
|
||||
#
|
||||
# - login_settings_tests.py for login/bypass UX
|
||||
#
|
||||
#
|
||||
import pytest, time, os, pdb
|
||||
from bip32 import BIP32Node
|
||||
from constants import simulator_fixed_words, simulator_fixed_xprv
|
||||
from test_ephemeral import SEEDVAULT_TEST_DATA, WORDLISTS
|
||||
from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
|
||||
from test_ux import word_menu_entry
|
||||
from charcodes import KEY_QR
|
||||
|
||||
@pytest.fixture
|
||||
def set_hobble(sim_exec, settings_set, settings_remove, goto_home):
|
||||
def doit(mode, enabled={}): # okeys, words, notes
|
||||
assert mode in { True, False, 2 }
|
||||
|
||||
if mode:
|
||||
v = dict(en=True, pol={})
|
||||
for w in enabled:
|
||||
v[w] = True
|
||||
settings_set('sssp', v)
|
||||
print(f'sssp = {v!r}')
|
||||
else:
|
||||
settings_remove('sssp')
|
||||
|
||||
sim_exec(f'''
|
||||
from pincodes import pa; from actions import goto_top_menu
|
||||
pa.hobbled_mode = {mode!r}
|
||||
goto_top_menu()
|
||||
''')
|
||||
goto_home() # required, not sure why
|
||||
|
||||
yield doit
|
||||
|
||||
doit(False)
|
||||
|
||||
@pytest.mark.parametrize('en_okeys', [ True, False] )
|
||||
@pytest.mark.parametrize('en_notes', [ True, False] )
|
||||
@pytest.mark.parametrize('en_nfc', [ True, False] )
|
||||
@pytest.mark.parametrize('en_multisig', [ True, False] )
|
||||
def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes, settings_set, need_some_notes, is_q1, is_mark4, en_nfc, sim_exec, en_multisig, vdisk_disabled):
|
||||
|
||||
# just enough to pass/fail the menu predicates!
|
||||
settings_set('seedvault', True)
|
||||
|
||||
#settings_set('nfc', en_nfc)
|
||||
sim_exec(f'import glob; glob.NFC = {(True if en_nfc else None)!r};')
|
||||
|
||||
settings_set('multisig', en_multisig)
|
||||
|
||||
if is_q1:
|
||||
need_some_notes()
|
||||
|
||||
# main menu basics
|
||||
expect = {'Ready To Sign', 'Address Explorer', 'Advanced/Tools' }
|
||||
|
||||
if is_q1:
|
||||
expect.add('Scan Any QR Code')
|
||||
else:
|
||||
expect.add('Secure Logout')
|
||||
|
||||
en = set()
|
||||
if en_okeys:
|
||||
en.add('okeys')
|
||||
expect.add('Seed Vault')
|
||||
expect.add('Passphrase')
|
||||
|
||||
if en_notes:
|
||||
en.add('notes')
|
||||
if is_q1:
|
||||
expect.add('Secure Notes & Passwords')
|
||||
|
||||
# enables hobble and goes to top menu
|
||||
set_hobble(True, en)
|
||||
|
||||
m = cap_menu()
|
||||
assert set(m) == expect, 'Main menu wrong'
|
||||
|
||||
# advanced menu
|
||||
pick_menu_item("Advanced/Tools")
|
||||
|
||||
adv_expect = { 'File Management',
|
||||
'Export Wallet',
|
||||
'View Identity',
|
||||
'Paper Wallets',
|
||||
'Destroy Seed' }
|
||||
|
||||
if is_q1 and en_multisig:
|
||||
adv_expect.add('Teleport Multisig PSBT')
|
||||
|
||||
if en_nfc:
|
||||
adv_expect.add('NFC Tools')
|
||||
|
||||
if en_okeys:
|
||||
adv_expect.add('Temporary Seed')
|
||||
|
||||
m = cap_menu()
|
||||
assert set(m) == adv_expect, "Adv menu wrong"
|
||||
|
||||
# file management
|
||||
pick_menu_item("File Management")
|
||||
|
||||
fm_expect = { 'Sign Text File',
|
||||
'Batch Sign PSBT',
|
||||
'List Files',
|
||||
'Export Wallet',
|
||||
'Verify Sig File',
|
||||
'Format SD Card' }
|
||||
|
||||
if not vdisk_disabled:
|
||||
fm_expect.add('Format RAM Disk')
|
||||
|
||||
if en_nfc:
|
||||
fm_expect.add('NFC File Share')
|
||||
if is_q1:
|
||||
fm_expect.add('BBQr File Share')
|
||||
fm_expect.add('QR File Share')
|
||||
|
||||
m = cap_menu()
|
||||
assert set(m) == fm_expect, "File Mgmt menu wrong"
|
||||
|
||||
|
||||
def test_h_notes(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes, is_q1, sim_exec, settings_remove):
|
||||
'''
|
||||
* load a secure note/pw; check readonly once hobbled
|
||||
* cannot export
|
||||
* cannot edit
|
||||
* can view / use for kbd emulation
|
||||
* check notes not offered if none defined
|
||||
* check readonly features on notes when note pre-defined before entering hobbled mode
|
||||
'''
|
||||
need_some_notes()
|
||||
set_hobble(True, {'notes'})
|
||||
|
||||
pick_menu_item('Secure Notes & Passwords')
|
||||
|
||||
m = cap_menu()
|
||||
assert m == [ '1: Title Here' ]
|
||||
pick_menu_item(m[0])
|
||||
|
||||
m = cap_menu()
|
||||
assert m == [ '"Title Here"', 'View Note', 'Sign Note Text' ]
|
||||
|
||||
# clear notes, should not be offered
|
||||
settings_remove('notes')
|
||||
settings_remove('secnap')
|
||||
set_hobble(True, {'notes'})
|
||||
|
||||
m = cap_menu()
|
||||
assert 'Secure Notes & Passwords' not in m
|
||||
|
||||
def test_kt_limits(only_q1, set_hobble, pick_menu_item, cap_menu, settings_set, need_some_notes, is_q1, sim_exec, settings_remove):
|
||||
'''
|
||||
- key teleport
|
||||
* check KT only offered if MS wallet setup
|
||||
'''
|
||||
settings_remove('multisig')
|
||||
set_hobble(True)
|
||||
pick_menu_item("Advanced/Tools")
|
||||
|
||||
assert 'Teleport Multisig PSBT' not in cap_menu()
|
||||
# converse already tested in test_menu_contents
|
||||
|
||||
@pytest.mark.parametrize('sv_empty', [ True, False] )
|
||||
def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1, sim_exec, settings_remove, restore_main_seed, settings_get, press_cancel, press_select, cap_story):
|
||||
'''
|
||||
- seed vault can be accessed, when enabled
|
||||
- temp seeds are read-only: no create, no rename, etc.
|
||||
- SV menu item is offered iff SV enabled; can be empty or not.
|
||||
'''
|
||||
|
||||
settings_set('seedvault', True)
|
||||
if sv_empty:
|
||||
settings_set('seeds', [])
|
||||
else:
|
||||
settings_set('seeds', [])
|
||||
xfp, enc = SEEDVAULT_TEST_DATA[0][0:2]
|
||||
settings_set("seeds", [(xfp, '80'+enc, f"Menu Label", "meta-source")])
|
||||
|
||||
set_hobble(True, {'okeys'})
|
||||
|
||||
assert cap_menu()[0] == 'Ready To Sign', 'restart simulator now'
|
||||
pick_menu_item('Seed Vault')
|
||||
|
||||
m = cap_menu()
|
||||
if sv_empty:
|
||||
assert m == ['(none saved yet)']
|
||||
else:
|
||||
assert m == [' 1: Menu Label']
|
||||
|
||||
pick_menu_item(m[0])
|
||||
m = cap_menu()
|
||||
assert m == ['Menu Label', 'Use This Seed']
|
||||
|
||||
pick_menu_item(m[0])
|
||||
title, story = cap_story()
|
||||
assert 'Origin:\nmeta-source' in story
|
||||
press_cancel()
|
||||
|
||||
pick_menu_item('Use This Seed')
|
||||
title, story = cap_story()
|
||||
assert 'temporary master key is in effect' in story
|
||||
press_select()
|
||||
|
||||
# arrive back in main menu, w/ tmp seed in effect
|
||||
# - but we are still hobbled.
|
||||
# - XFP shown
|
||||
# - Restore master should be offered.
|
||||
m = cap_menu()
|
||||
assert m[0] == f'[{xfp}]'
|
||||
assert m[-1] == 'Restore Master'
|
||||
|
||||
pick_menu_item("Advanced/Tools")
|
||||
m = cap_menu()
|
||||
assert 'Destroy Seed' in m # indicates hobble mode active
|
||||
press_cancel()
|
||||
|
||||
pick_menu_item("Restore Master")
|
||||
title, story = cap_story()
|
||||
assert 'main wallet' in story
|
||||
press_select()
|
||||
|
||||
|
||||
# clear keys from sv, should not be offered in menu, even if okeys set.
|
||||
settings_remove('seedvault')
|
||||
set_hobble(True, {'okey'})
|
||||
|
||||
m = cap_menu()
|
||||
assert 'Seed Vault' not in m
|
||||
|
||||
@pytest.mark.parametrize('mode', [ 'words', 'qr', 'xprv', 'tapsigner', 'coldcard', 'b39pass'])
|
||||
def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1,
|
||||
press_select, cap_story, word_menu_entry, confirm_tmp_seed, enter_complex,
|
||||
verify_ephemeral_secret_ui, scan_a_qr, tapsigner_encrypted_backup,
|
||||
need_keypress, enter_hex, open_microsd, microsd_path, go_to_passphrase):
|
||||
'''
|
||||
- can import and use a key for signing
|
||||
- NOT offered chance to save into seedvault
|
||||
'''
|
||||
if not is_q1 and mode == 'qr': return
|
||||
|
||||
settings_set('seedvault', True)
|
||||
settings_set('seeds', [])
|
||||
|
||||
set_hobble(True, {'okeys'})
|
||||
|
||||
if mode != "b39pass":
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item('Temporary Seed')
|
||||
|
||||
m = cap_menu()
|
||||
assert 'Generate Words' not in m
|
||||
assert all(i.startswith("Import ") or i.endswith(' Backup') for i in m), m
|
||||
|
||||
words, expect_xfp = WORDLISTS[12]
|
||||
|
||||
if mode == 'words':
|
||||
# just quick tests here, not in-depth
|
||||
# - from test_ephemeral_seed_import_words()
|
||||
pick_menu_item("Import Words")
|
||||
pick_menu_item(f"12 Words")
|
||||
time.sleep(0.1)
|
||||
word_menu_entry(words.split())
|
||||
|
||||
elif mode == 'qr':
|
||||
pick_menu_item("Import from QR Scan")
|
||||
val = ' '.join(words.split()).upper()
|
||||
scan_a_qr(val)
|
||||
time.sleep(0.2)
|
||||
|
||||
elif mode == 'tapsigner':
|
||||
# like test_ephemeral_seed_import_tapsigner()
|
||||
fname, backup_key_hex, node = tapsigner_encrypted_backup('sd', testnet=True)
|
||||
expect_xfp = node.fingerprint().hex().upper()
|
||||
pick_menu_item("Tapsigner Backup")
|
||||
time.sleep(0.1)
|
||||
need_keypress('1')
|
||||
time.sleep(0.1)
|
||||
pick_menu_item(fname)
|
||||
|
||||
time.sleep(0.1)
|
||||
_, story = cap_story()
|
||||
assert "your TAPSIGNER" in story
|
||||
|
||||
press_select() # yes I have backup key
|
||||
enter_hex(backup_key_hex)
|
||||
|
||||
elif mode == 'coldcard':
|
||||
# like test_temporary_from_backup()
|
||||
# - but skip making new bk file
|
||||
fn = 'data/tip-index-famous-embark-tobacco-rice-attitude-interest-mask-random-amazing-initial.7z'
|
||||
pw = fn[5:-3].split('-')
|
||||
|
||||
contents = open(fn, 'rb').read()
|
||||
with open_microsd('example.7z', 'wb') as fd:
|
||||
fd.write(contents)
|
||||
|
||||
pick_menu_item("Coldcard Backup")
|
||||
time.sleep(0.1)
|
||||
need_keypress('1')
|
||||
time.sleep(0.1)
|
||||
pick_menu_item('example.7z')
|
||||
|
||||
word_menu_entry(pw, has_checksum=False)
|
||||
|
||||
title, story = cap_story()
|
||||
assert title == 'FAILED'
|
||||
assert 'successfully tested recovery' in story
|
||||
|
||||
press_select()
|
||||
|
||||
return
|
||||
|
||||
elif mode == 'xprv':
|
||||
fname = "ek.txt"
|
||||
node = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN")
|
||||
expect_xfp = node.fingerprint().hex().upper()
|
||||
ek = node.hwif(as_private=True)
|
||||
with open(microsd_path(fname), "w") as f:
|
||||
f.write(ek)
|
||||
|
||||
pick_menu_item("Import XPRV")
|
||||
time.sleep(0.1)
|
||||
_, story = cap_story()
|
||||
if "Press (1) to import extended private key" in story:
|
||||
need_keypress("1")
|
||||
|
||||
time.sleep(0.1)
|
||||
pick_menu_item(fname)
|
||||
|
||||
elif mode == "b39pass":
|
||||
from mnemonic import Mnemonic
|
||||
go_to_passphrase()
|
||||
passphrase = "sssp"
|
||||
seed = Mnemonic.to_seed(simulator_fixed_words, passphrase=passphrase)
|
||||
node = BIP32Node.from_master_secret(seed, netcode="XTN")
|
||||
expect_xfp = node.fingerprint().hex().upper()
|
||||
|
||||
enter_complex(passphrase, apply=True)
|
||||
time.sleep(.2)
|
||||
title, story = cap_story()
|
||||
assert title[1:-1] == expect_xfp
|
||||
assert "Above is the master key fingerprint of the new wallet" in story
|
||||
press_select()
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert "store temporary seed into Seed Vault" not in story
|
||||
time.sleep(.1)
|
||||
|
||||
else:
|
||||
raise pytest.fail(mode)
|
||||
|
||||
if mode != "b39pass":
|
||||
# different UX for passphrase - verified above
|
||||
confirm_tmp_seed(seedvault=False, check_sv_not_offered=True)
|
||||
|
||||
# do not verify presence of Seed Vault menu item - irrelevant
|
||||
verify_ephemeral_secret_ui(expected_xfp=expect_xfp, mnemonic=None, seed_vault=None)
|
||||
|
||||
pick_menu_item("Restore Master")
|
||||
press_select()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('en_okeys', [ True, False])
|
||||
def test_h_usbcmds(en_okeys, set_hobble, dev):
|
||||
# test various usb commands are blocked during hobble
|
||||
|
||||
from ckcc_protocol.protocol import CCProtoError
|
||||
|
||||
set_hobble(True, {'okeys'} if en_okeys else {})
|
||||
|
||||
block_list = [ 'back', 'enrl', 'bagi', 'hsms', 'user', 'nwur', 'rmur' ]
|
||||
|
||||
if not en_okeys:
|
||||
block_list.insert(0, 'pass')
|
||||
|
||||
for cmd in block_list:
|
||||
with pytest.raises(CCProtoError) as ee:
|
||||
got = dev.send_recv(cmd)
|
||||
assert 'Spending policy in effect' in str(ee)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('en_okeys', [ True, False])
|
||||
def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel, cap_screen, only_q1, cap_story, press_select, pick_menu_item):
|
||||
# verify whitelist of QR types is correct when in hobbled mode
|
||||
# - no private key material, unless "okeys" is set
|
||||
# - no teleport starting, except multisig co-signing
|
||||
#
|
||||
set_hobble(True, {'okeys'} if en_okeys else {})
|
||||
|
||||
words, _ = WORDLISTS[12]
|
||||
keys = [
|
||||
' '.join(w[0:4] for w in words.split()),
|
||||
simulator_fixed_xprv]
|
||||
|
||||
for ss in keys:
|
||||
need_keypress(KEY_QR)
|
||||
scan_a_qr(ss)
|
||||
time.sleep(0.5)
|
||||
|
||||
title, story = cap_story()
|
||||
if en_okeys:
|
||||
assert 'New temporary master key is in effect' in story
|
||||
press_select()
|
||||
|
||||
pick_menu_item("Restore Master")
|
||||
press_select()
|
||||
else:
|
||||
assert 'Blocked when Spending Policy is in force.' in story
|
||||
press_select()
|
||||
|
||||
for dt in 'RSE':
|
||||
need_keypress(KEY_QR)
|
||||
tt = f'B$H{dt}0100'+('A'*80)
|
||||
scan_a_qr(tt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if dt == 'E':
|
||||
title, story = cap_story()
|
||||
assert 'Incoming PSBT requires multisig wallet' in story
|
||||
press_cancel()
|
||||
else:
|
||||
scr = cap_screen() # stays in scanning mode
|
||||
assert 'KT Blocked' in scr
|
||||
|
||||
# EOF
|
||||
@ -1487,6 +1487,8 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev
|
||||
f.write(b64encode(psbt).decode())
|
||||
for idx in range(M):
|
||||
select_wallet(idx)
|
||||
if incl_xpubs:
|
||||
clear_ms()
|
||||
_, updated = try_sign(psbt, accept_ms_import=incl_xpubs)
|
||||
with open(f'{sim_root_dir}/debug/myself-after.psbt', 'w') as f:
|
||||
f.write(b64encode(updated).decode())
|
||||
|
||||
@ -40,9 +40,10 @@ def goto_notes(cap_story, cap_menu, press_select, goto_home, pick_menu_item):
|
||||
return doit
|
||||
|
||||
@pytest.fixture
|
||||
def need_some_notes(settings_get, settings_set):
|
||||
def need_some_notes(is_q1, settings_get, settings_set):
|
||||
# create a note or use what's there, provide as obj
|
||||
def doit(title='Title Here', body='Body'):
|
||||
assert is_q1
|
||||
notes = settings_get('notes', [])
|
||||
if not notes:
|
||||
settings_set('notes', [dict(misc=body, title=title)])
|
||||
|
||||
@ -301,8 +301,10 @@ def new_trick_pin(goto_trick_menu, pick_menu_item, cap_menu, press_select,
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
assert m[0] == f'[{new_pin}]'
|
||||
assert set(m[1:]) == {'Duress Wallet', 'Just Reboot', 'Wipe Seed', \
|
||||
'Delta Mode', 'Look Blank', 'Brick Self', 'Login Countdown'}
|
||||
assert set(m[1:]) == {'Duress Wallet', 'Just Reboot', 'Wipe Seed', 'Delta Mode',
|
||||
'Look Blank', 'Policy Unlock',
|
||||
'Policy Unlock & Wipe' if is_q1 else 'P.U. & Wipe',
|
||||
'Brick Self', 'Login Countdown'}
|
||||
|
||||
pick_menu_item(op_mode)
|
||||
|
||||
@ -914,6 +916,14 @@ def build_duress_wallets(request, seed_vault=False):
|
||||
return 4
|
||||
|
||||
|
||||
def test_deltamode_toggle(get_deltamode, set_deltamode):
|
||||
# check test fixture works.
|
||||
assert get_deltamode() == False
|
||||
set_deltamode(True)
|
||||
assert get_deltamode() == True
|
||||
set_deltamode(False)
|
||||
assert get_deltamode() == False
|
||||
|
||||
|
||||
# TODO
|
||||
# - make trick and do login, check arrives right state?
|
||||
|
||||
621
testing/test_sssp.py
Normal file
621
testing/test_sssp.py
Normal file
@ -0,0 +1,621 @@
|
||||
# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# tests related to Single Signer Spending Policy feature (SSSP)
|
||||
#
|
||||
# run simulator without --eff
|
||||
#
|
||||
#
|
||||
import pytest, time, base64, os
|
||||
from psbt import BasicPSBT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def goto_sssp_menu(goto_home, pick_menu_item, is_mark4):
|
||||
def doit():
|
||||
goto_home()
|
||||
pick_menu_item("Advanced/Tools")
|
||||
pick_menu_item("Spending Policy")
|
||||
pick_menu_item("Single-Signer")
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_sssp(goto_sssp_menu, pick_menu_item, cap_story, press_select, pass_word_quiz, is_q1,
|
||||
seed_story_to_words, cap_menu, OK, word_menu_entry, press_cancel, press_delete,
|
||||
enter_number, scan_a_qr, cap_screen, settings_get, need_keypress, microsd_path,
|
||||
master_settings_get, enter_pin, settings_remove, sim_exec):
|
||||
|
||||
def doit(pin=None, mag=None, vel=None, whitelist=None, w2fa=None, has_violation=None,
|
||||
word_check=None, notes_and_pws=None, rel_keys=None):
|
||||
|
||||
goto_sssp_menu()
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
|
||||
# it is possible that PIN was set beforehand
|
||||
if title == "Spending Policy":
|
||||
assert "stops you from signing transactions unless conditions are met" in story
|
||||
assert "locked into a special mode" in story
|
||||
assert "First step is to define a new PIN" in story
|
||||
press_select()
|
||||
time.sleep(.1)
|
||||
scr = cap_screen()
|
||||
if "Spending Policy" in scr:
|
||||
what = "Enter first part of PIN" if is_q1 else "Enter PIN Prefix"
|
||||
assert what in scr
|
||||
|
||||
enter_pin(pin)
|
||||
time.sleep(.1)
|
||||
scr = cap_screen()
|
||||
what = "Confirm PIN value"if is_q1 else "CONFIRM PIN VALUE"
|
||||
assert what in scr
|
||||
enter_pin(pin)
|
||||
time.sleep(.1)
|
||||
|
||||
m = cap_menu()
|
||||
|
||||
assert "Edit Policy..." in m
|
||||
if has_violation is not None:
|
||||
if has_violation:
|
||||
assert "Last Violation" in m
|
||||
else:
|
||||
assert "last Violation" not in m
|
||||
|
||||
assert "Word Check" in m
|
||||
assert "Allow Notes" in m
|
||||
assert "Related Keys" in m
|
||||
assert "Remove Policy" in m
|
||||
assert "Test Drive" in m
|
||||
assert "ACTIVATE" in m
|
||||
|
||||
pick_menu_item("Edit Policy...")
|
||||
|
||||
whitelist_mi = "Whitelist Addresses" if is_q1 else "Whitelist"
|
||||
mag_mi = "Max Magnitude"
|
||||
vel_mi = "Limit Velocity"
|
||||
mi_2fa = "Web 2FA"
|
||||
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
assert mag_mi in m
|
||||
assert vel_mi in m
|
||||
assert whitelist_mi in m
|
||||
assert mi_2fa in m
|
||||
|
||||
# setting above values here
|
||||
if mag:
|
||||
pick_menu_item(mag_mi)
|
||||
enter_number(mag)
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert f"{mag} {'BTC' if int(mag) < 1000 else 'SATS'}" in story
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
assert settings_get("sssp")["pol"]["mag"] == mag
|
||||
|
||||
if vel:
|
||||
if not settings_get("sssp")["pol"].get("mag", None):
|
||||
pick_menu_item(vel_mi)
|
||||
title, story = cap_story()
|
||||
assert 'Velocity limit requires' in story
|
||||
assert 'starting value' in story
|
||||
press_select()
|
||||
else:
|
||||
pick_menu_item(vel_mi)
|
||||
|
||||
if vel == "Unlimited":
|
||||
target = 0
|
||||
else:
|
||||
target = int(vel.split()[0])
|
||||
|
||||
pick_menu_item(vel) # actually a full menu item
|
||||
time.sleep(.3)
|
||||
assert settings_get("sssp")["pol"]["vel"] == target
|
||||
|
||||
if whitelist:
|
||||
pick_menu_item(whitelist_mi)
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
assert "(none yet)" in m
|
||||
assert "Import from File" in m
|
||||
if is_q1:
|
||||
assert "Scan QR" in m
|
||||
pick_menu_item("Scan QR")
|
||||
for i, addr in enumerate(whitelist, start=1):
|
||||
scan_a_qr(addr)
|
||||
|
||||
for _ in range(10):
|
||||
scr = cap_screen()
|
||||
if (f"Got {i} so far" in scr) and ("ENTER to apply" in scr):
|
||||
break
|
||||
time.sleep(.2)
|
||||
else:
|
||||
assert False, "updating whitelist failed"
|
||||
|
||||
press_select()
|
||||
else:
|
||||
assert "Scan QR" not in m
|
||||
fname = "ccc_addrs.txt"
|
||||
with open(microsd_path(fname), "w") as f:
|
||||
for a in whitelist:
|
||||
f.write(f"{a}\n")
|
||||
|
||||
pick_menu_item("Import from File")
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
if "Press (1)" in story:
|
||||
need_keypress("1")
|
||||
pick_menu_item(fname)
|
||||
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
if len(whitelist) == 1:
|
||||
assert "Added new address to whitelist" in story
|
||||
else:
|
||||
assert f"Added {len(whitelist)} new addresses to whitelist" in story
|
||||
|
||||
for addr in whitelist:
|
||||
assert addr in story
|
||||
|
||||
# check menu correct
|
||||
press_select()
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
mi_addrs = [a for a in m if '⋯' in a]
|
||||
for mia, addr in zip(mi_addrs, reversed(whitelist)):
|
||||
_start, _end = mia.split('⋯')
|
||||
assert addr.startswith(_start)
|
||||
assert addr.endswith(_end)
|
||||
|
||||
press_cancel()
|
||||
|
||||
assert settings_get("sssp")["pol"]["addrs"] == whitelist
|
||||
|
||||
if w2fa:
|
||||
pick_menu_item(mi_2fa)
|
||||
|
||||
press_cancel() # leave Edit Policy... (shared settings with CCC)
|
||||
|
||||
# now rest of sssp specific settings
|
||||
if word_check is not None:
|
||||
pick_menu_item("Word Check")
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert "addition to special PIN" in story
|
||||
assert "provide the first and last seed words" in story
|
||||
if word_check:
|
||||
assert "Enable?" in story
|
||||
press_select() # confirm action
|
||||
assert settings_get("sssp")["words"]
|
||||
else:
|
||||
assert "Disable?" in story
|
||||
pol = settings_get("sssp")
|
||||
if "words" in pol:
|
||||
assert not pol["words"]
|
||||
|
||||
if notes_and_pws is not None:
|
||||
pick_menu_item("Allow Notes")
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert "Allow (read-only) access to secure notes and passwords?" in story
|
||||
if notes_and_pws:
|
||||
assert "Enable?" in story
|
||||
press_select() # confirm action
|
||||
assert settings_get("sssp")["notes"]
|
||||
else:
|
||||
assert "Disable?" in story
|
||||
pol = settings_get("sssp")
|
||||
if "notes" in pol:
|
||||
assert not pol["notes"]
|
||||
|
||||
if rel_keys is not None:
|
||||
pick_menu_item("Related Keys")
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert "Allow access to BIP-39 passphrase wallets" in story
|
||||
assert "or Seed Vault (if any)" in story
|
||||
if rel_keys:
|
||||
assert "Enable?" in story
|
||||
press_select() # confirm action
|
||||
assert settings_get("sssp")["okeys"]
|
||||
else:
|
||||
assert "Disable?" in story
|
||||
pol = settings_get("sssp")
|
||||
if "okeys" in pol:
|
||||
assert not pol["okeys"]
|
||||
|
||||
yield doit
|
||||
|
||||
# cleanup code -- all users of this fixture will get this code
|
||||
|
||||
settings_remove("sssp")
|
||||
sim_exec('from pincodes import pa;pa.hobbled_mode = False; from actions import goto_top_menu; goto_top_menu()')
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
|
||||
def doit(wallet, psbt, violation=None):
|
||||
start_sign(base64.b64decode(psbt))
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
|
||||
if violation:
|
||||
# assume SSSP cases
|
||||
assert title == "Failure"
|
||||
assert 'warning' not in story
|
||||
assert "Spending Policy violation." in story
|
||||
assert violation in get_last_violation()
|
||||
return
|
||||
|
||||
assert 'OK TO SEND?' == title
|
||||
assert "warning" not in story
|
||||
|
||||
signed = end_sign(accept=True)
|
||||
po = BasicPSBT().parse(signed)
|
||||
|
||||
tx_hex = None
|
||||
if violation is None:
|
||||
assert not get_last_violation()
|
||||
assert len(po.inputs[0].part_sigs) or po.inputs[0].taproot_key_sig
|
||||
res = wallet.finalizepsbt(base64.b64encode(signed).decode())
|
||||
assert res["complete"]
|
||||
tx_hex = res["hex"]
|
||||
res = wallet.testmempoolaccept([tx_hex])
|
||||
assert res[0]["allowed"]
|
||||
res = wallet.sendrawtransaction(tx_hex)
|
||||
assert len(res) == 64 # tx id
|
||||
|
||||
return signed, tx_hex
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remove_settings_slots(settings_slots):
|
||||
for s in settings_slots():
|
||||
try:
|
||||
os.remove(s)
|
||||
except: pass
|
||||
|
||||
|
||||
@pytest.mark.bitcoind
|
||||
@pytest.mark.parametrize("mag_ok", [True, False])
|
||||
@pytest.mark.parametrize("mag", [1000000, 2])
|
||||
def test_magnitude(mag_ok, mag, setup_sssp, bitcoind, settings_set, pick_menu_item,
|
||||
bitcoind_d_sim_watch, policy_sign, press_select,
|
||||
reset_seed_words, settings_path, remove_settings_slots):
|
||||
|
||||
wo = bitcoind_d_sim_watch
|
||||
|
||||
settings_set("chain", "XRT")
|
||||
|
||||
if mag_ok:
|
||||
# always try limit/border value
|
||||
if mag is None:
|
||||
to_send = 1
|
||||
else:
|
||||
to_send = mag / 100000000 if mag > 1000 else mag
|
||||
else:
|
||||
if mag is None:
|
||||
to_send = 1.1
|
||||
else:
|
||||
to_send = ((mag / 100000000)+1) if mag > 1000 else (mag+0.001)
|
||||
|
||||
setup_sssp("11-11", mag=mag)
|
||||
|
||||
pick_menu_item("ACTIVATE")
|
||||
press_select()
|
||||
|
||||
addr = wo.getnewaddress()
|
||||
bitcoind.supply_wallet.sendtoaddress(address=addr, amount=5.0)
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
# create funded PSBT
|
||||
psbt_resp = wo.walletcreatefundedpsbt(
|
||||
[], [{bitcoind.supply_wallet.getnewaddress(): to_send}], 0, {"fee_rate": 2}
|
||||
)
|
||||
psbt = psbt_resp.get("psbt")
|
||||
|
||||
policy_sign(wo, psbt, violation=None if mag_ok else "magnitude")
|
||||
|
||||
|
||||
@pytest.mark.bitcoind
|
||||
@pytest.mark.parametrize("whitelist_ok", [True, False])
|
||||
def test_whitelist(whitelist_ok, setup_sssp, bitcoind, settings_set, policy_sign,
|
||||
bitcoind_d_sim_watch):
|
||||
|
||||
wo = bitcoind_d_sim_watch
|
||||
|
||||
settings_set("chain", "XRT")
|
||||
|
||||
whitelist = [
|
||||
"bcrt1qqca9eefwz8tzn7rk6aumhwhapyf5vsrtrddxxp",
|
||||
"bcrt1q7nck280nje50gzjja3gyguhp2ds6astu5ndhkj",
|
||||
"bcrt1qhexpvdhwuerqq0h24j06g8y5eumjjdr28ng4vv",
|
||||
"bcrt1q3ylr55pk7rl0rc06d8th7h25zmcuvvg8wt0yl3",
|
||||
]
|
||||
|
||||
if whitelist_ok:
|
||||
send_to = whitelist[0]
|
||||
else:
|
||||
send_to = bitcoind.supply_wallet.getnewaddress()
|
||||
|
||||
setup_sssp("11-11", whitelist=whitelist)
|
||||
|
||||
multi_addr = wo.getnewaddress()
|
||||
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=5.0)
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
# create funded PSBT
|
||||
psbt_resp = wo.walletcreatefundedpsbt(
|
||||
[], [{send_to: 1}], 0, {"fee_rate": 2}
|
||||
)
|
||||
psbt = psbt_resp.get("psbt")
|
||||
policy_sign(wo, psbt, violation=None if whitelist_ok else "whitelist")
|
||||
|
||||
|
||||
@pytest.mark.bitcoind
|
||||
@pytest.mark.parametrize("velocity_mi", ['6 blocks (hour)', '48 blocks (8h)'])
|
||||
def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set,
|
||||
policy_sign, settings_get, bitcoind_d_sim_watch):
|
||||
|
||||
wo = bitcoind_d_sim_watch
|
||||
wo.keypoolrefill(20)
|
||||
settings_set("chain", "XRT")
|
||||
|
||||
blocks = int(velocity_mi.split()[0])
|
||||
|
||||
setup_sssp("11-11", vel=velocity_mi)
|
||||
|
||||
assert "block_h" not in settings_get("sssp")["pol"]
|
||||
|
||||
multi_addr = wo.getnewaddress()
|
||||
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49)
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
# create funded PSBT, first tx
|
||||
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
|
||||
init_block_height) # nLockTime set to current block height
|
||||
psbt = psbt_resp.get("psbt")
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt))
|
||||
assert po.parsed_txn.nLockTime == init_block_height
|
||||
policy_sign(wo, psbt) # success as this is first tx that sets block height from 0
|
||||
|
||||
assert settings_get("sssp")["pol"]["block_h"] == init_block_height
|
||||
|
||||
# mine some, BUT not enough to satisfy velocity policy
|
||||
# - check velocity is exactly right to block number vs. required gap
|
||||
bitcoind.supply_wallet.generatetoaddress(blocks - 1, bitcoind.supply_wallet.getnewaddress())
|
||||
block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"]
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
|
||||
block_height)
|
||||
psbt = psbt_resp.get("psbt")
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt))
|
||||
assert po.parsed_txn.nLockTime == block_height
|
||||
policy_sign(wo, psbt, violation="velocity")
|
||||
|
||||
assert settings_get("sssp")["pol"]["block_h"] == init_block_height # still initial block height as above failed
|
||||
|
||||
# mine the remaining one block to satisfy velocity policy
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"]
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
|
||||
block_height)
|
||||
psbt = psbt_resp.get("psbt")
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt))
|
||||
assert po.parsed_txn.nLockTime == block_height
|
||||
policy_sign(wo, psbt) # success
|
||||
|
||||
assert settings_get("sssp")["pol"]["block_h"] == block_height # updated block height
|
||||
|
||||
# check txn re-sign fails (if velocity in effect)
|
||||
policy_sign(wo, psbt, violation="rewound")
|
||||
# check decreasing nLockTime
|
||||
policy_sign(
|
||||
wo,
|
||||
wo.walletcreatefundedpsbt(
|
||||
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], block_height - 1
|
||||
)["psbt"],
|
||||
violation="rewound"
|
||||
)
|
||||
# check nLockTime disabled when velocity enabled - fail
|
||||
policy_sign(
|
||||
wo,
|
||||
wo.walletcreatefundedpsbt(
|
||||
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], 0
|
||||
)["psbt"],
|
||||
violation="no nLockTime"
|
||||
)
|
||||
# unix timestamp
|
||||
policy_sign(
|
||||
wo,
|
||||
wo.walletcreatefundedpsbt(
|
||||
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], 500000000
|
||||
)["psbt"],
|
||||
violation="nLockTime not height"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.bitcoind
|
||||
def test_warnings(setup_sssp, bitcoind, settings_set, policy_sign,
|
||||
bitcoind_d_sim_watch, settings_get):
|
||||
|
||||
wo = bitcoind_d_sim_watch
|
||||
wo.keypoolrefill(20)
|
||||
|
||||
settings_set("chain", "XRT")
|
||||
|
||||
whitelist = ["bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e",
|
||||
"2Mxp1Dy2MyR4w36J2VaZhrFugNNFgh6LC1j",
|
||||
"mjR14oKxYzRg9RAZdpu3hrw8zXfFgGzLKm"]
|
||||
|
||||
setup_sssp("11-11", mag=10000000, vel='6 blocks (hour)', whitelist=whitelist)
|
||||
|
||||
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
# create funded PSBT, first tx
|
||||
# whitelist OK, velocity OK, & magnitude OK - but fee high
|
||||
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
|
||||
init_block_height, {"fee_rate":48000})
|
||||
psbt = psbt_resp.get("psbt")
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt))
|
||||
assert po.parsed_txn.nLockTime == init_block_height
|
||||
policy_sign(wo, psbt, violation="has warnings")
|
||||
|
||||
# invalidate nLockTime with use of nSequence max values
|
||||
utxos = wo.listunspent()
|
||||
ins = []
|
||||
for i, utxo in enumerate(utxos):
|
||||
# block height based RTL
|
||||
inp = {
|
||||
"txid": utxo["txid"],
|
||||
"vout": utxo["vout"],
|
||||
"sequence": 0xffffffff,
|
||||
}
|
||||
ins.append(inp)
|
||||
|
||||
psbt_resp = wo.walletcreatefundedpsbt(ins, [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
|
||||
0, {"fee_rate":2, "replaceable": False}) # locktime needs to be zero, otherwise exception from core (contradicting parameters)
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt_resp.get("psbt")))
|
||||
assert po.parsed_txn.nLockTime == 0
|
||||
po.parsed_txn.nLockTime = init_block_height # add locktime
|
||||
po.txn = po.parsed_txn.serialize_with_witness()
|
||||
# num_warn=2, warn_list=["Bad Locktime"]
|
||||
policy_sign(wo, po.as_b64_str(), violation="has warnings")
|
||||
|
||||
# exotic sighash warning
|
||||
settings_set("sighshchk", 1) # needed to only get warning instead of failure
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
|
||||
init_block_height, {"fee_rate":2, "replaceable": True})
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt_resp.get("psbt")))
|
||||
for idx, i in enumerate(po.inputs):
|
||||
i.sighash = 2 # NONE
|
||||
|
||||
# num_warn=2, warn_list=["sighash NONE"]
|
||||
policy_sign(wo, po.as_b64_str(), violation="has warnings")
|
||||
|
||||
|
||||
def test_remove_sssp(setup_sssp, pick_menu_item, press_select, cap_story, cap_menu, settings_get):
|
||||
setup_sssp("11-11", mag=10000000, vel='6 blocks (hour)')
|
||||
|
||||
# check test drive
|
||||
pick_menu_item("Test Drive")
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
assert "COLDCARD operation will look like with Spending Policy" in story
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
assert "EXIT TEST DRIVE" in m
|
||||
assert "Settings" not in m
|
||||
|
||||
pick_menu_item("EXIT TEST DRIVE")
|
||||
time.sleep(.1)
|
||||
m = cap_menu()
|
||||
assert "Edit Policy..." in m # back in policy settings
|
||||
|
||||
pick_menu_item("Remove Policy")
|
||||
time.sleep(.1)
|
||||
_, story = cap_story()
|
||||
assert "Bypass PIN will be removed" in story
|
||||
assert "spending policy settings forgotten" in story
|
||||
press_select()
|
||||
|
||||
time.sleep(.1)
|
||||
assert not settings_get("sssp")
|
||||
|
||||
tps = settings_get("tp")
|
||||
if tps:
|
||||
assert "11-11" not in tps
|
||||
|
||||
assert not settings_get("sssp")
|
||||
|
||||
|
||||
def test_use_main_pin_as_unlock(setup_sssp, cap_story):
|
||||
# not allowed
|
||||
# simulator PIN
|
||||
with pytest.raises(Exception):
|
||||
setup_sssp("12-12")
|
||||
|
||||
_, story = cap_story()
|
||||
assert "already in use" in story
|
||||
assert "PIN codes must be unique" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hide", [True, False])
|
||||
def test_use_trick_pin_as_unlock(hide, setup_sssp, cap_story, new_trick_pin, pick_menu_item,
|
||||
press_select, clear_all_tricks):
|
||||
clear_all_tricks()
|
||||
pin = "11-11"
|
||||
new_trick_pin(pin, 'Wipe Seed', 'Wipe the seed and maybe do more')
|
||||
pick_menu_item('Wipe & Reboot')
|
||||
press_select()
|
||||
press_select()
|
||||
if hide:
|
||||
pick_menu_item(f"↳{pin}")
|
||||
pick_menu_item("Hide Trick")
|
||||
press_select() # confirm
|
||||
|
||||
with pytest.raises(Exception):
|
||||
setup_sssp(pin)
|
||||
|
||||
_, story = cap_story()
|
||||
assert "already in use" in story
|
||||
assert "PIN codes must be unique" in story
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("active_policy", [False, True])
|
||||
def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
|
||||
start_sign, end_sign,
|
||||
set_deltamode, bitcoind_d_sim_watch, settings_get):
|
||||
|
||||
# verify that "deltamode" trick pins will work in SSSP mode
|
||||
# - and that resulting signature is bad
|
||||
# - device should **not** wipe itself
|
||||
|
||||
dest = "bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e"
|
||||
wo = bitcoind_d_sim_watch
|
||||
wo.keypoolrefill(20)
|
||||
|
||||
settings_set("chain", "XRT")
|
||||
|
||||
if active_policy:
|
||||
setup_sssp("11-11", mag=100)
|
||||
|
||||
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
|
||||
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
|
||||
|
||||
# create funded PSBT, first tx
|
||||
# - within active policy.
|
||||
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
|
||||
psbt_resp = wo.walletcreatefundedpsbt([], [{dest: 0.06}],
|
||||
init_block_height, {"fee_rate":2, "replaceable": False})
|
||||
psbt = psbt_resp.get("psbt")
|
||||
|
||||
po = BasicPSBT().parse(base64.b64decode(psbt))
|
||||
assert po.parsed_txn.nLockTime == init_block_height
|
||||
|
||||
start_sign(base64.b64decode(psbt), finalize=True)
|
||||
signed = end_sign(accept=True, finalize=True)
|
||||
|
||||
set_deltamode(True)
|
||||
|
||||
start_sign(base64.b64decode(psbt), finalize=True)
|
||||
signed2 = end_sign(accept=True, finalize=True)
|
||||
|
||||
# check wrong signature happened
|
||||
assert signed != signed2
|
||||
probs = wo.testmempoolaccept([signed2.hex()])[0]
|
||||
assert 'Signature must be zero' in probs['reject-reason'], probs
|
||||
assert not probs['allowed']
|
||||
|
||||
# check right signature
|
||||
no_probs = wo.testmempoolaccept([signed.hex()])[0]
|
||||
assert no_probs['allowed']
|
||||
|
||||
|
||||
# EOF
|
||||
@ -12,6 +12,7 @@ from base64 import b32encode
|
||||
from constants import *
|
||||
from test_ephemeral import SEEDVAULT_TEST_DATA
|
||||
from test_backup import make_big_notes
|
||||
from test_hobble import set_hobble
|
||||
|
||||
# All tests in this file are exclusively meant for Q
|
||||
#
|
||||
@ -132,7 +133,7 @@ def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr,
|
||||
if 'Teleport Password' in scr: break
|
||||
time.sleep(.2)
|
||||
else:
|
||||
assert False, "Teleport Password not in screen"
|
||||
raise RuntimeError("Teleport Password not in screen")
|
||||
|
||||
if expect_xfp:
|
||||
assert xfp2str(expect_xfp) in scr
|
||||
@ -417,19 +418,22 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite
|
||||
|
||||
@pytest.mark.unfinalized
|
||||
@pytest.mark.parametrize('num_ins', [ 15 ])
|
||||
@pytest.mark.parametrize('M', [2, 4])
|
||||
@pytest.mark.parametrize('M', [4])
|
||||
@pytest.mark.parametrize('segwit', [True])
|
||||
@pytest.mark.parametrize('incl_xpubs', [ False ])
|
||||
@pytest.mark.parametrize('hobbled', [ False, True ])
|
||||
def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms,
|
||||
fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress,
|
||||
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select,
|
||||
ndef_parse_txn_psbt, press_nfc, nfc_read, settings_get, settings_set,
|
||||
txid_from_export_prompt, sim_root_dir):
|
||||
txid_from_export_prompt, sim_root_dir,
|
||||
set_hobble, hobbled, readback_bbqr, nfc_is_enabled):
|
||||
|
||||
# IMPORTANT: won't work if you start simulator with --ms flag. Use no args
|
||||
all_out_styles = list(unmap_addr_fmt.keys())
|
||||
num_outs = len(all_out_styles)
|
||||
|
||||
set_hobble(hobbled)
|
||||
clear_ms()
|
||||
use_regtest()
|
||||
|
||||
@ -526,12 +530,19 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, d
|
||||
txid = txid_from_export_prompt()
|
||||
press_select() # exit QR
|
||||
|
||||
# share signed txn via low-level NFC
|
||||
press_nfc()
|
||||
time.sleep(.1)
|
||||
contents = nfc_read()
|
||||
if nfc_is_enabled():
|
||||
# share signed txn via low-level NFC
|
||||
press_nfc()
|
||||
time.sleep(.1)
|
||||
contents = nfc_read()
|
||||
|
||||
got_psbt, got_txn, _ = ndef_parse_txn_psbt(contents, txid, expect_finalized=True)
|
||||
got_psbt, got_txn, _ = ndef_parse_txn_psbt(contents, txid, expect_finalized=True)
|
||||
else:
|
||||
# NFC disabled. use other means .. bbqr
|
||||
need_keypress(KEY_QR)
|
||||
tcode, contents = readback_bbqr()
|
||||
got_txn = (tcode == 'T')
|
||||
got_psbt = (tcode == 'P')
|
||||
|
||||
assert not got_psbt
|
||||
assert got_txn
|
||||
@ -725,4 +736,26 @@ def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick
|
||||
settings_set('notes', [])
|
||||
|
||||
|
||||
def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, reset_seed_words, make_big_notes):
|
||||
# verify: in hobbled mode, KT is blocked for everything except multisig cases
|
||||
|
||||
set_hobble(True)
|
||||
|
||||
from bbqr import split_qrs
|
||||
|
||||
_, parts = split_qrs(b's'*33, 'R')
|
||||
rx_complete(parts[0], '12345678', expect_fail=True)
|
||||
time.sleep(.1)
|
||||
last = cap_screen().split('\n')[-1]
|
||||
assert last == 'KT Blocked'
|
||||
|
||||
_, parts = split_qrs(b's'*33, 'S')
|
||||
rx_complete(parts[0], 'abcdefgh', expect_fail=True)
|
||||
time.sleep(.1)
|
||||
last = cap_screen().split('\n')[-1]
|
||||
assert last == 'KT Blocked'
|
||||
|
||||
|
||||
|
||||
|
||||
# EOF
|
||||
|
||||
@ -29,11 +29,16 @@ if '--sflash' not in sys.argv:
|
||||
|
||||
if '--eff' in sys.argv:
|
||||
# ignore files ondisk from previous runs, and also dont write any
|
||||
nvstore.SettingsObject.load = lambda *a:None
|
||||
nvstore.SettingsObject.save = lambda *a:None
|
||||
# limitation: pre-login values arent stored even during operation
|
||||
# - but do track settings during this run
|
||||
NVSTORE_FAKE = {bytes(32): dict(sim_defaults)} # prelogin values
|
||||
|
||||
#glob.settings.current = dict(sim_defaults)
|
||||
def _monkey_load(self, *a):
|
||||
self.current = dict(NVSTORE_FAKE.get(self.nvram_key, False) or sim_defaults)
|
||||
def _monkey_save(self, *a):
|
||||
NVSTORE_FAKE[self.nvram_key] = dict(self.current)
|
||||
|
||||
nvstore.SettingsObject.load = _monkey_load
|
||||
nvstore.SettingsObject.save = _monkey_save
|
||||
|
||||
if '--early-usb' in sys.argv:
|
||||
from usb import enable_usb
|
||||
|
||||
@ -15,30 +15,34 @@ class SecondSecureElement:
|
||||
self.wallet = None
|
||||
self.load()
|
||||
|
||||
if not self.state:
|
||||
# reconstruct based on user-space understanding of SE2 content
|
||||
# - can't work with duress wallet cases here (no data)
|
||||
# - mostly here so sim_settings works w/ non-empty defaults
|
||||
print("SIM SE2: found no state, trying to reconstruct")
|
||||
from glob import settings
|
||||
from trick_pins import TC_FAKE_OUT, TC_WORD_WALLET, TC_XPRV_WALLET
|
||||
from trick_pins import TC_DELTA_MODE, make_slot, TRICK_SLOT_LAYOUT
|
||||
def reconstruct(self, tp):
|
||||
# reconstruct based on user-space understanding of SE2 content
|
||||
# - can't work with duress wallet cases here (no data)
|
||||
# - mostly here so sim_settings works w/ non-empty defaults
|
||||
print("SIM SE2: found no state, trying to reconstruct")
|
||||
from glob import settings
|
||||
from trick_pins import TC_FAKE_OUT, TC_WORD_WALLET, TC_XPRV_WALLET
|
||||
from trick_pins import TC_DELTA_MODE, make_slot, TRICK_SLOT_LAYOUT
|
||||
|
||||
for pin, (slot_num, tc_flags, tc_arg) in settings.get('tp', {}).items():
|
||||
if (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)):
|
||||
print("cant do duress cases")
|
||||
continue
|
||||
#assert not (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)), \
|
||||
#'unhandled simulated case: 0x%x' % tc_flags
|
||||
print(" .. tp = %r" % tp)
|
||||
if not tp: return
|
||||
|
||||
b, s = make_slot()
|
||||
s.pin_len = len(pin)
|
||||
s.pin[:s.pin_len] = pin.encode()
|
||||
s.tc_flags = tc_flags
|
||||
s.tc_arg = tc_arg
|
||||
s.slot_num = slot_num
|
||||
for pin, (slot_num, tc_flags, tc_arg) in tp.items():
|
||||
if (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)):
|
||||
print("cant do duress cases")
|
||||
continue
|
||||
#assert not (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)), \
|
||||
#'unhandled simulated case: 0x%x' % tc_flags
|
||||
|
||||
self.state[slot_num] = bytes(b)
|
||||
b, s = make_slot()
|
||||
s.pin_len = len(pin)
|
||||
s.pin[:s.pin_len] = pin.encode()
|
||||
s.tc_flags = tc_flags
|
||||
s.tc_arg = tc_arg
|
||||
s.slot_num = slot_num
|
||||
|
||||
self.state[slot_num] = bytes(b)
|
||||
print("slot[%d] <= flags=0x%x arg=0x%x" % (slot_num, tc_flags, tc_arg))
|
||||
|
||||
# Storage: base64 encoded binary for all the slot numbers in a dict
|
||||
|
||||
@ -64,16 +68,18 @@ class SecondSecureElement:
|
||||
# merging default values as they contain useful nfc,vidsk info
|
||||
dv = obj.default_values()
|
||||
obj.current.update(dv)
|
||||
s = obj.get('_se2', None)
|
||||
if not s:
|
||||
print("no SE2 data")
|
||||
return
|
||||
s = obj.get('_se2', None) or []
|
||||
|
||||
for record in s:
|
||||
b = a2b_base64(record)
|
||||
slot = uctypes.struct(uctypes.addressof(b), TRICK_SLOT_LAYOUT)
|
||||
self.state[slot.slot_num] = b
|
||||
print("SE2 slot %d is populated" % slot.slot_num)
|
||||
else:
|
||||
print("no SE2 data")
|
||||
|
||||
if not self.state:
|
||||
self.reconstruct(obj.get('tp'))
|
||||
|
||||
def callgate(self, buf_io, arg2):
|
||||
# ckcc.callgate(22, ...)
|
||||
@ -149,11 +155,12 @@ class SecondSecureElement:
|
||||
# similar to stm32/mk4-bootloader/se2.c se2_test_trick_pin(safety_mode=False)
|
||||
xs = self.get_by_pin(pin.encode(), num_fails)
|
||||
if not xs:
|
||||
self.wallet = None # bugfix: normal login after trick login (SP unlock case)
|
||||
return None
|
||||
|
||||
print("PIN %s is a TRICK!" % pin)
|
||||
tc_flags = xs.tc_flags
|
||||
tc_arg = xs.tc_arg
|
||||
print("PIN %s is a TRICK! flags=0x%x arg=%d" % (pin, tc_flags, tc_arg))
|
||||
|
||||
from trick_pins import TC_WIPE, TC_BRICK, TC_REBOOT, TC_FAKE_OUT
|
||||
from trick_pins import TC_WORD_WALLET, TC_XPRV_WALLET, TC_DELTA_MODE
|
||||
|
||||
@ -141,7 +141,7 @@ if '--secret' in sys.argv:
|
||||
|
||||
|
||||
if '-g' in sys.argv:
|
||||
# do login
|
||||
# do login.. but does not work if _skip_pin got saved into settings already
|
||||
sim_defaults.pop('_skip_pin', 0)
|
||||
|
||||
if '--nick' in sys.argv:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user