Compare commits

...

40 Commits

Author SHA1 Message Date
Peter D. Gray
c1b932685d
nits 2025-09-16 10:34:33 -04:00
scgbckbone
3fa25b2d09
add SSSP login tests 2025-09-16 10:08:49 -04:00
Peter D. Gray
3195efb547
done 2025-09-12 10:50:16 -04:00
Peter D. Gray
2774ae5609
cleanups 2025-09-12 10:49:10 -04:00
Peter D. Gray
b788b23cf4
test deltamode works 2025-09-11 11:48:57 -04:00
Peter D. Gray
4320ffc599
nits 2025-09-11 10:45:37 -04:00
scgbckbone
4db1b10ee0
SSSP update menu tree & related adjustments 2025-09-11 10:28:24 -04:00
Peter D. Gray
1581dc8b69
nits 2025-09-10 11:13:20 -04:00
scgbckbone
60254045e5
fixes after --eff changes 2025-09-10 10:45:57 -04:00
Peter D. Gray
2716ce631e
block some USB command in hobble mode 2025-09-10 10:45:36 -04:00
Peter D. Gray
bb0b1b408f
tune 2025-09-10 09:45:23 -04:00
scgbckbone
ac44b6b700
more tests 2025-09-10 09:35:22 -04:00
Peter D. Gray
93b934c021
testing 2025-09-09 12:18:15 -04:00
Peter D. Gray
2d48ad744b
forgotten pin 2025-09-09 10:52:50 -04:00
Peter D. Gray
6bbb05edaa
add seedvault 2025-09-09 10:52:23 -04:00
Peter D. Gray
45145ee0e9
cleanup 2025-09-09 10:51:54 -04:00
Peter D. Gray
9f285d8d5c
improve --eff handling 2025-09-09 10:39:05 -04:00
Peter D. Gray
ebc1f33c64
nits 2025-09-08 10:02:10 -04:00
Peter D. Gray
c24fa9f771
bug note 2025-09-04 09:37:12 -04:00
Peter D. Gray
c493677595
word entry 2025-08-22 10:49:11 -04:00
scgbckbone
d316fa0124
Mk4 SSSP Word Check 2025-08-22 09:48:24 -04:00
scgbckbone
6213e997b6
nits 2025-08-21 10:43:19 -04:00
Peter D. Gray
ca5dd11219
tidy 2025-08-21 10:35:35 -04:00
scgbckbone
8fea4aac93
(some) policy test for sssp 2025-08-21 10:28:31 -04:00
Peter D. Gray
ce5f31019f
test cases 2025-08-20 12:25:25 -04:00
Peter D. Gray
c0f964868f
improvements 2025-08-20 12:25:14 -04:00
scgbckbone
4397e4a4ce
fix0 2025-08-20 09:16:27 -04:00
Peter D. Gray
9528c429e9
passes test_ccc_magnitude 2025-08-19 12:16:48 -04:00
Peter D. Gray
411eb78f73
reword last_fail_reason 2025-08-19 12:16:39 -04:00
Peter D. Gray
fe37c52657
more 2025-08-19 10:54:19 -04:00
Peter D. Gray
62e8d3d4d2
more 2025-08-19 10:54:14 -04:00
Peter D. Gray
b671183f11
mroe docs 2025-08-19 10:52:58 -04:00
Peter D. Gray
99e66c7a9d
spending policy implemented 2025-08-18 16:13:26 -04:00
Peter D. Gray
5f835fa739
cleanup 2025-08-18 11:43:42 -04:00
Peter D. Gray
c6272161b7
few notes 2025-08-18 08:58:58 -04:00
Peter D. Gray
c70d6559a7
slightly better SE2 emulation 2025-08-18 08:58:19 -04:00
Peter D. Gray
a97eecc8a0
planning 2025-08-18 08:57:27 -04:00
Peter D. Gray
980bfd9b1c
hobbled mode support for spending policy 2025-08-18 08:57:12 -04:00
Peter D. Gray
7d20f03639
few notes 2025-08-12 10:28:44 -04:00
Peter D. Gray
0e7d65686a
notes 2025-08-11 12:08:23 -04:00
44 changed files with 3057 additions and 413 deletions

View File

@ -30,6 +30,7 @@
Tapsigner Backup Tapsigner Backup
Seed XOR Seed XOR
Migrate Coldcard Migrate Coldcard
Key Teleport (start)
Help Help
Advanced/Tools Advanced/Tools
View Identity View Identity
@ -54,7 +55,6 @@
From VirtDisk [IF VIRTDISK ENABLED] From VirtDisk [IF VIRTDISK ENABLED]
File Management File Management
Verify Backup Verify Backup
Teleport Multisig PSBT [IF QR AND SECRET]
List Files List Files
Verify Sig File Verify Sig File
NFC File Share [IF NFC ENABLED] NFC File Share [IF NFC ENABLED]
@ -168,12 +168,12 @@
[NORMAL OPERATION] [NORMAL OPERATION]
Ready To Sign Ready To Sign
Passphrase [IF WORD BASED SEED] Passphrase [IF WORD BASED SEED]
Restore Saved [MAYBE] Restore Saved
A*********** c*******
[0C52BAD4] [3A14F788]
Restore Restore
Delete Delete
Edit Phrase [MAYBE] Edit Phrase [IF QWERTY]
Add Word [IF NOT QWERTY] Add Word [IF NOT QWERTY]
[SEED WORD MENUS] [SEED WORD MENUS]
Add Numbers [IF NOT QWERTY] Add Numbers [IF NOT QWERTY]
@ -197,35 +197,44 @@
Account Number Account Number
Custom Path Custom Path
CC-2-of-4 CC-2-of-4
Secure Notes & Passwords [IF ENBALED] Secure Notes & Passwords [IF ENBALED] [MAYBE]
1: note1 1: note0
"note1" "note0"
View Note View Note
Edit Edit
Delete Delete
Export Export
SHORTCUT Sign Note Text
SHORTCUT 2: secret-PWD
2: nostr "secret-PWD"
"nostr" ↳ satoshi
↳ scg ↳ abc.org
↳ brb.io
View Password View Password
Send Password [MAYBE] Send Password [MAYBE]
Export Export
Edit Metadata Edit Metadata
Delete Delete
Change Password Change Password
SHORTCUT Sign Note Text
SHORTCUT
New Note New Note
New Password New Password
Export All Export All
Sort By Title
Import Import
Type Passwords [MAYBE] Type Passwords [MAYBE]
Seed Vault [MAYBE] Seed Vault [MAYBE]
1: [B14E9AE0] 1: [7126EB3C]
[B14E9AE0] [7126EB3C]
Use This Seed
Rename
Delete
2: [CCEE13B9]
[CCEE13B9]
Use This Seed
Rename
Delete
3: [03EE9989]
[03EE9989]
Use This Seed Use This Seed
Rename Rename
Delete Delete
@ -236,17 +245,18 @@
Restore Backup Restore Backup
Clone Coldcard Clone Coldcard
Export Wallet Export Wallet
Sparrow
Cove
Bitcoin Core Bitcoin Core
Fully Noded
Sparrow Wallet
Nunchuk Nunchuk
Bull Bitcoin
Zeus Zeus
Electrum Wallet Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya Theya
Bitcoin Safe Bitcoin Safe
Wasabi Wallet
Unchained
Lily Wallet
Samourai Postmix Samourai Postmix
Samourai Premix Samourai Premix
Descriptor Descriptor
@ -266,17 +276,18 @@
Verify Backup Verify Backup
Backup System Backup System
Export Wallet Export Wallet
Sparrow
Cove
Bitcoin Core Bitcoin Core
Fully Noded
Sparrow Wallet
Nunchuk Nunchuk
Bull Bitcoin
Zeus Zeus
Electrum Wallet Electrum Wallet
Wasabi Wallet
Fully Noded
Unchained
Theya Theya
Bitcoin Safe Bitcoin Safe
Wasabi Wallet
Unchained
Lily Wallet
Samourai Postmix Samourai Postmix
Samourai Premix Samourai Premix
Descriptor Descriptor
@ -290,7 +301,7 @@
Dump Summary Dump Summary
Sign Text File Sign Text File
Batch Sign PSBT Batch Sign PSBT
Teleport Multisig PSBT [IF QR AND SECRET] Teleport Multisig PSBT
List Files List Files
Verify Sig File Verify Sig File
NFC File Share [IF NFC ENABLED] NFC File Share [IF NFC ENABLED]
@ -300,29 +311,28 @@
Format SD Card Format SD Card
Format RAM Disk [IF VIRTDISK ENABLED] Format RAM Disk [IF VIRTDISK ENABLED]
Secure Notes & Passwords [IF QWERTY KEYBOARD] Secure Notes & Passwords [IF QWERTY KEYBOARD]
1: note1 1: note0
"note1" "note0"
View Note View Note
Edit Edit
Delete Delete
Export Export
SHORTCUT Sign Note Text
SHORTCUT 2: secret-PWD
2: nostr "secret-PWD"
"nostr" ↳ satoshi
↳ scg ↳ abc.org
↳ brb.io
View Password View Password
Send Password [MAYBE] Send Password [MAYBE]
Export Export
Edit Metadata Edit Metadata
Delete Delete
Change Password Change Password
SHORTCUT Sign Note Text
SHORTCUT
New Note New Note
New Password New Password
Export All Export All
Sort By Title
Import Import
Derive Seeds (BIP-85) Derive Seeds (BIP-85)
View Identity View Identity
@ -342,13 +352,14 @@
Tapsigner Backup Tapsigner Backup
Coldcard Backup Coldcard Backup
Key Teleport (start) 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 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] NFC Tools [IF NFC ENABLED]
Sign PSBT Sign PSBT
Show Address Show Address
@ -357,7 +368,7 @@
Verify Address Verify Address
File Share File Share
Import Multisig Import Multisig
Push Transaction [IF ENBALED] Push Transaction [IF PUSHTX ENABLED]
Danger Zone Danger Zone
Debug Functions Debug Functions
Seed Functions Seed Functions
@ -398,22 +409,32 @@
Settings Space Settings Space
MCU Key Slots MCU Key Slots
Bless Firmware Bless Firmware
Reflash GPU [IF QWERTY KEYBOARD]
Wipe LFS Wipe LFS
Settings Settings
Login Settings Login Settings
Change Main PIN Change Main PIN
Trick PINs [IF SECRET AND NOT TMP SEED] Trick PINs [IF SECRET AND NOT TMP SEED]
Trick PINs: Trick PINs:
↳123-254 ↳11-11
PIN 123-254 PIN 11-11
↳Bricks CC
Hide Trick
Delete Trick
Change PIN
↳333-3334
PIN 333-3334
↳Duress Wallet ↳Duress Wallet
Activate Wallet Activate Wallet
Hide Trick Hide Trick
Delete Trick Delete Trick
Change PIN Change PIN
↳WRONG PIN
After 3 wrong:
↳Wipes seed
↳Reboots
Hide Trick
Delete Trick
Add New Trick Add New Trick
Add If Wrong
Delete All Delete All
Set Nickname Set Nickname
Scramble Keys Scramble Keys
@ -458,11 +479,11 @@
View Details View Details
Delete Delete
Coldcard Export Coldcard Export
Electrum Wallet
Descriptors Descriptors
View Descriptor View Descriptor
Export Export
Bitcoin Core Bitcoin Core
Electrum Wallet
Import from File Import from File
Import from QR [IF QR SCANNER] Import from QR [IF QR SCANNER]
Import via NFC [IF NFC ENABLED] Import via NFC [IF NFC ENABLED]
@ -538,7 +559,7 @@
Verify Address Verify Address
File Share File Share
Import Multisig Import Multisig
Push Transaction [IF ENBALED] Push Transaction [IF PUSHTX ENABLED]
--- ---
[FACTORY MODE] [FACTORY MODE]
@ -550,3 +571,144 @@
Perform Selftest 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
View 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.

View File

@ -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, The HSM feature uses HOTP tokens, which do not require a backend,
but are not as robust as time-based tokens. but are not as robust as time-based tokens.
For now, Web2FA is only being used as part of CCC spending policy (opt-in), Web2FA is available to be enabled as part of a Spending Policy,
but we may find other uses for it. 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 ## 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 - multiplies that private key by server's known public key
- apply sha256(resulting coordinate) => the session key - apply sha256(resulting coordinate) => the session key
- apply AES-256-CTR over URL contents (ascii text) - 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}` - full url is: `https://coldcard.com/2fa?{base64 encoded binary}`
## Trust Issues ## Trust Issues

View File

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

View File

@ -1,12 +1,12 @@
xx X
xx XX
xx X
xx XX
xx xx X X
xx xx XX XX
xx xx XX X
xxx XXXX
x XX

View File

@ -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: If all change outputs have `nValue=0` they're not shown in UX.
- Bugfix: Disallow negative input/output amounts in PSBT. - 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 # Mk4 Specific Changes
## 5.4.? - 2025-08-xx ## 5.4.? - 2025-08-xx

View File

@ -319,7 +319,7 @@ Press (6) to prove you read to the end of this message.''', title='WARNING', esc
if ch == '6': break if ch == '6': break
# do the actual picking # do the actual picking
pin = await lll.get_new_pin(title) pin = await lll.get_new_pin()
del lll del lll
if pin is None: return if pin is None: return
@ -573,8 +573,11 @@ async def clear_seed(*a):
# This is super dangerous for the customer's money. # This is super dangerous for the customer's money.
import seed import seed
if await any_active_duress_ux(): # in hobble mode, they cannot reach duress wallets and/or maybe we don't
return await ux_aborted() # 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. ' if not await ux_confirm('Wipe seed words and reset wallet. '
'All funds will be lost. ' '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\ 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, \ 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 \ 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() return await ux_aborted()
# clear all trick PINs from SE2 # 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 # If that didn't work, or no skip defined, force
# them to login successfully. # them to login successfully.
sp_unlock = False
try: try:
from trick_pins import tp
# Get a PIN and try to use it to login # Get a PIN and try to use it to login
# - does warnings about attempt usage counts # - does warnings about attempt usage counts
await block_until_login() 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) # Do we need to do countdown delay? (real or otherwise)
# Q/Mk4 approach: # - wiping has already occured if that was selected by trick details
# - wiping has already occured if that was picked
# - delay is variable, stored in tc_arg # - delay is variable, stored in tc_arg
from trick_pins import tp
delay = tp.was_countdown_pin() 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: if not delay:
delay = settings.get('lgto', 0) delay = settings.get('lgto', 0)
if delay: if delay:
# kill some time, with countdown, and get "the" PIN again for real login # kill some time, with countdown, and get "the" PIN again for real login
pa.reset() pa.reset()
await ux_login_countdown(delay * (60 if not version.is_devmode else 1)) await ux_login_countdown(delay * (60 if not version.is_devmode else 1))
# keep it simple for Mk4+: just challenge again for any PIN # keep it simple for Mk4+: just challenge again for any PIN
@ -847,14 +861,32 @@ async def start_login_sequence():
# handle upgrades/downgrade issues # handle upgrades/downgrade issues
try: try:
await version_migration() await version_migration()
except: except: pass
pass
# Maybe insist on the "right" microSD being already installed? # Maybe insist on the "right" microSD being already installed?
try: try:
from pwsave import MicroSD2FA from pwsave import MicroSD2FA
MicroSD2FA.enforce_policy() 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 # implement idle timeout now that we are logged-in
IMPT.start_task('idle', idle_logout()) IMPT.start_task('idle', idle_logout())
@ -943,7 +975,7 @@ async def restore_main_secret(*a):
goto_top_menu() goto_top_menu()
def make_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 glob import hsm_active, settings
from pincodes import pa from pincodes import pa
@ -959,7 +991,9 @@ def make_top_menu():
assert pa.is_successful(), "nonblank but wrong pin" assert pa.is_successful(), "nonblank but wrong pin"
if pa.has_secrets(): 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): if pa.tmp_value or settings.get("hmx", False):
active_xfp = settings.get("xfp", 0) active_xfp = settings.get("xfp", 0)
sl, sr = ("[", "]") if pa.tmp_value else ("<", ">") sl, sr = ("[", "]") if pa.tmp_value else ("<", ">")
@ -2013,7 +2047,7 @@ Write it down.'''
while 1: while 1:
lll.reset() lll.reset()
lll.subtitle = "New " + title lll.subtitle = "New " + title
pin = await lll.get_new_pin(title) pin = await lll.get_new_pin()
if pin is None: if pin is None:
return await ux_aborted() return await ux_aborted()

View File

@ -327,7 +327,7 @@ class ApproveTransaction(UserAuthorizedAction):
async def interact(self): async def interact(self):
# Prompt user w/ details and get approval # Prompt user w/ details and get approval
from glob import dis, hsm_active 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. # 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 # early test for spending policy; not an error if violates policy
# - might add warnings # - 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 # step 2: figure out what we are approving, so we can get sign-off
# - outputs, amounts # - outputs, amounts
@ -500,7 +506,7 @@ class ApproveTransaction(UserAuthorizedAction):
self.done() self.done()
return 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) # They still need to pass web2fa challenge (but it meets other specs ok)
try: try:
await CCCFeature.web2fa_challenge() await CCCFeature.web2fa_challenge()
@ -510,6 +516,13 @@ class ApproveTransaction(UserAuthorizedAction):
if ch2 != 'y': if ch2 != 'y':
return await self.failure("2FA Failed") 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. # do the actual signing.
try: try:
dis.fullscreen('Wait...') dis.fullscreen('Wait...')
@ -517,9 +530,13 @@ class ApproveTransaction(UserAuthorizedAction):
self.psbt.sign_it() self.psbt.sign_it()
if could_ccc_sign: if could_ccc_sign:
dis.fullscreen('CCC Sign...') # this is where the CCC co-signing happens.
dis.fullscreen('Co-Signing...')
gc.collect() gc.collect()
CCCFeature.sign_psbt(self.psbt) CCCFeature.sign_psbt(self.psbt)
else:
# maybe capture new min-height for velocity limit
SSSPFeature.update_last_signed(self.psbt)
except FraudulentChangeOutput as exc: except FraudulentChangeOutput as exc:
return await self.failure(exc.args[0], title='Change Fraud') 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) return await self.failure("Signing failed late", exc)
try: try:
await done_signing(self.psbt, self, self.input_method, self.filename, self.output_encoder, await done_signing(self.psbt, self, self.input_method,
slot_b=True if ch == "b" else False, finalize=self.do_finalize) self.filename, self.output_encoder,
slot_b=(ch == "b"), finalize=self.do_finalize)
self.done() self.done()
except AbortInteraction: except AbortInteraction:
# user might have sent new sign cmd, while we still at export prompt # user might have sent new sign cmd, while we still at export prompt

View File

@ -101,6 +101,7 @@ def render_backup_contents(bypass_tmp=False):
if k == 'words': continue # words length is recalculated from secret if k == 'words': continue # words length is recalculated from secret
if k == 'ccc': continue # not supported, security issue if k == 'ccc': continue # not supported, security issue
if k == 'ktrx': continue # not useful after the fact 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 == 'seedvault' and not v: continue
if k == 'seeds' and not v: continue if k == 'seeds' and not v: continue
ADD('setting.' + k, v) ADD('setting.' + k, v)

View File

@ -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. # 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 import gc, chains, version, ngu, web2fa, bip39, re
from chains import NLOCK_IS_TIME from chains import NLOCK_IS_TIME
from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address 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 seed import seed_words_to_encoded_secret
from stash import SecretStash from stash import SecretStash
from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC
from exceptions import CCCPolicyViolationError from exceptions import SpendPolicyViolation
# limit to number of addresses in list # limit to number of addresses in list
MAX_WHITELIST = const(25) MAX_WHITELIST = const(25)
class CCCFeature: class LastFailReason:
# We don't show the user the reason for policy fail (by design, so attacker
# 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 # cannot maximize their take against the policy), but during setup/experiments
# we offer to show the reason in the menu # we offer to show the reason in the menu. Includes both SS and MS cases.
last_fail_reason = "" # - 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 @classmethod
def is_enabled(cls): def is_enabled(cls):
@ -85,29 +283,13 @@ class CCCFeature:
# a very basic and permissive policy, but non-zero too. # a very basic and permissive policy, but non-zero too.
# - 1BTC per day # - 1BTC per day
chain = chains.current_chain() 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 @classmethod
def get_policy(cls): def get_policy(cls):
# de-serialize just the spending policy # de-serialize just the spending policy
return dict(settings.get('ccc', dict(pol={})).get('pol')) return SpendingPolicy('ccc')
@classmethod
def update_policy(cls, pol):
# serialize the spending policy, save it
v = dict(settings.get('ccc', {}))
v['pol'] = dict(pol)
settings.set('ccc', v)
return v['pol']
@classmethod
def update_policy_key(cls, **kws):
# update a few elements of the spending policy
# - all settings "saved" as they are changed.
# - return updated policy
p = cls.get_policy()
p.update(kws)
return cls.update_policy(p)
@classmethod @classmethod
def remove_ccc(cls): def remove_ccc(cls):
@ -117,75 +299,16 @@ class CCCFeature:
settings.save() settings.save()
@classmethod @classmethod
def meets_policy(cls, psbt): def could_cosign(cls, psbt):
# Does policy allow signing this? Else raise why
pol = cls.get_policy()
# not safe to sign any txn w/ warnings: might be complaining about
# massive miner fees, or weird OP_RETURN stuff
if psbt.warnings:
raise CCCPolicyViolationError("has warnings")
# Magnitude: size limits for output side (non change)
magnitude = pol.get("mag", None)
if magnitude is not None:
if magnitude < 1000:
# it is a BTC, convert to sats
magnitude = magnitude * 100000000
outgoing = psbt.total_value_out - psbt.total_change_value
if outgoing > magnitude:
raise CCCPolicyViolationError("magnitude")
# Velocity: if zero => no velocity checks
velocity = pol.get("vel", None)
if velocity:
if not psbt.lock_time:
raise CCCPolicyViolationError("no nLockTime")
if psbt.lock_time >= NLOCK_IS_TIME:
# this is unix timestamp - not allowed - fail
raise CCCPolicyViolationError("nLockTime not height")
block_h = pol.get("block_h", chains.current_chain().ccc_min_block)
if psbt.lock_time <= block_h:
raise CCCPolicyViolationError("rewound (%d)" % psbt.lock_time)
# we won't sign txn unless old height + velocity >= new height
if psbt.lock_time < (block_h + velocity):
raise CCCPolicyViolationError("velocity (%d)" % psbt.lock_time)
# Whitelist of outputs addresses
wl = pol.get("addrs", None)
if wl:
c = chains.current_chain()
wl = set(wl)
for idx, txo in psbt.output_iter():
out = psbt.outputs[idx]
if not out.is_change: # ignore change
addr = c.render_address(txo.scriptPubKey)
if addr not in wl:
raise CCCPolicyViolationError("whitelist")
# Web 2FA
# - slow, requires UX, and they might not acheive it...
# - wait until about to do signature
if pol.get('web2fa', False):
psbt.warnings.append(('CCC', 'Web 2FA required.'))
return True
@classmethod
def could_sign(cls, psbt):
# We are looking at a PSBT: can we sign it, and would we? # 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 # - if we **could** but will not, due to policy, add warning msg
# - return (we could sign, needs 2fa step) # - return (we could sign, needs 2fa step)
if not cls.is_enabled: if not cls.is_enabled():
return False, False return False, False
ms = psbt.active_multisig ms = psbt.active_multisig
if not ms: if not ms:
# single-sig CCC not supported # not multisig, so ignore/permit
return False, False return False, False
# TODO: if key B has already signed the PSBT, and so we don't need key C, # TODO: if key B has already signed the PSBT, and so we don't need key C,
@ -198,41 +321,29 @@ class CCCFeature:
try: try:
# check policy # check policy
needs_2fa = cls.meets_policy(psbt) pol = cls.get_policy()
except CCCPolicyViolationError as e: needs_2fa = pol.meets_policy(psbt)
cls.last_fail_reason = str(e) except SpendPolicyViolation as e:
LastFailReason.record(str(e))
psbt.warnings.append(('CCC', "Violates spending policy. Won't sign.")) psbt.warnings.append(('CCC', "Violates spending policy. Won't sign."))
return False, False return False, False
return True, needs_2fa 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 @classmethod
def sign_psbt(cls, psbt): def sign_psbt(cls, psbt):
# do the math # do the math
psbt.sign_it(cls.get_encoded_secret(), cls.get_xfp()) 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) pol = cls.get_policy()
if old_h < psbt.lock_time < NLOCK_IS_TIME: pol.update_last_signed(psbt)
# always update last block height, even if velocity isn't enabled yet
# - attacker might have changed to testnet, but there is no @classmethod
# reason to ever lower block height. strictly ascending async def web2fa_challenge(cls):
cls.update_policy_key(block_h=psbt.lock_time) # do UX for web2fa; user is given option to proceed even if it fails
settings.save() # (without the co-signing)
await cls.get_policy().web2fa_challenge('Approve Transaction: Co-Sign')
def render_mag_value(mag): def render_mag_value(mag):
@ -257,9 +368,10 @@ class CCCConfigMenu(MenuSystem):
my_xfp = CCCFeature.get_xfp() my_xfp = CCCFeature.get_xfp()
items = [ items = [
# xxxxxxxxxxxxxxxx MenuItem(('[%s] Co-Signing' if version.has_qwerty else '[%s]')
MenuItem('CCC [%s]' % xfp2str(my_xfp), f=self.show_ident), % xfp2str(my_xfp), f=self.show_ident),
MenuItem('Spending Policy', menu=CCCPolicyMenu.be_a_submenu), MenuItem('Spending Policy',
menu=lambda *a: SpendingPolicyMenu.be_a_submenu(CCCFeature.get_policy())),
MenuItem('Export CCC XPUBs', f=self.export_xpub_c), MenuItem('Export CCC XPUBs', f=self.export_xpub_c),
MenuItem('Multisig Wallets'), MenuItem('Multisig Wallets'),
] ]
@ -274,7 +386,7 @@ class CCCConfigMenu(MenuSystem):
items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count)) items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count))
if CCCFeature.last_fail_reason: if LastFailReason.get():
# xxxxxxxxxxxxxxxx # xxxxxxxxxxxxxxxx
items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail)) items.insert(1, MenuItem('Last Violation', f=self.debug_last_fail))
@ -291,12 +403,13 @@ class CCCConfigMenu(MenuSystem):
if bh: if bh:
msg += "CCC height:\n\n%s\n\n" % 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.' \ 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') ch = await ux_show_story(msg, escape='4')
if ch == '4': if ch == '4':
CCCFeature.last_fail_reason = '' LastFailReason.clear()
self.update_contents() self.update_contents()
async def remove_ccc(self, *a): async def remove_ccc(self, *a):
@ -379,23 +492,11 @@ be ready to show it as a QR, before proceeding.'''
goto_top_menu() 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): class SPAddrWhitelist(MenuSystem):
# should we show a check in parent menu? check the policy
m = the_ux.top_of_stack()
#assert isinstance(m, CCCPolicyMenu)
return bool(m.policy.get(self.polkey, False))
class CCCAddrWhitelist(MenuSystem):
# simulator arg: --seq tcENTERENTERsENTERwENTER # simulator arg: --seq tcENTERENTERsENTERwENTER
def __init__(self): def __init__(self, pol):
self.policy = pol
items = self.construct() items = self.construct()
super().__init__(items) super().__init__(items)
@ -404,12 +505,12 @@ class CCCAddrWhitelist(MenuSystem):
self.replace_items(tmp) self.replace_items(tmp)
@classmethod @classmethod
async def be_a_submenu(cls, *a): async def be_a_submenu(cls, pol, *a):
return cls() return cls(pol)
def construct(self): def construct(self):
# list of addresses # list of addresses
addrs = CCCFeature.get_policy().get('addrs', []) addrs = self.policy.get('addrs', [])
maxxed = (len(addrs) >= MAX_WHITELIST) maxxed = (len(addrs) >= MAX_WHITELIST)
items = [] items = []
@ -444,15 +545,14 @@ class CCCAddrWhitelist(MenuSystem):
def delete_addr(self, addr): def delete_addr(self, addr):
# no confirm, stakes are low # no confirm, stakes are low
addrs = CCCFeature.get_policy().get('addrs', []) addrs = self.policy.get('addrs', [])
addrs.remove(addr) addrs.remove(addr)
CCCFeature.update_policy_key(addrs=addrs) self.policy.update_policy_key(addrs=addrs)
self.update_contents() self.update_contents()
async def clear_all(self, *a): async def clear_all(self, *a):
if await ux_confirm("Irreversibly remove all addresses from the whitelist?", if await ux_confirm("Remove all addresses from the whitelist?", confirm_key='4'):
confirm_key='4'): self.policy.update_policy_key(addrs=[])
CCCFeature.update_policy_key(addrs=[])
self.update_contents() self.update_contents()
async def import_file(self, *a): async def import_file(self, *a):
@ -537,7 +637,7 @@ class CCCAddrWhitelist(MenuSystem):
async def add_addresses(self, more_addrs): async def add_addresses(self, more_addrs):
# add new entries, if unique; preserve ordering # add new entries, if unique; preserve ordering
addrs = CCCFeature.get_policy().get('addrs', []) addrs = self.policy.get('addrs', [])
new = [] new = []
for a in more_addrs: for a in more_addrs:
if a not in addrs: if a not in addrs:
@ -552,23 +652,39 @@ class CCCAddrWhitelist(MenuSystem):
if len(addrs) > MAX_WHITELIST: if len(addrs) > MAX_WHITELIST:
return await self.maxed_out() return await self.maxed_out()
CCCFeature.update_policy_key(addrs=addrs) self.policy.update_policy_key(addrs=addrs)
self.update_contents() self.update_contents()
if len(new) > 1: if len(new) > 1:
await ux_show_story("Added %d new addresses to whitelist:\n\n%s" % 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))) (len(new), '\n\n'.join(show_single_address(a) for a in new)))
else: 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 # 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?) # - and delete/cancel CCC (clears setting?)
# - be a sticky menu that's hard to exit (ie. SAVE choice and no cancel out) # - be a sticky menu that's hard to exit (ie. SAVE choice and no cancel out)
def __init__(self): def __init__(self, pol):
self.policy = CCCFeature.get_policy() self.policy = pol
items = self.construct() items = self.construct()
super().__init__(items) super().__init__(items)
@ -577,17 +693,18 @@ class CCCPolicyMenu(MenuSystem):
self.replace_items(tmp) self.replace_items(tmp)
@classmethod @classmethod
async def be_a_submenu(cls, *a): async def be_a_submenu(cls, pol, *a):
return cls() return cls(pol)
def construct(self): def construct(self):
items = [ items = [
# xxxxxxxxxxxxxxxx # xxxxxxxxxxxxxxxx
PolCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude), SPCheckedMenuItem('Max Magnitude', 'mag', f=self.set_magnitude),
PolCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity), SPCheckedMenuItem('Limit Velocity', 'vel', f=self.set_velocity),
PolCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''), SPCheckedMenuItem('Whitelist' + (' Addresses' if version.has_qr else ''),
'addrs', menu=CCCAddrWhitelist.be_a_submenu), 'addrs',
PolCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa), menu=lambda *a: SPAddrWhitelist.be_a_submenu(self.policy)),
SPCheckedMenuItem('Web 2FA', 'web2fa', f=self.toggle_2fa),
] ]
if self.policy.get('web2fa'): if self.policy.get('web2fa'):
@ -601,15 +718,15 @@ class CCCPolicyMenu(MenuSystem):
async def test_2fa(self, *a): async def test_2fa(self, *a):
ss = self.policy.get('web2fa') ss = self.policy.get('web2fa')
assert ss 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.') await ux_show_story('Correct code was given.' if ok else 'Failed or aborted.')
async def enroll_more_2fa(self, *a): 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') ss = self.policy.get('web2fa')
assert ss assert ss
await web2fa.web2fa_enroll('CCC', ss) await web2fa.web2fa_enroll(ss)
async def set_magnitude(self, *a): async def set_magnitude(self, *a):
# Looks decent on both Q and Mk4... # Looks decent on both Q and Mk4...
@ -633,7 +750,7 @@ class CCCPolicyMenu(MenuSystem):
else: else:
msg += " maximum per-transaction: \n\n %s" % render_mag_value(val) 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") await ux_show_story(msg, title="TX Magnitude")
@ -643,7 +760,7 @@ class CCCPolicyMenu(MenuSystem):
if not mag: if not mag:
msg = 'Velocity limit requires a per-transaction magnitude to be set.'\ msg = 'Velocity limit requires a per-transaction magnitude to be set.'\
' This has been set to 1BTC as a starting value.' ' 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) await ux_show_story(msg)
@ -678,7 +795,7 @@ class CCCPolicyMenu(MenuSystem):
which = 0 which = 0
def set(idx, text): 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 return which, ch, set
@ -689,7 +806,7 @@ class CCCPolicyMenu(MenuSystem):
if not await ux_confirm("Disable web 2FA check? Effect is immediate."): if not await ux_confirm("Disable web 2FA check? Effect is immediate."):
return return
self.policy = CCCFeature.update_policy_key(web2fa='') self.policy.update_policy_key(web2fa='')
self.update_contents() self.update_contents()
await ux_show_story("Web 2FA has been disabled. If you re-enable it, a new " 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 return
# challenge them, and don't set unless it works # challenge them, and don't set unless it works
ss = await web2fa.web2fa_enroll('CCC') ss = await web2fa.web2fa_enroll()
if not ss: if not ss:
return return
# update state # update state
self.policy = CCCFeature.update_policy_key(web2fa=ss) self.policy.update_policy_key(web2fa=ss)
self.update_contents() self.update_contents()
async def gen_or_import(): async def gen_or_import():
@ -886,4 +1003,278 @@ async def key_c_challenge(words):
m = CCCConfigMenu() m = CCCConfigMenu()
the_ux.push(m) 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 # EOF

View File

@ -139,6 +139,11 @@ def decode_qr_result(got, expect_secret=False, expect_text=False, expect_bbqr=Fa
elif ty in 'RSE': elif ty in 'RSE':
# key-teleport related # 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: if ty == 'R' and len(got) != 33:
raise QRDecodeExplained("Truncated KT RX") raise QRDecodeExplained("Truncated KT RX")

View File

@ -266,17 +266,18 @@ class Display:
if is_sel: if is_sel:
self.dis.fill_rect(0, y, Display.WIDTH, h-1, 1) self.dis.fill_rect(0, y, Display.WIDTH, h-1, 1)
self.icon(2, y, 'wedge', invert=1) self.icon(2, y, 'wedge', invert=1)
self.text(x, y, msg, invert=1) nx = self.text(x, y, msg, invert=1)
else: else:
self.text(x, y, msg) nx = self.text(x, y, msg)
# LATER: removed because caused confusion w/ underscore # LATER: removed because caused confusion w/ underscore
#if msg[0] == ' ' and space_indicators: #if msg[0] == ' ' and space_indicators:
# see also graphics/mono/space.txt # see also graphics/mono/space.txt
#self.icon(x-2, y+9, 'space', invert=is_sel) #self.icon(x-2, y+9, 'space', invert=is_sel)
if is_checked: if is_checked and nx <= 113:
self.icon(108, y, 'selected', invert=is_sel) # omit checkmark if it doesn't fit
self.icon(113, y, 'selected', invert=is_sel)
def menu_show(self, *a): def menu_show(self, *a):
self.show() self.show()

View File

@ -19,10 +19,10 @@ class CCBusyError(RuntimeError):
# HSM is blocking your action # HSM is blocking your action
class HSMDenied(RuntimeError): class HSMDenied(RuntimeError):
pass pass
class HSMCMDDisabled(RuntimeError): class HSMCMDDisabled(RuntimeError):
pass pass
# PSBT / transaction related # PSBT / transaction related
class FatalPSBTIssue(RuntimeError): class FatalPSBTIssue(RuntimeError):
pass pass
@ -51,8 +51,8 @@ class QRDecodeExplained(ValueError):
class UnknownAddressExplained(ValueError): class UnknownAddressExplained(ValueError):
pass pass
# We're not going to co-sign using CCC feature # We're not going to (co-)sign using spending policy features
class CCCPolicyViolationError(RuntimeError): class SpendPolicyViolation(RuntimeError):
pass pass
# EOF # EOF

View File

@ -19,7 +19,7 @@ from countdowns import countdown_chooser
from paper import make_paper_wallet from paper import make_paper_wallet
from trick_pins import TrickPinMenu from trick_pins import TrickPinMenu
from tapsigner import import_tapsigner_backup_file 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 # useful shortcut keys
from charcodes import KEY_QR, KEY_NFC 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) # contains hsm feature + can it be used (needs se2 secret and no tmp active)
return version.supports_hsm and has_real_secret() 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): async def goto_home(*a):
goto_top_menu() goto_top_menu()
@ -355,7 +382,20 @@ NFCToolsMenu = [
MenuItem('Verify Address', f=nfc_address_verify), MenuItem('Verify Address', f=nfc_address_verify),
MenuItem('File Share', f=nfc_share_file), MenuItem('File Share', f=nfc_share_file),
MenuItem('Import Multisig', f=import_multisig_nfc), 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 = [ AdvancedNormalMenu = [
@ -371,14 +411,8 @@ AdvancedNormalMenu = [
MenuItem("View Identity", f=view_ident), MenuItem("View Identity", f=view_ident),
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu), MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr), 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), 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('NFC Tools', predicate=nfc_enabled, menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'), MenuItem("Danger Zone", menu=DangerZoneMenu, shortcut='z'),
] ]
@ -460,3 +494,70 @@ FactoryMenu = [
MenuItem("Debug Functions", menu=DebugFunctionsMenu, shortcut='f'), MenuItem("Debug Functions", menu=DebugFunctionsMenu, shortcut='f'),
MenuItem("Perform Selftest", f=start_selftest, shortcut='s'), 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),
]

View File

@ -58,8 +58,8 @@ class ImportantTask:
else: else:
# uncaught exception in an unnamed (and unimportant) task # uncaught exception in an unnamed (and unimportant) task
print("UNNAMED: " + context["message"]) print("UNNAMED: " + context["message"])
# sys.print_exception(context["exception"]) sys.print_exception(context["exception"]) # VERY USEFUL on sim
print("... future: %r" % context.get("future", '?')) #print("... future: %r" % context.get("future", '?'))
def start_task(self, name, awaitable): def start_task(self, name, awaitable):
# start a critical task and watch for it to never die # start a critical task and watch for it to never die

View File

@ -270,7 +270,7 @@ suffix break point is correct.\n\n'''
return await self.interact() 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 # Do UX flow to get new (or change) PIN. Always does the double-entry thing
self.is_setting = True self.is_setting = True

View File

@ -119,7 +119,7 @@ class ShortcutItem(MenuItem):
super().__init__('SHORTCUT', shortcut=key, **kws) super().__init__('SHORTCUT', shortcut=key, **kws)
class NonDefaultMenuItem(MenuItem): 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): def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
super().__init__(label, **kws) super().__init__(label, **kws)
self.nvkey = nvkey self.nvkey = nvkey
@ -306,10 +306,6 @@ class MenuSystem:
if fcn and fcn(): if fcn and fcn():
checked = True 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): if self.multi_selected is not None and (real_idx in self.multi_selected):
# ignore length constraint above, we need to visually show that # ignore length constraint above, we need to visually show that
# smthg is selected - in any case # smthg is selected - in any case

View File

@ -21,6 +21,16 @@ from utils import problem_file_line, url_unquote, wipe_if_deltamode
ONE_LINE = CHARS_W-2 ONE_LINE = CHARS_W-2
async def make_notes_menu(*a): 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): if not settings.get('secnap', False):
# Explain feature, and then enable if interested. Drop them into menu. # 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): class NotesMenu(MenuSystem):
readonly = False
@classmethod @classmethod
def construct(cls): def construct(cls):
# Dynamic menu with user-defined names of notes shown # Dynamic menu with user-defined names of notes shown
@ -134,6 +146,18 @@ class NotesMenu(MenuSystem):
return rv 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 @classmethod
async def export_all(cls, *a): async def export_all(cls, *a):
await start_export(NoteContent.get_all()) await start_export(NoteContent.get_all())
@ -205,8 +229,8 @@ class NotesMenu(MenuSystem):
async def drill_to(cls, menu, item): async def drill_to(cls, menu, item):
# make it so looks like we drilled down into the new note # make it so looks like we drilled down into the new note
menu.goto_idx(item.idx) menu.goto_idx(item.idx)
m = MenuSystem(await item.make_menu()) m = await item._make_menu()
the_ux.push(m) the_ux.push(MenuSystem(m))
class NoteContentBase: class NoteContentBase:
@ -302,7 +326,8 @@ class NoteContentBase:
if not is_new: if not is_new:
# change our own menu contents # change our own menu contents
menu.replace_items(await self.make_menu()) mi = await self._make_menu()
menu.replace_items(mi)
# update parent # update parent
parent = the_ux.parent_of(menu) parent = the_ux.parent_of(menu)
@ -344,25 +369,36 @@ class PasswordContent(NoteContentBase):
flds = ['title', 'user', 'password', 'site', 'misc' ] flds = ['title', 'user', 'password', 'site', 'misc' ]
type_label = 'password' type_label = 'password'
async def make_menu(self, *a): async def _make_menu(self, readonly=False):
rv = [MenuItem('"%s"' % self.title, f=self.view)] rv = [MenuItem('"%s"' % self.title, f=self.view)]
if self.user: if self.user:
rv.append(MenuItem('%s' % self.user, f=self.view)) rv.append(MenuItem('%s' % self.user, f=self.view))
if self.site: if self.site:
rv.append(MenuItem('%s' % self.site, f=self.view)) rv.append(MenuItem('%s' % self.site, f=self.view))
#if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view)) # if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
return rv + [ rv += [
MenuItem('View Password', f=self.view_pw), MenuItem('View Password', f=self.view_pw),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)), MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
MenuItem('Export', f=self.export), ]
MenuItem('Edit Metadata', f=self.edit), if not readonly:
MenuItem('Delete', f=self.delete), rv += [
MenuItem('Change Password', f=self.change_pw), 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(), self.sign_misc_menu_item(),
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label), ShortcutItem(KEY_QR, f=self.view_qr_menu, arg=self.type_label),
ShortcutItem(KEY_NFC, f=self.share_nfc, 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): async def view(self, *a):
pl = len(self.password) pl = len(self.password)
m = '' m = ''
@ -476,18 +512,28 @@ class NoteContent(NoteContentBase):
flds = ['title', 'misc'] flds = ['title', 'misc']
type_label = 'note' type_label = 'note'
async def make_menu(self, *a): async def _make_menu(self, readonly=False):
# Details and actions for this Note # Details and actions for this Note
return [ rv = [
MenuItem('"%s"' % self.title, f=self.view), MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Note', f=self.view), MenuItem('View Note', f=self.view),
MenuItem('Edit', f=self.edit), ]
MenuItem('Delete', f=self.delete), if not readonly:
MenuItem('Export', f=self.export), rv += [
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
rv += [
self.sign_misc_menu_item(), self.sign_misc_menu_item(),
ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"), ShortcutItem(KEY_QR, f=self.view_qr_menu, arg="misc"),
ShortcutItem(KEY_NFC, f=self.share_nfc, 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): async def view(self, *a):
ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR, ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR,

View File

@ -67,6 +67,8 @@ from utils import call_later_ms
# msas = multisig address show (do not censor multisig addresses) # msas = multisig address show (do not censor multisig addresses)
# ccc = (complex) If present, CCC feature is enabled and key details stored here. # 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 # 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 # Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug) # _skip_pin = hard code a PIN value (dangerous, only for debug)

View File

@ -3,7 +3,6 @@
# pincodes.py - manage PIN code (which map to wallet seeds) # pincodes.py - manage PIN code (which map to wallet seeds)
# #
import ustruct, ckcc, version, chains, stash import ustruct, ckcc, version, chains, stash
# from ubinascii import hexlify as b2a_hex
from callgate import enter_dfu from callgate import enter_dfu
from bip39 import wordlist_en from bip39 import wordlist_en
@ -127,6 +126,9 @@ class PinAttempt:
self.private_state = 0 # opaque data, but preserve self.private_state = 0 # opaque data, but preserve
self.cached_main_pin = bytearray(32) 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 MAX_PIN_LEN == 32 # update FMT otherwise
assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1 assert ustruct.calcsize(PIN_ATTEMPT_FMT_V1) == PIN_ATTEMPT_SIZE_V1
@ -339,10 +341,6 @@ class PinAttempt:
return self.state_flags return self.state_flags
def delay(self):
# obsolete since Mk3, but called from login.py
self.roundtrip(1)
def login(self): def login(self):
# test we have the PIN code right, and unlock access if so. # test we have the PIN code right, and unlock access if so.
chk = self.roundtrip(2) chk = self.roundtrip(2)
@ -533,6 +531,7 @@ class PinAttempt:
from trick_pins import TC_DELTA_MODE from trick_pins import TC_DELTA_MODE
return bool(self.delay_required & TC_DELTA_MODE) return bool(self.delay_required & TC_DELTA_MODE)
def get_tc_values(self): def get_tc_values(self):
# Mk4 only # Mk4 only
# return (tc_flags, tc_arg) # return (tc_flags, tc_arg)

View File

@ -40,6 +40,10 @@ _PREFIX_MARKER = const(1<<26)
# - 'encoded' is hex, and has is trimmed of right side zeros # - 'encoded' is hex, and has is trimmed of right side zeros
VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin') 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(): def seed_vault_iter():
# iterate over all seeds in the vault; returns VaultEntry instances. # iterate over all seeds in the vault; returns VaultEntry instances.
# raw vault entries are list type when json.loaded from flash # raw vault entries are list type when json.loaded from flash
@ -150,23 +154,53 @@ class WordNestMenu(MenuSystem):
done_cb = None done_cb = None
def __init__(self, num_words=None, has_checksum=True, done_cb=commit_new_words, 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: if num_words is not None:
WordNestMenu.target_words = num_words WordNestMenu.target_words = num_words
WordNestMenu.has_checksum = has_checksum WordNestMenu.has_checksum = has_checksum
WordNestMenu.words = [] WordNestMenu.words = []
assert done_cb
WordNestMenu.done_cb = done_cb WordNestMenu.done_cb = done_cb
is_commit = True is_commit = True
if not items: 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 self.is_commit = is_commit
super(WordNestMenu, self).__init__(items) 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 @staticmethod
async def next_menu(self, idx, choice): 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): if in_seed_vault(encoded):
return return
# stay "read only" in hobbled mode
if pa.hobbled_mode:
return
main_xfp = settings.master_get("xfp", 0) main_xfp = settings.master_get("xfp", 0)
# parse encoded # 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='', async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
is_restore=False, origin=None, label=None): is_restore=False, origin=None, label=None):
# Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp. # 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) await add_seed_to_vault(encoded, origin=origin, label=label)
dis.fullscreen("Wait...") dis.fullscreen("Wait...")
@ -879,6 +917,8 @@ class SeedVaultMenu(MenuSystem):
ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc) ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
if ch == "x": return if ch == "x": return
assert not_hobbled_mode()
dis.fullscreen("Saving...") dis.fullscreen("Saving...")
wipe_slot = not current_active and (ch != "1") wipe_slot = not current_active and (ch != "1")
@ -890,6 +930,7 @@ class SeedVaultMenu(MenuSystem):
xs.blank() xs.blank()
del xs del xs
# CAUTION: will get shadow copy if in tmp seed mode already # CAUTION: will get shadow copy if in tmp seed mode already
seeds = settings.master_get("seeds", []) seeds = settings.master_get("seeds", [])
try: try:
@ -926,6 +967,8 @@ class SeedVaultMenu(MenuSystem):
from glob import dis from glob import dis
from ux import ux_input_text from ux import ux_input_text
assert not_hobbled_mode()
idx, old = item.arg idx, old = item.arg
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40) 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): async def _add_current_tmp(*a):
from pincodes import pa from pincodes import pa
assert not_hobbled_mode()
assert pa.tmp_value assert pa.tmp_value
main_xfp = settings.master_get("xfp", 0) main_xfp = settings.master_get("xfp", 0)
@ -998,9 +1043,10 @@ class SeedVaultMenu(MenuSystem):
if not seeds: if not seeds:
rv.append(MenuItem('(none saved yet)')) rv.append(MenuItem('(none saved yet)'))
if pa.tmp_value: if not_hobbled_mode():
rv.append(add_current_tmp) if pa.tmp_value:
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu)) rv.append(add_current_tmp)
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
else: else:
wipe_if_deltamode() wipe_if_deltamode()
@ -1016,8 +1062,10 @@ class SeedVaultMenu(MenuSystem):
submenu = [ submenu = [
MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)), MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
MenuItem('Use This Seed', f=cls._set, arg=encoded), MenuItem('Use This Seed', f=cls._set, arg=encoded),
MenuItem('Rename', f=cls._rename, arg=(i, rec)), MenuItem('Rename', f=cls._rename, arg=(i, rec),
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)), predicate=not_hobbled_mode),
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded),
predicate=not_hobbled_mode),
] ]
if is_active: if is_active:
submenu[1] = MenuItem("Seed In Use") submenu[1] = MenuItem("Seed In Use")
@ -1035,7 +1083,7 @@ class SeedVaultMenu(MenuSystem):
rv.append(item) rv.append(item)
if pa.tmp_value: 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 # give em chance to store current active
rv.append(add_current_tmp) rv.append(add_current_tmp)
@ -1124,7 +1172,7 @@ class EphemeralSeedMenu(MenuSystem):
] ]
rv = [ 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, MenuItem('Import from QR Scan', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)), shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
MenuItem("Import Words", menu=import_ephemeral_menu), MenuItem("Import Words", menu=import_ephemeral_menu),
@ -1137,6 +1185,7 @@ class EphemeralSeedMenu(MenuSystem):
async def make_ephemeral_seed_menu(*a): async def make_ephemeral_seed_menu(*a):
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)): if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
# force a warning on them, unless they are already doing it. # force a warning on them, unless they are already doing it.
if not await ux_confirm( if not await ux_confirm(

View File

@ -307,11 +307,17 @@ async def kt_accept_values(dtype, raw):
- `b` - complete system backup file (text, internal format) - `b` - complete system backup file (text, internal format)
''' '''
from flow import has_se_secrets, goto_top_menu from flow import has_se_secrets, goto_top_menu
from pincodes import pa
enc = None enc = None
origin = 'Teleported' origin = 'Teleported'
label = None 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': if dtype == 's':
# words / bip 32 master / xprv, etc # words / bip 32 master / xprv, etc
enc = bytearray(72) enc = bytearray(72)
@ -475,6 +481,12 @@ def decode_step2(session_key, noid_key, body):
async def kt_incoming(type_code, payload): async def kt_incoming(type_code, payload):
# incoming BBQr was scanned (via main menu, etc) # 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': if type_code == 'R':
# they want to send to this guy # they want to send to this guy
return await kt_start_send(payload) return await kt_start_send(payload)
@ -495,6 +507,10 @@ class SecretPickerMenu(MenuSystem):
def __init__(self, rx_pubkey): def __init__(self, rx_pubkey):
self.rx_pubkey = 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 from flow import word_based_seed, is_tmp, has_se_secrets
has_notes = bool(NoteContentBase.count()) has_notes = bool(NoteContentBase.count())
has_sv = bool(settings.get('seedvault', False)) has_sv = bool(settings.get('seedvault', False))

View File

@ -32,7 +32,7 @@ TC_WORD_WALLET = const(0x1000)
TC_XPRV_WALLET = const(0x0800) TC_XPRV_WALLET = const(0x0800)
TC_DELTA_MODE = const(0x0400) TC_DELTA_MODE = const(0x0400)
TC_REBOOT = const(0x0200) TC_REBOOT = const(0x0200)
TC_RFU = const(0x0100) TC_FW_DEFINED = const(0x0100)
# for our use, not implemented in bootrom # for our use, not implemented in bootrom
TC_BLANK_WALLET = const(0x0080) TC_BLANK_WALLET = const(0x0080)
TC_COUNTDOWN = const(0x0040) # tc_arg = minutes of delay 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_args encoding:
# TC_WORD_WALLET -> BIP-85 index, 1001..1003 for 24 words, 2001..2003 for 12-words # 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 # special "pin" used as catch-all for wrong pins
WRONG_PIN_CODE = '!p' WRONG_PIN_CODE = '!p'
@ -274,6 +278,10 @@ class TrickPinMgmt:
# put them in order, with "wrong" last # put them in order, with "wrong" last
return sorted(self.tp.keys(), key=lambda i: i if (i != WRONG_PIN_CODE) else 'Z') return sorted(self.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): def was_countdown_pin(self):
# was the trick pin just used? if so how much delay needed (or zero if not) # was the trick pin just used? if so how much delay needed (or zero if not)
from pincodes import pa from pincodes import pa
@ -284,6 +292,30 @@ class TrickPinMgmt:
else: else:
return 0 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): def get_deltamode_pins(self):
# iterate over all delta-mode PIN's defined. # iterate over all delta-mode PIN's defined.
for k, (sn,flags,args) in self.tp.items(): for k, (sn,flags,args) in self.tp.items():
@ -375,6 +407,12 @@ class TrickPinMgmt:
b, slot = tp.update_slot(pin.encode(), new=True, b, slot = tp.update_slot(pin.encode(), new=True,
tc_flags=flags, tc_arg=arg, secret=new_secret) tc_flags=flags, tc_arg=arg, secret=new_secret)
except: pass 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() tp = TrickPinMgmt()
@ -520,8 +558,7 @@ class TrickPinMenu(MenuSystem):
have.remove(existing_pin) have.remove(existing_pin)
if (new_pin == self.current_pin) or (new_pin in have): 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 await tp.err_unique_pin(new_pin)
return
# check if we "forgot" this pin, and read it back if we did. # 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 # - 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 \ For this mode only, trick PIN must be same length as true PIN and \
differ only in final 4 positions (ignoring dash).\ differ only in final 4 positions (ignoring dash).\
''', flags=TC_DELTA_MODE), ''', 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 = MenuSystem(FirstMenu)
m.goto_idx(1) m.goto_idx(1)
@ -651,9 +691,14 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
the_ux.push(m) the_ux.push(m)
async def clear_all(self, m,l,item): async def clear_all(self, m,l,item):
if not await ux_confirm("Remove ALL TRICK PIN codes and special wrong-pin handling?"): if not await ux_confirm("Remove ALL TRICK PIN codes and special wrong-pin handling?"):
return 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 any(tp.get_duress_pins()):
if not await ux_confirm("Any funds on the duress wallet(s) have been moved already?"): if not await ux_confirm("Any funds on the duress wallet(s) have been moved already?"):
return return
@ -662,7 +707,7 @@ setting) the Coldcard will always brick after 13 failed PIN attempts.''')
m.update_contents() m.update_contents()
async def hide_pin(self, m,l, item): 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: if flags & TC_DELTA_MODE:
await ux_show_story('''Delta mode PIN will be hidden if trick PIN menu is shown \ 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.''') hiding this item.''')
return 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. 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 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 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) await ux_show_story("Failed: %s" % exc)
async def delete_pin(self, m,l, item): 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 flags & (TC_WORD_WALLET | TC_XPRV_WALLET):
if not await ux_confirm("Any funds on this duress wallet have been moved already?"): if not await ux_confirm("Any funds on this duress wallet have been moved already?"):
return 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: if pin == WRONG_PIN_CODE:
msg = "Remove special handling of wrong PINs?" msg = "Remove special handling of wrong PINs?"
else: 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('''\ ch = await ux_show_story('''\
This will temporarily load the secrets associated with this trick wallet \ This will temporarily load the secrets associated with this trick wallet \
so you may perform transactions with it. Reboot the Coldcard to restore \ so you may perform transactions with it.''')
normal operation.''')
if ch != 'y': return if ch != 'y': return
b, slot = tp.get_by_pin(pin) 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")) rv.append(MenuItem("↳Pretends Wrong"))
elif flags & TC_DELTA_MODE: elif flags & TC_DELTA_MODE:
rv.append(MenuItem("↳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 [ for m, msg in [
(TC_WIPE, '↳Wipes seed'), (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.append(MenuItem("Activate Wallet", f=self.activate_wallet, arg=(pin, flags, arg)))
rv.extend([ rv.extend([
MenuItem('Hide Trick', f=self.hide_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)), MenuItem('Delete Trick', f=self.delete_pin, arg=(pin, slot_num, flags, arg)),
]) ])
if pin != WRONG_PIN_CODE: if pin != WRONG_PIN_CODE:
rv.append( rv.append(
@ -907,6 +959,7 @@ Wallet is XPRV-based and derived from a fixed path.''' % pin
class StoryMenuItem(MenuItem): class StoryMenuItem(MenuItem):
def __init__(self, label, story, flags=0, **kws): def __init__(self, label, story, flags=0, **kws):
# arg= .. handled by super
self.story = story self.story = story
self.flags = flags self.flags = flags
super().__init__(label, **kws) super().__init__(label, **kws)

View File

@ -11,7 +11,8 @@ from ustruct import pack, unpack_from
from ckcc import watchpoint, is_simulator from ckcc import watchpoint, is_simulator
from utils import problem_file_line, call_later_ms from utils import problem_file_line, call_later_ms
from version import supports_hsm, is_devmode, MAX_TXN_LEN, MAX_UPLOAD_LEN 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 # Unofficial, unpermissioned... numbers
COINKITE_VID = 0xd13e COINKITE_VID = 0xd13e
@ -68,6 +69,21 @@ HSM_DISABLE_CMDS = frozenset({
"hsms", "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() # singleton instance of USBHandler()
handler = None handler = None
@ -217,6 +233,8 @@ class USBHandler:
except CCBusyError: except CCBusyError:
# auth UX is doing something else # auth UX is doing something else
resp = b'busy' resp = b'busy'
except SpendPolicyViolation:
resp = b'err_Spending policy in effect'
except HSMDenied: except HSMDenied:
resp = b'err_Not allowed in HSM mode' resp = b'err_Not allowed in HSM mode'
except HSMCMDDisabled: except HSMCMDDisabled:
@ -345,7 +363,7 @@ class USBHandler:
except: except:
raise FramingError('decode') 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 # special hacky commands to support testing w/ the simulator
try: try:
from usb_test_commands import do_usb_command from usb_test_commands import do_usb_command
@ -358,7 +376,18 @@ class USBHandler:
if cmd not in HSM_WHITELIST: if cmd not in HSM_WHITELIST:
raise HSMDenied 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: if cmd in HSM_DISABLE_CMDS:
raise HSMCMDDisabled raise HSMCMDDisabled
@ -741,7 +770,6 @@ class USBHandler:
from glob import dis, hsm_active from glob import dis, hsm_active
from utils import check_firmware_hdr from utils import check_firmware_hdr
from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC
from pincodes import pa
# maintain a running SHA256 over what's received # maintain a running SHA256 over what's received
if offset == 0: if offset == 0:
@ -754,8 +782,8 @@ class USBHandler:
assert offset % 256 == 0, 'alignment' assert offset % 256 == 0, 'alignment'
assert offset+len(data) <= total_size <= MAX_UPLOAD_LEN, 'long' assert offset+len(data) <= total_size <= MAX_UPLOAD_LEN, 'long'
if hsm_active: if hsm_active or pa.hobbled_mode:
# additional restrictions in HSM mode # additional restriction in HSM mode or hobbled: must be PSBT
assert offset+len(data) <= total_size <= MAX_TXN_LEN, 'psbt' assert offset+len(data) <= total_size <= MAX_TXN_LEN, 'psbt'
if offset == 0: if offset == 0:
assert data[0:5] == b'psbt\xff', 'psbt' assert data[0:5] == b'psbt\xff', 'psbt'
@ -834,7 +862,6 @@ class USBHandler:
def handle_bag_number(self, bag_num): def handle_bag_number(self, bag_num):
import version, callgate import version, callgate
from glob import dis, settings from glob import dis, settings
from pincodes import pa
if bag_num and version.is_factory_mode and not version.has_qr: if bag_num and version.is_factory_mode and not version.has_qr:
# check state first # check state first

View File

@ -429,6 +429,8 @@ def clean_shutdown(style=0):
# wipe SPI flash and shutdown (wiping main memory) # wipe SPI flash and shutdown (wiping main memory)
# - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom) # - 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 # - 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 import callgate
# save if anything pending # save if anything pending

View File

@ -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 # Draw seed words on single screen (hard) and return x/y position of start of each
from glob import dis 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: if num_words == 12:
cols = 2 cols = 2
xpos = [2, 18] xpos = [2, 18]
@ -902,6 +912,8 @@ class QRScannerInteraction:
async def scan_anything(self, expect_secret=False, tmp=False): async def scan_anything(self, expect_secret=False, tmp=False):
# start a QR scan, and act on what we find, whatever it may be. # start a QR scan, and act on what we find, whatever it may be.
from ux import ux_show_story from ux import ux_show_story
from pincodes import pa
problem = None problem = None
while 1: while 1:
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \ prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
@ -923,6 +935,21 @@ class QRScannerInteraction:
problem = "Unable to decode QR" problem = "Unable to decode QR"
continue 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': if what == 'xprv':
from actions import import_extended_key_as_secret from actions import import_extended_key_as_secret
text_xprv, = vals text_xprv, = vals
@ -1104,6 +1131,7 @@ async def ux_visualize_bip21(proto, addr, args):
await OWNERSHIP.search_ux(addr) await OWNERSHIP.search_ux(addr)
async def ux_visualize_wif(wif_str, kp, compressed, testnet): 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 from ux import ux_show_story
msg = wif_str + "\n\n" msg = wif_str + "\n\n"
msg += "chain: %s\n\n" % ("XTN" if testnet else "BTC") msg += "chain: %s\n\n" % ("XTN" if testnet else "BTC")

View File

@ -95,7 +95,7 @@ async def perform_web2fa(label, shared_secret):
return False 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. # 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 # - can't fit any metadata, like username or our serial # in there
# - better on Q1 where no limitations for this size of QR # - better on Q1 where no limitations for this size of QR
qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, nm = 'COLDCARD' if has_qr else 'CC' # must be url-safe
nm=url_quote(label if has_qr else label[0:4])) qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss, nm=nm)
while 1: while 1:
# show QR for enroll # show QR for enroll
await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App", await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App",
force_msg=True) force_msg=True)
# important: force them to prove they store it correctly # important: force them to prove they stored it correctly
ok = await perform_web2fa('Enroll: ' + label, ss) ok = await perform_web2fa('Enroll: COLDCARD', ss)
if ok: break if ok: break
ch = await ux_show_story("That isn't correct. Please re-import and/or " ch = await ux_show_story("That isn't correct. Please re-import and/or "
"try again or %s to give up." % X) "try again or %s to give up." % X)
if ch == 'x': if ch == 'x':
# mk4 only?
return None return None
return ss return ss

View File

@ -1726,6 +1726,7 @@ ae_read_config_byte(int offset)
uint8_t tmp[4]; uint8_t tmp[4];
ae_read_config_word(offset, tmp); ae_read_config_word(offset, tmp);
// BUG: didnt check for failure, in which case we will return un-inited values
return tmp[offset % 4]; return tmp[offset % 4];
} }

View File

@ -1827,6 +1827,16 @@ def nfc_block4rf(sim_eval):
return doit 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 @pytest.fixture
def load_shared_mod(): def load_shared_mod():
# load indicated file.py as a module # load indicated file.py as a module
@ -2617,11 +2627,33 @@ def build_test_seed_vault():
return sv return sv
return doit 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 # useful fixtures
from test_backup import backup_system 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_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
from test_bip39pw import set_bip39_pw 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_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 generate_ephemeral_words, import_ephemeral_xprv, goto_eph_seed_menu
from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed

View File

@ -44,6 +44,10 @@ def _press_select(device, is_Q, timeout=None):
btn = KEY_ENTER if is_Q else "y" btn = KEY_ENTER if is_Q else "y"
_need_keypress(device, btn, timeout=timeout) _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): def _dev_hw_label(device):
# gets a short string that labels product: mk4 / q1, etc # gets a short string that labels product: mk4 / q1, etc
v = device.send_recv(CCProtocolPacker.version()).split() v = device.send_recv(CCProtocolPacker.version()).split()

View File

@ -10,12 +10,13 @@ async def doit():
import version import version
async def dump_menu(fd, m, label, indent, menu_item=None, menu_idx=0, whs=False): async def dump_menu(fd, m, label, indent, menu_item=None, menu_idx=0, whs=False):
from menu import MenuItem, ToggleMenuItem, MenuSystem, NonDefaultMenuItem 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 trick_pins import TrickPinMenu
from users import UsersMenu from users import UsersMenu
from flow import has_secrets, nfc_enabled, vdisk_enabled, word_based_seed 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 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) print("%s%s"% (indent, label), file=fd)
@ -32,8 +33,9 @@ async def doit():
if version.has_qwerty and m.__name__ == "start_seed_import": if version.has_qwerty and m.__name__ == "start_seed_import":
print('%s[SEED WORD ENTRY]' % indent, file=fd) print('%s[SEED WORD ENTRY]' % indent, file=fd)
return return
if m.__name__ == "make_custom": if m.__name__ in ("make_custom", "bkpw_override"):
# address explorer custom path menu # address explorer custom path menu
# bkpw override = dev thing
return return
print("Calling: %r (%s)" % (m.__name__, label)) print("Calling: %r (%s)" % (m.__name__, label))
@ -99,9 +101,24 @@ async def doit():
here += ' [IF HSM AND SECRET]' here += ' [IF HSM AND SECRET]'
elif pred == qr_and_has_secrets: elif pred == qr_and_has_secrets:
here += ' [IF QR AND SECRET]' 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: elif pred:
if here in ("Secure Notes & Passwords", "Push Transaction"): if here in ("Secure Notes & Passwords", "Push Transaction"):
here += ' [IF ENBALED]' here += ' [IF ENBALED]'
if here == "Secure Logout":
here += ' [IF NOT BATTERIES]'
else: else:
here += ' [MAYBE]' here += ' [MAYBE]'
@ -133,16 +150,25 @@ async def doit():
print('%s%s' % (indent, here), file=fd) 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 from glob import settings
# need these to supress warnings and info messages # need these to supress warnings and info messages
# that need user interaction nad/or show hidden items # that need user interaction nad/or show hidden items
settings.put("seedvault", 1) 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("axskip", 1)
settings.put("b39skip", 1) settings.put("b39skip", 1)
settings.put("sd2fa", ["a"]) settings.put("sd2fa", ["a"])
settings.put("ptxurl", 'https://coldcard.com/pushtx#') 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 # saved passphrase on MicroSD
with open("MicroSD/.tmp.tmp", "wb") as f: with open("MicroSD/.tmp.tmp", "wb") as f:
@ -154,8 +180,13 @@ async def doit():
('[IF BLANK WALLET]', EmptyWallet), ('[IF BLANK WALLET]', EmptyWallet),
('[NORMAL OPERATION]', NormalSystem), ('[NORMAL OPERATION]', NormalSystem),
('[FACTORY MODE]', FactoryMenu), ('[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('---\n', file=fd)
print("DONE: check menudump.txt file") print("DONE: check menudump.txt file")

View File

@ -10,7 +10,7 @@
# #
import pytest, time, pdb import pytest, time, pdb
from core_fixtures import _pick_menu_item, _cap_menu, _cap_story, _cap_screen 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 ckcc_protocol.client import ColdcardDevice
from run_sim_tests import ColdcardSimulator, clean_sim_data from run_sim_tests import ColdcardSimulator, clean_sim_data
@ -530,5 +530,293 @@ def test_calc_login(request):
assert "Ready To Sign" in m assert "Ready To Sign" in m
sim.stop() 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 # EOF

View File

@ -486,7 +486,6 @@ def test_tmp_on_xprv_master(generate_ephemeral_words, cap_menu, go_to_passphrase
time.sleep(.1) time.sleep(.1)
title, story = cap_story() title, story = cap_story()
assert parent_fp in title # no choice story assert parent_fp in title # no choice story
assert "current active temporary seed" in story assert "current active temporary seed" in story
press_select() press_select()

View File

@ -21,21 +21,18 @@ from psbt import BasicPSBT
# pubkey for production server. # pubkey for production server.
SERVER_PUBKEY = '0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd' SERVER_PUBKEY = '0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd'
def py_ckcc_hashfp(output, x, y, data=None): @pytest.fixture
try: def goto_ccc_menu(goto_home, pick_menu_item, is_mark4):
m = hashlib.sha256() def doit():
m.update(x.contents.raw) goto_home()
m.update(y.contents.raw) pick_menu_item("Advanced/Tools")
output.contents.raw = m.digest() pick_menu_item("Spending Policy")
return 1 pick_menu_item("Co-Sign Multi." if is_mark4 else "Co-Sign Multisig (CCC)")
except:
return 0
ckcc_hashfp = ECDH_HASHFP_CLS(py_ckcc_hashfp)
return doit
def make_session_key(his_pubkey=None): def make_session_key(his_pubkey=None):
# - second call: given the pubkey of far side, calculate the shared pt on curve # - second call: given the pubkey of far side, calculate the shared pt on curve
# - creates session key based on that # - creates session key based on that
while True: while True:
@ -50,6 +47,19 @@ def make_session_key(his_pubkey=None):
his_pubkey = ec_pubkey_parse(bytes.fromhex(SERVER_PUBKEY)) his_pubkey = ec_pubkey_parse(bytes.fromhex(SERVER_PUBKEY))
# do the D-H thing # 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) shared_key = ecdh(my_seckey, his_pubkey, hashfp=ckcc_hashfp)
return shared_key, ec_pubkey_serialize(my_pubkey) 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 assert ans == f'CCC-AUTH:{nonce}'.upper() if q_mode else nonce
@pytest.fixture @pytest.fixture
def get_last_violation(sim_exec): def get_last_violation(settings_get):
def doit(): def doit():
return sim_exec('from ccc import CCCFeature; RV.write(CCCFeature.last_fail_reason)') return settings_get('lfr')
return doit return doit
_skip_quiz = False _skip_quiz = False
@pytest.fixture @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, 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, enter_number, scan_a_qr, cap_screen, settings_get, need_keypress, microsd_path,
master_settings_get): master_settings_get):
def doit(c_words=None, mag=None, vel=None, whitelist=None, w2fa=None, first_time=True): def doit(c_words=None, mag=None, vel=None, whitelist=None, w2fa=None, first_time=True):
if first_time: if first_time:
goto_home() goto_ccc_menu()
pick_menu_item("Advanced/Tools")
pick_menu_item("Coldcard Co-Signing")
time.sleep(.1) time.sleep(.1)
title, story = cap_story() title, story = cap_story()
assert title == ("Coldcard Co-Signing" if is_q1 else "CC Co-Sign") 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() m = cap_menu()
assert m[0] == f"CCC [{xfp}]" assert f"[{xfp}]" in m[0]
assert "Spending Policy" in m assert "Spending Policy" in m
assert "Export CCC XPUBs" in m assert "Export CCC XPUBs" in m
assert "Multisig Wallets" 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 assert f"{mag} {'BTC' if int(mag) < 1000 else 'SATS'}" in story
press_select() press_select()
time.sleep(.1)
assert settings_get("ccc")["pol"]["mag"] == mag assert settings_get("ccc")["pol"]["mag"] == mag
if vel: if vel:
@ -362,12 +371,10 @@ def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz
return doit return doit
@pytest.fixture @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): word_menu_entry, cap_menu):
def doit(c_words, seed_vault=False): def doit(c_words, seed_vault=False):
goto_home() goto_ccc_menu()
pick_menu_item("Advanced/Tools")
pick_menu_item("Coldcard Co-Signing")
time.sleep(.1) time.sleep(.1)
title, story = cap_story() title, story = cap_story()
if seed_vault: if seed_vault:
@ -578,13 +585,19 @@ def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
time.sleep(.1) time.sleep(.1)
title, story = cap_story() title, story = cap_story()
assert 'OK TO SEND?' == title 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 ("(%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 "CCC: Violates spending policy. Won't sign." in story
assert get_last_violation().startswith(violation) assert get_last_violation().startswith(violation)
if warn_list: if warn_list:
for w in warn_list: for w in warn_list:
assert w in story 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: else:
assert "warning" not in story 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]) @pytest.mark.parametrize("has_candidates", [True, False])
def test_c_key_from_seed_vault(has_candidates, setup_ccc, build_test_seed_vault, settings_set, 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, goto_ccc_menu, pick_menu_item, press_select, need_keypress, cap_menu,
cap_story, press_cancel, enter_enabled_ccc): cap_story, press_cancel, enter_enabled_ccc, goto_home):
goto_home() goto_home()
settings_set("ccc", None) settings_set("ccc", None)
settings_set("multisig", []) 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) settings_set("seeds", sv)
goto_home() goto_ccc_menu()
pick_menu_item("Advanced/Tools")
pick_menu_item("Coldcard Co-Signing")
press_select() press_select()
time.sleep(.1) 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: for _, obj in keys:
assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc
# EOF # EOF

View File

@ -221,10 +221,14 @@ def restore_main_seed(goto_home, pick_menu_item, cap_story, cap_menu,
@pytest.fixture @pytest.fixture
def confirm_tmp_seed(need_keypress, cap_story, press_select): 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) time.sleep(0.3)
title, story = cap_story() 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 "Press (1) to store temporary seed into Seed Vault" in story:
if seedvault: if seedvault:
need_keypress("1") # store it 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 assert "Seed In Use" in m
pick_menu_item("Seed In Use") # noop pick_menu_item("Seed In Use") # noop
else: elif seed_vault is False:
# Seed Vault disabled # Seed Vault disabled
m = cap_menu() m = cap_menu()
assert "Seed Vault" not in m 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") pick_menu_item("Always Show")
time.sleep(.3) time.sleep(.3)
m = cap_menu() m = cap_menu()
assert m[1] == "Ready To Sign"
assert m[0] == "<" + xfp2str(settings_get("xfp")) + ">" assert m[0] == "<" + xfp2str(settings_get("xfp")) + ">"
assert m[1] == "Ready To Sign"
goto_eph_seed_menu() goto_eph_seed_menu()
pick_menu_item("Generate Words") pick_menu_item("Generate Words")
pick_menu_item(f"12 Words") pick_menu_item(f"12 Words")
time.sleep(0.1) time.sleep(0.1)
need_keypress("6") # skip words need_keypress("6") # skip quiz
press_select() press_select()
time.sleep(.1) time.sleep(.1)
_, story = cap_story() _, story = cap_story()
if "Press (1) to store temporary seed" in story: if "Press (1) to store temporary seed" in story:
# seed vault enabled # seed vault enabled
press_select() # do not save press_select() # do not save
press_select() # new tmp seed press_select() # new tmp seed
time.sleep(.2) time.sleep(.2)
m = cap_menu() m = cap_menu()
assert m[1] == "Ready To Sign" assert m[1] == "Ready To Sign"
assert m[0] == "[" + xfp2str(settings_get("xfp")) + "]" assert m[0] == "[" + xfp2str(settings_get("xfp")) + "]"
pick_menu_item("Restore Master") pick_menu_item("Restore Master")
press_select() press_select()
time.sleep(.3) time.sleep(.3)
m = cap_menu() m = cap_menu()
assert m[1] == "Ready To Sign" 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 # disable now
pick_menu_item("Settings") pick_menu_item("Settings")
pick_menu_item("Home Menu XFP") pick_menu_item("Home Menu XFP")
time.sleep(.1) time.sleep(.1)
_, story = cap_story() _, story = cap_story()
if "Forces display of XFP" in story: if "Forces display of XFP" in story:
press_select() press_select()
pick_menu_item("Only Tmp") pick_menu_item("Only Tmp")
time.sleep(.3) time.sleep(.3)
m = cap_menu() m = cap_menu()
assert m[0] == "Ready To Sign" assert m[0] == "Ready To Sign"

441
testing/test_hobble.py Normal file
View 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

View File

@ -1487,6 +1487,8 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev
f.write(b64encode(psbt).decode()) f.write(b64encode(psbt).decode())
for idx in range(M): for idx in range(M):
select_wallet(idx) select_wallet(idx)
if incl_xpubs:
clear_ms()
_, updated = try_sign(psbt, accept_ms_import=incl_xpubs) _, updated = try_sign(psbt, accept_ms_import=incl_xpubs)
with open(f'{sim_root_dir}/debug/myself-after.psbt', 'w') as f: with open(f'{sim_root_dir}/debug/myself-after.psbt', 'w') as f:
f.write(b64encode(updated).decode()) f.write(b64encode(updated).decode())

View File

@ -40,9 +40,10 @@ def goto_notes(cap_story, cap_menu, press_select, goto_home, pick_menu_item):
return doit return doit
@pytest.fixture @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 # create a note or use what's there, provide as obj
def doit(title='Title Here', body='Body'): def doit(title='Title Here', body='Body'):
assert is_q1
notes = settings_get('notes', []) notes = settings_get('notes', [])
if not notes: if not notes:
settings_set('notes', [dict(misc=body, title=title)]) settings_set('notes', [dict(misc=body, title=title)])

View File

@ -301,8 +301,10 @@ def new_trick_pin(goto_trick_menu, pick_menu_item, cap_menu, press_select,
time.sleep(.1) time.sleep(.1)
m = cap_menu() m = cap_menu()
assert m[0] == f'[{new_pin}]' assert m[0] == f'[{new_pin}]'
assert set(m[1:]) == {'Duress Wallet', 'Just Reboot', 'Wipe Seed', \ assert set(m[1:]) == {'Duress Wallet', 'Just Reboot', 'Wipe Seed', 'Delta Mode',
'Delta Mode', 'Look Blank', 'Brick Self', 'Login Countdown'} 'Look Blank', 'Policy Unlock',
'Policy Unlock & Wipe' if is_q1 else 'P.U. & Wipe',
'Brick Self', 'Login Countdown'}
pick_menu_item(op_mode) pick_menu_item(op_mode)
@ -914,6 +916,14 @@ def build_duress_wallets(request, seed_vault=False):
return 4 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 # TODO
# - make trick and do login, check arrives right state? # - make trick and do login, check arrives right state?

621
testing/test_sssp.py Normal file
View 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

View File

@ -12,6 +12,7 @@ from base64 import b32encode
from constants import * from constants import *
from test_ephemeral import SEEDVAULT_TEST_DATA from test_ephemeral import SEEDVAULT_TEST_DATA
from test_backup import make_big_notes from test_backup import make_big_notes
from test_hobble import set_hobble
# All tests in this file are exclusively meant for Q # 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 if 'Teleport Password' in scr: break
time.sleep(.2) time.sleep(.2)
else: else:
assert False, "Teleport Password not in screen" raise RuntimeError("Teleport Password not in screen")
if expect_xfp: if expect_xfp:
assert xfp2str(expect_xfp) in scr 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.unfinalized
@pytest.mark.parametrize('num_ins', [ 15 ]) @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('segwit', [True])
@pytest.mark.parametrize('incl_xpubs', [ False ]) @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, 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, fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress,
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, cap_menu, pick_menu_item, grab_payload, rx_complete, press_select,
ndef_parse_txn_psbt, press_nfc, nfc_read, settings_get, settings_set, 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 # IMPORTANT: won't work if you start simulator with --ms flag. Use no args
all_out_styles = list(unmap_addr_fmt.keys()) all_out_styles = list(unmap_addr_fmt.keys())
num_outs = len(all_out_styles) num_outs = len(all_out_styles)
set_hobble(hobbled)
clear_ms() clear_ms()
use_regtest() 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() txid = txid_from_export_prompt()
press_select() # exit QR press_select() # exit QR
# share signed txn via low-level NFC if nfc_is_enabled():
press_nfc() # share signed txn via low-level NFC
time.sleep(.1) press_nfc()
contents = nfc_read() 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 not got_psbt
assert got_txn assert got_txn
@ -725,4 +736,26 @@ def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick
settings_set('notes', []) 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 # EOF

View File

@ -29,11 +29,16 @@ if '--sflash' not in sys.argv:
if '--eff' in sys.argv: if '--eff' in sys.argv:
# ignore files ondisk from previous runs, and also dont write any # ignore files ondisk from previous runs, and also dont write any
nvstore.SettingsObject.load = lambda *a:None # - but do track settings during this run
nvstore.SettingsObject.save = lambda *a:None NVSTORE_FAKE = {bytes(32): dict(sim_defaults)} # prelogin values
# limitation: pre-login values arent stored even during operation
#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: if '--early-usb' in sys.argv:
from usb import enable_usb from usb import enable_usb

View File

@ -15,30 +15,34 @@ class SecondSecureElement:
self.wallet = None self.wallet = None
self.load() self.load()
if not self.state: def reconstruct(self, tp):
# reconstruct based on user-space understanding of SE2 content # reconstruct based on user-space understanding of SE2 content
# - can't work with duress wallet cases here (no data) # - can't work with duress wallet cases here (no data)
# - mostly here so sim_settings works w/ non-empty defaults # - mostly here so sim_settings works w/ non-empty defaults
print("SIM SE2: found no state, trying to reconstruct") print("SIM SE2: found no state, trying to reconstruct")
from glob import settings from glob import settings
from trick_pins import TC_FAKE_OUT, TC_WORD_WALLET, TC_XPRV_WALLET 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 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(): print(" .. tp = %r" % tp)
if (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)): if not tp: return
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
b, s = make_slot() for pin, (slot_num, tc_flags, tc_arg) in tp.items():
s.pin_len = len(pin) if (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)):
s.pin[:s.pin_len] = pin.encode() print("cant do duress cases")
s.tc_flags = tc_flags continue
s.tc_arg = tc_arg #assert not (tc_flags & (TC_DELTA_MODE | TC_WORD_WALLET | TC_XPRV_WALLET)), \
s.slot_num = slot_num #'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 # 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 # merging default values as they contain useful nfc,vidsk info
dv = obj.default_values() dv = obj.default_values()
obj.current.update(dv) obj.current.update(dv)
s = obj.get('_se2', None) s = obj.get('_se2', None) or []
if not s:
print("no SE2 data")
return
for record in s: for record in s:
b = a2b_base64(record) b = a2b_base64(record)
slot = uctypes.struct(uctypes.addressof(b), TRICK_SLOT_LAYOUT) slot = uctypes.struct(uctypes.addressof(b), TRICK_SLOT_LAYOUT)
self.state[slot.slot_num] = b self.state[slot.slot_num] = b
print("SE2 slot %d is populated" % slot.slot_num) 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): def callgate(self, buf_io, arg2):
# ckcc.callgate(22, ...) # ckcc.callgate(22, ...)
@ -149,11 +155,12 @@ class SecondSecureElement:
# similar to stm32/mk4-bootloader/se2.c se2_test_trick_pin(safety_mode=False) # similar to stm32/mk4-bootloader/se2.c se2_test_trick_pin(safety_mode=False)
xs = self.get_by_pin(pin.encode(), num_fails) xs = self.get_by_pin(pin.encode(), num_fails)
if not xs: if not xs:
self.wallet = None # bugfix: normal login after trick login (SP unlock case)
return None return None
print("PIN %s is a TRICK!" % pin)
tc_flags = xs.tc_flags tc_flags = xs.tc_flags
tc_arg = xs.tc_arg 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_WIPE, TC_BRICK, TC_REBOOT, TC_FAKE_OUT
from trick_pins import TC_WORD_WALLET, TC_XPRV_WALLET, TC_DELTA_MODE from trick_pins import TC_WORD_WALLET, TC_XPRV_WALLET, TC_DELTA_MODE

View File

@ -141,7 +141,7 @@ if '--secret' in sys.argv:
if '-g' 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) sim_defaults.pop('_skip_pin', 0)
if '--nick' in sys.argv: if '--nick' in sys.argv: