What changed, and why it matters
This is a large feature commit adding Codex32 (BIP-93) secret encoding and Shamir secret sharing to COLDCARD firmware. It introduces new menu flows to generate, import, split, recover, and derive Codex32 shares, plus two COLDCARD-specific extensions (CW1 for BIP-39 entropy and CX1 for extended private keys). The commit also adds documentation, conformance vectors, and many tests. There is no indication of a security bug or vulnerability fix; it is a new feature implementation.
Review the new cryptographic implementation and integration for correctness, side-channel resistance, and secure handling of sensitive shares. Ensure the CW1/CX1 extensions are clearly communicated to users and that recovery software compatibility is understood. No immediate security patch is indicated.
Security signals we found
New feature: Codex32 / BIP-93 secret encoding and Shamir secret sharing
New COLDCARD extensions CW1 and CX1 with documented wire format and conformance vectors
Input validation for case, HRP, length, checksum, threshold, and scalar bounds
Pending recovery shares excluded from encrypted backups and Key Teleport
No vendor disclosure of security relevance or vulnerability fix
Evidence from the diff
The commit adds a complete Codex32 implementation in shared/codex32.py (checksums, GF(32) interpolation, Share class), integrates it into seed management, backup/restore, NFC, QR scanning, UI input, and menu flows. It defines CW1 (BIP-39 entropy) and CX1 (chain code + private scalar) extensions with their own HRPs, lengths, and checksum constants. The code rejects mixed case, invalid HRPs, unsupported lengths, bad checksums, threshold 1, non-secret shares at threshold 0, invalid secp256k1 scalars, and mismatched recovery sets. Extensive test vectors and unit tests are included. No security-relevant bug or patch is evident from the diff.
Changed components
shared/codex32.pyshared/seed.pyshared/actions.pyshared/stash.pyshared/backups.pyshared/flow.pyshared/nfc.pyshared/ux.pyshared/ux_mk4.pyshared/ux_q1.pyshared/decoders.pyshared/teleport.pyshared/tapsigner.pyshared/utils.pyshared/manifest.pyshared/nvstore.pydocs/codex32.mddocs/codex32-extensions.mddocs/codex32-extension-vectors.jsontesting/test_codex32.pytesting/test_codex32_extensions.pyInspect captured patch +4993 / −89
### docs/README.md
@@ -13,6 +13,7 @@ wants to understand why it's safe to put your moneys into Coldcard.
- [`backup-files.md`](backup-files.md) Some details of our encrypted backup files.
- [`usb-ncry-v3.md`](usb-ncry-v3.md) Details of USB ncry v3 encrypted sessions and MITM-check best practices.
- [`temporary-seeds.md`](temporary-seeds.md) Temporary (ephemeral) seeds and the Seed Vault.
+- [`codex32.md`](codex32.md) Creating, importing, splitting, and recovering Codex32 secrets.
- [`seed-xor.md`](seed-xor.md) More about _Seed XOR_ feature, including fully worked Seed XOR example, and useful XOR lookup chart.
- [`key-teleport.md`](key-teleport.md) Key Teleport: encrypted transfer of seeds and secrets between Q devices.
- [`spending-policy.md`](spending-policy.md) Spending policy: autonomous signing with configurable limits.
### docs/codex32-extension-vectors.json
@@ -0,0 +1,272 @@
+{
+ "description": "Public CW1/CX1 conformance vectors. Never use these secrets for funds.",
+ "valid_sets": [
+ {
+ "id": "cw-128",
+ "hrp": "cw",
+ "payload_hex": "00000000000000000000000000000000",
+ "padding": 0,
+ "secret": "CW12TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQEQHKN8PU566VY",
+ "standalone": "CW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG74",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "CW12TESTCDFXLE9M586X7S3CWTTLTT8VKN2UMXTKT0GRZGZ4",
+ "CW12TESTD40HTUG3F9RHENPW2ZZTZZ98YVS33KSMQT4Z5YX9"
+ ],
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ "wallets": [
+ {
+ "passphrase": "",
+ "xprv": "xprv9s21ZrQH143K3GJpoapnV8SFfukcVBSfeCficPSGfubmSFDxo1kuHnLisriDvSnRRuL2Qrg5ggqHKNVpxR86QEC8w35uxmGoggxtQTPvfUu",
+ "fingerprint": "73C5DA0A"
+ },
+ {
+ "passphrase": "codex32-example",
+ "xprv": "xprv9s21ZrQH143K3j6fpDsRkit6yTu3LpMgJGJGH7tWLQCSQS6TDeQyVn2bQ7WhboREHYN4EU26F7vd7NeYPWvEoL2atbNwCgHB8U8Sr38zE5a",
+ "fingerprint": "AD300F70"
+ }
+ ]
+ },
+ {
+ "id": "cw-192-nonzero-padding",
+ "hrp": "cw",
+ "payload_hex": "000102030405060708090a0b0c0d0e0f1011121314151617",
+ "padding": 7,
+ "secret": "CW12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9LPKXVWVTYD0EAK",
+ "standalone": "CW10TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9LZRNLFVAAYU0X4",
+ "shares": [
+ "CW12TESTACSX9Z98HK88YT4E7HZ5KMZ0K208HDA6R2KEQEDS3EZJD4AR5AHNN",
+ "CW12TESTC4P0DTU5KWXF2J6R30EJLT43MXW9008PJKLCX33JQMVKA09AWTZX2",
+ "CW12TESTDMJCKU5L622NS6VL8TFNG5854YZATHM3PTG582V2NRK0ZNPCJ4GVZ"
+ ],
+ "mnemonic": "abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rib",
+ "wallets": [
+ {
+ "passphrase": "",
+ "xprv": "xprv9s21ZrQH143K2CqMfQ23xCYdZXgh41nCQBtaPeuhpFgpzUaV7qKZJGcW43hyftAZuu6P2HXkPgJmcT7Mxo5qgCo9inthtPP8RCuLBipS6WY",
+ "fingerprint": "28C3D664"
+ },
+ {
+ "passphrase": "codex32-example",
+ "xprv": "xprv9s21ZrQH143K46EPLTwQig6EpXzYtUj2cn3N9kJ22rKXZZ6n3GJMV1aBFTLx21JcyLKDgxdjpzVv2CxngzNdNCApSQyD8EJz2GWmR2R8PwM",
+ "fingerprint": "796C4739"
+ }
+ ]
+ },
+ {
+ "id": "cw-256-nonzero-padding",
+ "hrp": "cw",
+ "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
+ "padding": 15,
+ "secret": "CW12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9CCRYDPK8QARC0LYHHQS7RWHTF54",
+ "standalone": "CW10TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9CCRYDPK8QARC0LAZNGRHPWVMJ2D",
+ "shares": [
+ "CW12TESTAHCT7U02JDXTTQXKURDU3642AATVW6VNZHSJF4SKW8UZJ2TE0EHAX4YEVSLKQXKH0J",
+ "CW12TESTCC48T3DU2GSH84GW5Y5XXADDU7YZVHSRYLSLY0CRKF3QNFEREN4TUN9V7SG74QZNJV",
+ "CW12TESTDWM9PL4ZWAW29LJ20FU65SFQS2JC5EDU9YS32NRFJNLCLA7L8SD4QJFXES69LPY2RF"
+ ],
+ "mnemonic": "abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware",
+ "wallets": [
+ {
+ "passphrase": "",
+ "xprv": "xprv9s21ZrQH143K2VqrrWbcFGpF6RBabiU5bv9V8kgy1zfcQwq46iGuzhSbWPvA3ZxAFQ1jtDSEgnSvZjBBydNYobTUbsSBRxKKb5LMHzN1Cmi",
+ "fingerprint": "3E1F3AE0"
+ },
+ {
+ "passphrase": "codex32-example",
+ "xprv": "xprv9s21ZrQH143K3tZSEXGoUKeyyAeuX2oAHjh4xCkWaXHmnuLBpRxb4s9XgxxZhbvif4TVUUB2usizBoX7yzvWy3Kjz4at4aGm7i255QxAfLc",
+ "fingerprint": "2A15A8C0"
+ }
+ ]
+ },
+ {
+ "id": "cx-passphrase-wallet",
+ "hrp": "cx",
+ "payload_hex": "a7883746d5c6d1d1dedf075123fe844264d63915234442696ee155f60ceb8b2c32783274204029a2f59b29b371cbf5b1c1461692d747c9d43fedda09f532ee3a",
+ "padding": 0,
+ "secret": "CX12TESTS57YRW3K4CMGARHKLQAGJ8L5YGFJDVWG4YDZYY6TWU92LVR8T3VKRY7PJWSSYQ2DZ7KDJNVM3E06MRS2XZ6FDW37F6SL7MKSF75EWUWS8Y7MXXRMH3H3RG8",
+ "standalone": "CX10TESTS57YRW3K4CMGARHKLQAGJ8L5YGFJDVWG4YDZYY6TWU92LVR8T3VKRY7PJWSSYQ2DZ7KDJNVM3E06MRS2XZ6FDW37F6SL7MKSF75EWUWSPZ05PRRL4EN75HN",
+ "shares": [
+ "CX12TESTAFMPQ5A37Q7U6NMQD6ULWEPKHHQXKE9MNAXNG447NPS2PMFQ83G4S0XCTJCEDEHWSU9M2MT8FWZ37FMVQUKWZPULMH5V5V6R33HF9CV2FF4FMKDRHVZEV9K",
+ "CX12TESTCAZCS700JD8RYZFCMSTSDA93KYTET3F668246NHK84C295J743X93RTZ33YJXRR7XMYR884YYP8A8JH9FCYSQREGDJ6D0RGZUN8CFKTQUVEL08CWHC29WQS",
+ "CX12TESTDM2S0AG2SR02P3WU9NEXEYUU60YS62TKDMG5AJ0DPNR2UZZMJ3U6AP97D9EAFL9PCKG8F6SS2HJL0Z4J7PR4CMV6N8QPL40WJTCTTVXRKEMPF5JQH73CK43"
+ ],
+ "chain_code_hex": "a7883746d5c6d1d1dedf075123fe844264d63915234442696ee155f60ceb8b2c",
+ "private_key_hex": "32783274204029a2f59b29b371cbf5b1c1461692d747c9d43fedda09f532ee3a",
+ "xprv": "xprv9s21ZrQH143K3j6fpDsRkit6yTu3LpMgJGJGH7tWLQCSQS6TDeQyVn2bQ7WhboREHYN4EU26F7vd7NeYPWvEoL2atbNwCgHB8U8Sr38zE5a",
+ "fingerprint": "AD300F70"
+ },
+ {
+ "id": "cx-key-one",
+ "hrp": "cx",
+ "payload_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000000000000000000000000000000000000000000000000000000000000001",
+ "padding": 7,
+ "secret": "CX12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9CCRYDPK8QARC0SQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ0NLPWN84JVWYL9G0",
+ "standalone": "CX10TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9CCRYDPK8QARC0SQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ04ESP5Z4KWXQSJHM",
+ "shares": [
+ "CX12TESTAQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQJC4MCSFC68PFGMM",
+ "CX12TESTCQQQ3WQDWQLSQ48QTHUE3DSU4HRUH8W3P83MXJEDDSW9HC7Q5SDZ3QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQZ9X2N5L2RZVC3D6Y",
+ "CX12TESTDQQQZ5QR5QK0QL2QY9P3ZR0PL9HP925ZD2ZT843RR05S9UMQJ0R6ZQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ6HRRGKF7NXRS4CKD"
+ ],
+ "chain_code_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
+ "private_key_hex": "0000000000000000000000000000000000000000000000000000000000000001",
+ "xprv": "xprv9s21ZrQH143K24MoUenttLtWQNeeDZvsczTUeCMmb85Mn2qbbmZbpre8QqPqPmd8WTHi9dvj1xdRPwwyuutTwApKSzkJwpuVB4m6KdwVJXY",
+ "fingerprint": "751E76E8"
+ },
+ {
+ "id": "cx-key-order-minus-one",
+ "hrp": "cx",
+ "payload_hex": "0000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140",
+ "padding": 3,
+ "secret": "CX12TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ0LLLLLLLLLLLLLLLLLLLLLLLLAW4WMNN27J9Q8WLAYH5V6QMYZSRPM0NF2Z07FTV4DX",
+ "standalone": "CX10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ0LLLLLLLLLLLLLLLLLLLLLLLLAW4WMNN27J9Q8WLAYH5V6QMYZSR8A7UW0ZTUP0RZJJ",
+ "shares": [
+ "CX12TESTA6T3T63UWYV9GUYQ8KA0CDJXCXG9CY4SYHT7LP2ZK8KFSNTYKC8LRAFLRKUELDDH3FZHV0W9S7NJFY8VHZ9CJP4R8R7HYMWD3MMR30HA68NC92F2UZFP9VQ",
+ "CX12TESTCS8H8SHLM27U5L2QEWFD4GY0405U42AP2C86VK39WEWZPJ82W4EV363LQAVSLMMTY3KTD7G0JFPH37VKDCA5LJT2ED0T7G5H9VXWE2FE8YS5H29NMWWY5MF",
+ "CX12TESTDN9K9NKT3SE5FTSQU204MA6CMCF5MSXJSW9R8YPG2U2DJ79S2MU8KH4L5JN8L99KW4SKPDFV2MXZ4ZR23YEK3EWAUHUKZRGTR7HQZLVQKSYKS28MKVR38F7"
+ ],
+ "chain_code_hex": "0000000000000000000000000000000000000000000000000000000000000000",
+ "private_key_hex": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140",
+ "xprv": "xprv9s21ZrQH143K24Mfq5zL5MhWK9hUhhGbd45hLXo2Pq2oqzMMo63oStZzFAzHGBP2UuGCqWLTAPLcMtD5SDKr24z3aiUvKr9bJpdrc9bCf9B",
+ "fingerprint": "ADDE4C73"
+ }
+ ],
+ "invalid_encodings": [
+ {
+ "id": "mixed-case",
+ "encoded": "cW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG74",
+ "reason": "mixed case"
+ },
+ {
+ "id": "bad-character",
+ "encoded": "CW10TESTSBQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG74",
+ "reason": "invalid alphabet character"
+ },
+ {
+ "id": "bad-checksum",
+ "encoded": "CW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG7Q",
+ "reason": "checksum mismatch"
+ },
+ {
+ "id": "changed-prefix-short",
+ "encoded": "MS10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG74",
+ "reason": "checksum mismatch"
+ },
+ {
+ "id": "changed-prefix-long",
+ "encoded": "MS10TESTS57YRW3K4CMGARHKLQAGJ8L5YGFJDVWG4YDZYY6TWU92LVR8T3VKRY7PJWSSYQ2DZ7KDJNVM3E06MRS2XZ6FDW37F6SL7MKSF75EWUWSPZ05PRRL4EN75HN",
+ "reason": "checksum mismatch"
+ },
+ {
+ "id": "unknown-prefix",
+ "encoded": "CC10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQGU34F99UQ259KEZ",
+ "reason": "unsupported HRP"
+ },
+ {
+ "id": "threshold-one",
+ "encoded": "CW11TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQD858VGW90MG74",
+ "reason": "invalid threshold"
+ },
+ {
+ "id": "threshold-zero-share",
+ "encoded": "CW10TESTAQQQQQQQQQQQQQQQQQQQQQQQQQQ6EZ3NR2R2KFU8",
+ "reason": "non-secret index at threshold zero"
+ },
+ {
+ "id": "cw-length-20",
+ "encoded": "CW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQESY0P8ENX443R",
+ "reason": "unsupported CW1 length"
+ },
+ {
+ "id": "cw-length-28",
+ "encoded": "CW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQTFJW42MGV8W6U",
+ "reason": "unsupported CW1 length"
+ },
+ {
+ "id": "cw-length-64",
+ "encoded": "CW10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ4V5C64PKYWRGYQ0",
+ "reason": "unsupported CW1 length"
+ },
+ {
+ "id": "cx-length-32",
+ "encoded": "CX10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQUZHH266JXYR09",
+ "reason": "unsupported CX1 length"
+ }
+ ],
+ "invalid_wallets": [
+ {
+ "id": "cx-key-zero",
+ "encoded": "CX10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQZQFRAEZ98D8NA55",
+ "reason": "invalid private scalar"
+ },
+ {
+ "id": "cx-key-order",
+ "encoded": "CX10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ0LLLLLLLLLLLLLLLLLLLLLLLLAW4WMNN27J9Q8WLAYH5V6QMYZSG4PT506CCSMUD9H7",
+ "reason": "invalid private scalar"
+ },
+ {
+ "id": "cx-key-maximum",
+ "encoded": "CX10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ0LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLC3DTV9WXW2WCMKPH",
+ "reason": "invalid private scalar"
+ },
+ {
+ "id": "cw-non-secret",
+ "encoded": "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "reason": "non-secret index"
+ },
+ {
+ "id": "cx-non-secret-zero-key",
+ "encoded": "CX12TESTAQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQJC4MCSFC68PFGMM",
+ "reason": "non-secret index"
+ }
+ ],
+ "invalid_recoveries": [
+ {
+ "id": "too-few",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W"
+ ],
+ "reason": "insufficient shares"
+ },
+ {
+ "id": "duplicate-index",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W"
+ ],
+ "reason": "duplicate index"
+ },
+ {
+ "id": "mismatched-hrp",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "MS12TESTCQQQQQQQQQQQQQQQQQQQQQQQQQQURE0LYMY6KRKP"
+ ],
+ "reason": "mismatched hrp"
+ },
+ {
+ "id": "mismatched-identifier",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "CW12NAMECQQQQQQQQQQQQQQQQQQQQQQQQQQ0FMZYG52LS3D0"
+ ],
+ "reason": "mismatched identifier"
+ },
+ {
+ "id": "mismatched-threshold",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "CW13TESTCQQQQQQQQQQQQQQQQQQQQQQQQQQK0S5SY0WM4FVV"
+ ],
+ "reason": "mismatched threshold"
+ },
+ {
+ "id": "mismatched-length",
+ "shares": [
+ "CW12TESTA0AMU8ZWGT7MV62HK55U55TLPRYMWAR3CH59DF6W",
+ "CW12TESTCQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ7ML0JFVQG42XT"
+ ],
+ "reason": "mismatched length"
+ }
+ ]
+}
### docs/codex32-extensions.md
@@ -0,0 +1,198 @@
+# CW1 and CX1: Codex32 Extensions
+
+Status: COLDCARD-defined extensions, maintained by Coinkite. This document is
+the normative definition of CW1 and CX1 in this repository. They are not part
+of BIP-93, and implementations must explicitly support them. The `1` in each
+name is the separator, not a version number. Future incompatible encodings must
+use a different prefix; existing prefixes must retain the meanings defined here.
+
+The [user guide](codex32.md) describes COLDCARD workflows. This specification
+defines the portable encoding, independently of device menus and storage.
+MUST, MUST NOT and SHOULD indicate requirements and recommendations.
+
+## Wire Format
+
+Use [BIP-93](https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki)'s
+alphabet, header, bit packing, checksum polynomials and GF(32) sharing scheme,
+with the HRP and payload interpretations defined below:
+
+```text
+HRP "1" threshold identifier index payload checksum
+```
+
+The alphabet, in numerical order from 0 to 31, is
+`qpzry9x8gf2tvdw0s3jn54khce6mua7l`. The threshold is one character: `0`, or
+`2` through `9`. The identifier is four alphabet characters; the index is one.
+Index `s` denotes a secret. Threshold `0` MUST only be used with index `s`.
+A secret may also carry a threshold of 2 through 9.
+
+Strings MUST be entirely lowercase or entirely uppercase. Checksum computation
+uses lowercase. Mixed case, unknown prefixes, invalid alphabet characters,
+unsupported lengths and failed checksums MUST be rejected. Whitespace is not
+part of the wire format; an input UI may remove presentation spaces before
+validation. Uppercase is recommended for handwriting and QR presentation.
+
+| HRP | Payload bytes | Payload characters | Checksum characters | Total characters |
+|-----|---------------|--------------------|---------------------|------------------|
+| `cw` | 16 | 26 | 13 | 48 |
+| `cw` | 24 | 39 | 13 | 61 |
+| `cw` | 32 | 52 | 13 | 74 |
+| `cx` | 64 | 103 | 15 | 127 |
+
+These lengths apply to both secrets and shares. All other lengths MUST be
+rejected, including 20- and 28-byte CW1 payloads.
+
+### Bit Packing and Padding
+
+Encode bytes in order, most significant bit first, into five-bit alphabet
+values. Append enough padding bits to complete the last symbol: 2, 3, 4 and 3
+bits respectively for the four rows above. New encodings of wallet material
+SHOULD use zero padding. Decoders MUST accept every value of those padding bits.
+
+Padding is part of the shared polynomial data. It MUST be preserved during
+share recovery and derivation, including in a reconstructed `s` secret. Only
+when converting a secret into wallet bytes are the final incomplete byte's bits
+discarded. Do not decode shares to bytes and re-encode them with zero padding
+before interpolation.
+
+### Checksum
+
+CW1 uses BIP-93's regular 13-symbol checksum and constant
+`0x10ce0795c2fd1e62a`. CX1 uses its long 15-symbol checksum and constant
+`0x43381e570bf4798ab26`. Both retain the respective BIP-93 generator constants.
+
+The actual lowercase HRP MUST be included using BIP-173 expansion:
+
+```python
+def expand(hrp):
+ return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
+# cw -> [3, 3, 0, 3, 23]
+# cx -> [3, 3, 0, 3, 24]
+```
+
+Start the polynomial residue at **1**, then process the expanded HRP followed
+by the alphabet values of the header and payload. For construction, append
+13 or 15 zero values, XOR the resulting residue with the corresponding
+constant, and emit that many five-bit symbols, most significant first. For
+verification, process the supplied checksum too; the final residue MUST equal
+the constant.
+
+BIP-93's reference `ms32_polymod` functions instead start at `0x23181b3`, which
+already incorporates `ms`. Do not retain that initial state for CW1 or CX1,
+and do not include both a precomputed HRP state and an expanded HRP. Merely
+replacing a string's prefix invalidates its checksum.
+
+## Payload Interpretation
+
+### CW1: English BIP-39 Entropy
+
+The secret bytes are exactly the original 16, 24 or 32 bytes of entropy for
+12, 18 or 24 English BIP-39 words. They contain neither the mnemonic checksum
+nor a passphrase. Regenerate the checksum and words using
+[BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) and its
+English word list. Other word-list languages are not represented by CW1.
+
+CW1 makes the tradeoff described in BIP-93's
+[Not BIP-0039 Entropy rationale](https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki#not-bip-0039-entropy):
+sharing can be performed manually, but regenerating the mnemonic checksum
+practically requires software.
+
+To obtain wallet keys, use BIP-39's mnemonic-to-seed procedure, including its
+normalization and optional passphrase, followed by BIP-32 master-key derivation.
+An empty passphrase is the default. Do not feed the CW1 entropy directly into
+BIP-32; that produces a different wallet. Sharing the keys of an already-active
+passphrase wallet uses CX1 instead and does not preserve the words.
+
+### CX1: Extended Private-Key Material
+
+The secret bytes are exactly:
+
+```text
+32-byte chain code || 32-byte ser256(k)
+```
+
+`k` is an unsigned, big-endian secp256k1 private scalar, left-padded to 32 bytes.
+The XPRV key-data field's leading `0x00` byte is NOT included. Before activating
+an index `s` secret, implementations MUST require `1 <= k < n`, where:
+
+```text
+n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
+```
+
+Any 32-byte chain code is permitted. Non-secret shares MUST NOT be rejected
+because their payload bytes would form an invalid private scalar. Validate the
+scalar after reconstructing the secret; valid checksums alone do not establish
+that it is a usable wallet.
+
+Use the chain code and scalar directly as an extended private node; do not run
+them through master-seed derivation. For
+[BIP-32 serialization](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#serialization-format),
+set depth, parent fingerprint and child number to zero, and choose version bytes
+for the externally selected network. The node's own fingerprint is HASH160 of
+its compressed public key, truncated to the first four bytes.
+
+CX1 carries no network, ancestry, derivation path, address type or wallet
+descriptor. Importing a non-root extended key makes it a new root: its original
+serialization and ancestry are not recoverable. Words and BIP-39 passphrases
+cannot be recovered or applied through the words-wallet flow.
+
+## Sharing and Recovery
+
+Apply BIP-93's GF(32) interpolation to the five-bit header and payload values
+(checksums may be regenerated). The field polynomial is `x^5 + x^3 + 1`;
+indices are their alphabet values, so the secret coordinate `s` is 16.
+
+Recovery requires a threshold-sized subset with identical HRP, threshold,
+identifier and payload length, and distinct indices. Reject mismatches,
+duplicates and insufficient shares. A recovery input share has a non-`s`
+index and threshold 2 through 9. If more shares are supplied, select a valid
+threshold-sized subset; handling inconsistent extra shares is outside this
+format. An `s` secret may be activated directly but is not an independent
+recovery share.
+
+Matching headers and valid checksums do not prove that shares belong to the
+same original set. Modified shares can reconstruct a different, valid wallet;
+deriving additional shares does not authenticate the input set. Before relying
+on recovered or derived shares, verify the wallet against an independently
+recorded address using the original network, address type and derivation path.
+A fingerprint alone is insufficient.
+
+For splitting an existing secret at threshold `t`, choose a fresh identifier
+and `t-1` independent, uniformly random payloads at distinct non-secret indices,
+including their padding bits. Interpolate from those shares and the secret to
+produce the desired output indices. Deterministic vector data below is for
+testing only. Derived shares preserve the HRP, identifier, threshold and length.
+
+The format permits all 31 non-secret indices. COLDCARD's current limit of nine
+output shares and its index ordering are UI choices, not encoding restrictions.
+An identifier, threshold and padding are not wallet material; importing a
+secret does not require retaining them. A fresh split is a new share set.
+
+## Conformance Vectors
+
+[codex32-extension-vectors.json](codex32-extension-vectors.json) contains fixed,
+public test data. Never use these secrets for funds. It covers all CW1 lengths,
+CX1 scalar boundaries, complete 2-of-3 sets, standalone secrets, nonzero padding,
+invalid encodings, invalid wallet inputs and incompatible recovery inputs.
+
+`valid_sets` entries specify payload hex, padding as a low-bit integer, the exact
+threshold-bearing `secret`, a threshold-0 `standalone` with the same payload and
+padding, and three shares. Every pair MUST reconstruct the exact `secret`;
+deriving the third share from either other pair MUST reproduce it. CW1 entries
+include the mnemonic and mainnet root XPRV/fingerprint with empty and
+`codex32-example` passphrases. CX1 entries include the chain code, scalar and
+mainnet root outputs.
+Fingerprints are the four hash bytes in order, written as uppercase hex.
+
+`invalid_encodings` MUST fail string validation. `invalid_wallets` are valid
+checksummed encodings but MUST fail wallet activation for the stated reason.
+`invalid_recoveries` MUST fail recovery for the stated mismatch or insufficiency.
+Reason labels describe requirements, not mandated error-message text.
+
+[Host regression tests](../testing/test_codex32_extensions.py) check these fixed
+vectors against `shared/codex32.py`. They require the repository's testing
+dependencies, but no simulator:
+
+```sh
+python -m pytest --noconftest testing/test_codex32_extensions.py
+```
### docs/codex32.md
@@ -0,0 +1,446 @@
+# Codex32
+
+[_(new in v5.6.3 for Mk4/Mk5 and v1.5.3Q for Q)_](https://coldcard.com/docs/upgrade/)
+
+Codex32 is the checksummed secret encoding and Shamir secret-sharing scheme
+defined by [BIP-93](https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki).
+COLDCARD can create and import Codex32 wallets, split an active wallet into a
+threshold set of shares, recover a wallet, and derive additional shares from
+an existing threshold set.
+
+COLDCARD displays and exports Codex32 shares using capital letters to make them
+easier to read and transcribe by hand. Shares written entirely in lowercase are
+equally valid and can be imported. Uppercase and lowercase letters must not be
+mixed within a share. Spaces added for readability are ignored when importing.
+
+## Anatomy of a Codex32 String
+
+ MS1 2 W7F2 S XXXXXXXXXXXXXXXXXXXXXXXXXY 9ML44VCLR4TFD
+ <1> <2><-3-><4><----------5------------> <-----6----->
+
+ 1: prefix and separator: MS1, CX1 or CW1 (see Encodings below)
+ 2: threshold: 0, or 2 through 9
+ 3: identifier: four characters, the same for every share in one set
+ 4: share index
+ 5: payload
+ 6: checksum: 13 characters, or 15 for the longest strings
+
+A string whose share index is `s` is the **secret**: the value the wallet is
+made from. A string with any other index is a **share**. Threshold `0` marks a
+standalone secret that does not specify a share-set threshold. A threshold of
+2 through 9 is the number of shares needed to recover the secret. Every share
+in one set carries the same prefix, threshold, identifier, and length.
+
+When a Codex32 string is shown on screen, COLDCARD numbers it in groups of four
+characters. The verification quiz refers to those group numbers.
+
+## Encodings
+
+The prefix identifies what the recovered bytes mean. COLDCARD selects it from
+the active wallet automatically:
+
+| Active wallet | Split format | Recovery restores | Share characters |
+|---------------|--------------|-------------------|------------------|
+| English BIP-39 words (12, 18, 24) | `cw1` | Original words; passphrases can be applied afterward | 48, 61, 74 |
+| Raw BIP-32 master seed (128, 256, 512 bits) | `ms1` | Master-seed bytes | 48, 74, 127 |
+| Extended private key, including an active BIP-39 passphrase wallet | `cx1` | Chain code and private key | 127 |
+
+`ms1` is defined by BIP-93. `cw1` and `cx1` are COLDCARD extensions and require
+software that explicitly supports the respective prefix. Changing a prefix is
+not a conversion between wallet types: the same bytes interpreted as BIP-39
+entropy, a BIP-32 master seed, or an extended key do not represent the same
+wallet.
+
+Implementers: see the [CW1/CX1 specification](codex32-extensions.md) and its
+[conformance vectors](codex32-extension-vectors.json) for the exact encoding and
+recovery rules.
+
+### Seed Words and BIP-39 Passphrases
+
+For a backup of your **words**, return to or reload the original words wallet
+before using Shamir Split. It uses CW1. After recovering those shares, apply
+your passphrase again to access the corresponding passphrase wallet. Keep the
+passphrase separately: CW1 shares contain only word entropy, and the same
+recovered words can be used with any of their BIP-39 passphrases.
+
+For a backup of the **currently active passphrase wallet's keys**, split while
+that passphrase wallet is active. COLDCARD stores its active secret as an
+extended key, so the split uses CX1. Recovery accesses that wallet directly,
+without requesting the passphrase. The shares do not retain the original words
+or passphrase, and the recovered extended-key wallet cannot apply a different
+BIP-39 passphrase. This also applies to passphrases used with temporary words.
+
+MS1 and CX1 wallets are not word-based and do not support the BIP-39 passphrase
+flow. CW1 recovers a words wallet and supports that flow normally.
+
+For CX1, keep the original network and derivation settings to reproduce the
+same addresses. An imported non-root extended key becomes a new root; its
+original ancestry is not preserved.
+
+## Secret Shares and Wallets
+
+Only index `S` can be imported as a wallet. Non-secret shares are accepted by
+Shamir Recover and Derive Shares, but cannot be activated or saved to Seed Vault.
+Recovery interpolates `S` from a threshold set, then imports its wallet material.
+The original ID, threshold and padding are not retained in wallet storage.
+
+## Create a Codex32 Wallet
+
+On a device with a PIN but no wallet, select:
+
+ Codex32 > Generate > 128-bit
+
+or:
+
+ Codex32 > Generate > 256-bit
+
+To create a temporary Codex32 wallet while another wallet is present, select:
+
+ Advanced/Tools > Temporary Seed > Codex32 > Generate
+
+COLDCARD mixes its random sources with user-provided entropy and displays the
+resulting `ms1` secret with fixed ID `SEED`, index `S`, threshold `0` and zero
+padding. A verification quiz checks the recorded groups before the wallet is
+activated. Temporary-wallet creation also offers an explicit option to skip
+the quiz.
+
+To verify generation with dice or coin entropy, use the standalone
+[verify_seed_mix.py](verify_seed_mix.py) script:
+
+ python3 verify_seed_mix.py --codex32
+
+The ID defaults to `SEED`. Enter the device's `View TRNG Words`, your dice
+rolls or coin flips, and the selected bit length. Add `--tmp` for a temporary
+wallet; the default is a master wallet. Verify offline and keep inputs and
+output secret.
+
+For dice-only generation, select `Generate > Advanced > 128-bit Dice Roll`
+or `256-bit Dice Roll`. These require at least 50 or 99 rolls respectively.
+Generation aborts if any face occurs more than 30% of the time, even when the
+minimum roll count is met.
+The seed is SHA-256 of the entered roll digits (truncated to 16 bytes for
+128-bit), with no device randomness mixed in. The ID is always `SEED`.
+
+Verify this using the standalone [rolls_codex32.py](rolls_codex32.py) script:
+
+ python3 rolls_codex32.py --bits 128 < rolls.txt
+
+Use `128` or `256`; the ID defaults to `SEED`. The script reads rolls from
+stdin, ignores whitespace, and prints the full rolling-screen hash followed
+by the Codex32 secret. Keep the rolls and output secret; verify offline.
+The script enforces the same minimum roll count and 30% distribution check.
+
+!!! warning "The displayed Codex32 secret backs up the wallet's key material."
+
+ Record it completely and accurately. View Secret can reproduce this
+ generated MS1 backup while the wallet is active. If you lose the wallet,
+ you need the recorded backup or a threshold of its split shares.
+
+Codex32 backs up key material only, not device settings or wallet configuration.
+For multisig, also retain the wallet descriptor, configuration file, or a backup
+containing that information.
+
+## Import a Codex32 Secret
+
+Select `Import Codex32` from either Codex32 menu. Depending on the device and
+enabled hardware, an index `S` secret can be imported by:
+
+- MicroSD or Virtual Disk text file
+- NFC
+- QR scan
+- Manual entry
+
+For file imports, use a `.txt` file between 48 and 512 bytes. Put one complete
+secret or share on one line, without a label such as `secret` or `share A`.
+Spaces are allowed, but do not wrap the string across lines. Only the first
+line beginning with `MS1`, `CX1` or `CW1` (case-insensitive, after removing spaces) is
+imported; a file containing several shares does not import the whole set.
+For example, this is the entire contents of a valid import file using a public
+test secret. Never use it to hold funds:
+
+ MS10TESTSXXXXXXXXXXXXXXXXXXXXXXXXXX4NZVCA9CMCZLW
+
+Non-secret shares must be entered through `Shamir Recover` or `Derive Shares`.
+`Import Codex32` rejects them without changing the wallet or Seed Vault.
+
+`Advanced/Tools > Danger Zone > Seed Functions > View Secret` displays raw master
+seed bytes as hex together with an MS1 backup string for MS1 imports, English
+seed words for CW1 imports, and the extended private key for CX1 imports. The
+original Codex32 string is not stored or included in encrypted backups.
+
+## Calculate a Checksum
+
+Select **Codex32 > Calculate Checksum** (**Calc Checksum** on Mk4/Mk5) and enter
+the header and payload without a checksum, manually or using Q's scan shortcut.
+MS1, CW1 and CX1 secret `S`
+and ordinary Shamir shares are supported. Manual entry uses uppercase; Q converts
+lowercase keystrokes to uppercase. Scanned text must be all uppercase or all
+lowercase. Spaces between groups are allowed.
+The result shows the checksum and completed string, preserving all payload and
+padding bits. Use the standard share actions to display a QR code, share via NFC,
+or save to MicroSD or Virtual Disk when available. It does not import or activate
+a wallet.
+
+Calculating a checksum cannot detect existing transcription mistakes: it computes
+a checksum for exactly the header and payload you entered.
+
+## Split the Active Wallet
+
+Select:
+
+ Advanced/Tools > Danger Zone > Seed Functions > Shamir Split
+
+The format follows the active wallet: English words use CW1, raw master seeds
+use MS1, and extended keys use CX1. The table under [Encodings](#encodings)
+shows what each format recovers. There is no format-selection menu.
+
+If a BIP-39 passphrase wallet is active, its derived keys are split as CX1.
+Return to or reload the original words wallet first if you want a CW1 backup
+of the words instead. COLDCARD warns about CW1/CX1 compatibility before the
+split and explains what recovery will restore.
+
+Choose between two and nine total shares, then a threshold between two and the
+total. Every split uses fresh randomness and chooses its own ID independently
+of the fixed `SEED` ID used for standalone MS1 backups.
+
+A split creates a new set with zero padding on its secret `S`. The original
+wallet remains unchanged, but shares from an earlier set cannot be mixed with
+these shares.
+
+The resulting menu lists each share separately. Open every share to display it
+and export it by QR, NFC, MicroSD, or Virtual Disk as available. File exports
+are written as `<id>_share_<index>.txt`. A signature file is also written when a
+master or temporary wallet is available.
+
+Text files, QR codes, and NFC exports contain the share in plaintext. The
+signature file does not encrypt it. Keep fewer than the threshold number of
+shares on any one storage medium.
+
+!!! warning "COLDCARD does not retain the generated share set."
+
+ It cannot recreate the shares later. Do not leave the split screen until
+ every share you intend to keep has been recorded and compared with its
+ displayed value. Splitting has no verification quiz. Splitting again
+ produces a different set; do not mix shares from separate splits.
+
+## Recover from Shares
+
+On a device without a wallet, select:
+
+ Codex32 > Shamir Recover
+
+To recover as a temporary wallet, select:
+
+ Advanced/Tools > Temporary Seed > Codex32 > Shamir Recover
+
+Shares may be supplied in any order and through any supported import method.
+After the first share, COLDCARD requires the HRP, ID, threshold, and length to
+match. Duplicate share indices and secret index `s` values are rejected.
+
+Shares are collected from external sources only. Neither the active wallet nor
+Seed Vault supplies shares to recovery or derivation.
+
+In Shamir Recover or Derive Shares, cancel and choose **Save & Exit** to save a
+partial set and resume later. Saved shares are encrypted when a master wallet is
+configured; otherwise, they are stored without confidentiality protection.
+
+Shamir Recover and Derive Shares share one saved partial set. Opening either
+automatically resumes that set. To start a different set, cancel at the import
+method prompt and confirm **Discard collected shares?** instead of choosing
+**Save & Exit**, then reopen the operation. The saved copy is cleared as soon
+as the threshold is collected, even if wallet recovery subsequently fails.
+
+Recovery begins automatically as soon as the threshold is reached. The
+reconstructed index `s` secret is then activated as the selected master or
+temporary wallet. CW1 recovery restores a words wallet: use **View Secret** to
+check the words, then apply your original BIP-39 passphrase separately if needed.
+CX1 recovery restores extended keys directly. MS1 recovery restores raw
+master-seed bytes; View Secret displays hex and a standalone `MS10SEEDS...`
+backup, not the original share-set identifier or threshold.
+
+### Verify Your Backup
+
+Valid checksums and matching share headers do not authenticate the share set.
+Modified shares can recover a different, valid wallet, and Derive Shares can
+carry that substitution into additional shares. Check against an independently
+recorded address before relying on a recovered wallet or derived shares.
+
+Before splitting, record the original wallet fingerprint and a known receive
+address, including its network, address type, and derivation path. After
+recording the shares, test recovery before relying on them:
+
+1. Keep the original wallet and its existing backup intact. Use temporary
+ recovery or a separate device that supports the shares' prefix (MS1, CW1
+ or CX1).
+2. Import a threshold number of shares from the copies you will store.
+3. For CW1, reapply any passphrase needed for the wallet you are checking.
+ Check that the recovered wallet has the original fingerprint and reproduces
+ the known address using the same wallet configuration. A fingerprint alone
+ is not sufficient verification.
+4. Repeat with other combinations until every share you intend to keep has
+ been included in a successful recovery. There is no need to test every
+ possible combination.
+
+### Troubleshooting
+
+- **File not found or recognised:** check the `.txt` extension, 48–512-byte
+ size, and single-line format described above. Remove example labels.
+- **Checksum or case error:** compare the entire string with the original,
+ including its prefix and checksum. Use all uppercase or all lowercase.
+ COLDCARD detects errors but does not implement BIP-93 error correction.
+- **Unsupported length:** only the payload sizes listed under [Encodings](#encodings)
+ are supported, even if another length is valid under BIP-93.
+- **Mismatched or duplicate shares:** use distinct indices from the same split,
+ with matching prefix, ID, threshold, and length. Index `s` is already a
+ secret, so use `Import Codex32` rather than `Shamir Recover` for it.
+- **Invalid `cx1` private key:** a checksummed string is not necessarily usable
+ as a standalone wallet. If it is a non-secret share, use it in `Shamir Recover`;
+ if the recovered secret is rejected, recheck the source and share set.
+
+## Derive Additional Shares
+
+Select `Codex32 > Derive Shares` on a blank device, or use the temporary
+Codex32 menu on a device with a wallet. Collection uses the same import
+methods and matching checks as recovery. After collecting exactly the
+threshold, select any offered output index to display/export that share. Output
+indices are limited to `A`, `C`, `D`, `E`, `F`, `G`, `H`, `J`, and `K`, matching
+Shamir Split's nine-share limit.
+Collected indices are excluded.
+
+Outputs preserve the original ID, threshold, prefix and size, and belong to the
+same share set.
+You can select multiple outputs or reproduce the same output later from any
+threshold set. This also works with existing `cx1` and `cw1` sets. Exiting
+discards the session; derivation does not activate a wallet or save shares to
+Seed Vault.
+
+The device collecting a threshold can calculate the combined secret, even
+though this flow only exports shares. Trust it accordingly. Recovering `S`
+and using `Shamir Split` instead creates a fresh set, whose shares cannot be
+mixed with the original set.
+
+## Worked Examples
+
+All secrets, seed words, and passphrases in these examples are public and
+deliberately insecure. Never use them to hold funds.
+
+### Native `ms1` Wallet
+
+Import this public BIP-93 test secret:
+
+ MS10TESTSXXXXXXXXXXXXXXXXXXXXXXXXXX4NZVCA9CMCZLW
+
+Its padding is nonzero. Import discards that padding and the identifier, while
+preserving these 128 seed bits:
+
+ 318c6318c6318c6318c6318c6318c631
+
+View Secret shows those bytes and the following standalone MS1 backup. The
+payload's last character and checksum differ, but it restores the same wallet:
+
+ MS10SEEDSXXXXXXXXXXXXXXXXXXXXXXXXXYXCV5FGVUZJQQ6
+
+An illustrative 2-of-3 split uses a fresh identifier `W7F2` and zero padding on
+its secret S. Actual splits choose a random identifier and random share data:
+
+ secret MS12W7F2SXXXXXXXXXXXXXXXXXXXXXXXXXY9ML44VCLR4TFD
+ share A MS12W7F2AQQQSYQCYQ5RQWZQFPG9SCRGWPUAM077H9XN5W88
+ share C MS12W7F2CFFFGRFURFZ6FJVFTLA4GU6AJL3SM7JJ23UZRHJU
+ share D MS12W7F2D777VW79W7UJ70K7N6H2V9JH06L7MDSSMHQ33LCV
+
+Shamir Split exports A, C and D; the secret above is shown here to explain what
+they reconstruct. Any two recover the same master seed. After recovery,
+View Secret displays `MS10SEEDS...` again. To derive additional shares belonging
+to `W7F2`, supply a threshold of the original shares to Derive Shares.
+
+### Splitting a Seed-Word Wallet
+
+Consider this public 12-word BIP-39 test mnemonic:
+
+ abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
+
+With no passphrase, its master key fingerprint is `73C5DA0A`. Splitting these
+words uses `cw1` with a 128-bit payload of sixteen zero bytes. A 2-of-3 split
+produces three 48-character shares. Any two recover the same English words,
+which reproduce this wallet and can still be used with a BIP-39 passphrase.
+For example, this illustrative set uses ID `W0RD`:
+
+ secret CW12W0RDSQQQQQQQQQQQQQQQQQQQQQQQQQQY7APCDMV8SRFS
+ share A CW12W0RDAQQQSYQCYQ5RQWZQFPG9SCRGWPUZVSX8UXYXF6SF
+ share C CW12W0RDCQQQP2Q42QTNQM9QZK5UP4N5MKLT64C56JC3NQ2N
+ share D CW12W0RDDQQQJSQMSQZVQ3GQDYF5JMVF3YTUYQALM59R0UK0
+
+After recovery, View Secret shows the twelve English words. Apply the original
+passphrase afterward if the wallet you need used one. This differs from
+splitting an already-active passphrase wallet, as shown next.
+
+### Passphrase Wallet Key Conversion
+
+Activating the same seed words with the example passphrase `codex32-example`
+produces a different wallet with fingerprint `AD300F70`. An illustrative
+2-of-3 split with identifier `PASS` would reconstruct this secret. This example
+shows the converted key material only, not a complete share set for recovery:
+
+ CX12PASSS57YRW3K4CMGARHKLQAGJ8L5YGFJDVWG4YDZYY6TWU92LVR8T3VKRY7PJWSSYQ2DZ7KDJNVM3E06MRS2XZ6FDW37F6SL7MKSF75EWUWSGP3L3LSA0VKS82T
+
+Importing this secret restores fingerprint `AD300F70` directly, without
+requesting the seed words or passphrase. Neither the words nor the passphrase
+can be recovered from the reconstructed `cx1` secret.
+
+## Storage and Backups
+
+Importing or recovering `S` stores only its wallet material in the ordinary
+secret format: MS1 becomes a raw master seed, CW1 becomes English BIP-39 words,
+and CX1 becomes an extended private key. There is no Codex32 storage trailer.
+The identifier, threshold, share index and payload padding are discarded.
+
+Encrypted backups, Seed Vault wallet entries and Key Teleport preserve the
+underlying wallet, not its original Codex32 string or share-set identity.
+The same storage formats are already understood by older firmware.
+
+Pending shares saved with **Save & Exit** stay on this device. They are excluded
+from wallet backups and full Key Teleport transfers, and ignored during restore.
+
+Record a newly generated MS1 secret when it is displayed and verified. After
+activation, View Secret shows both its seed bytes as hex and its MS1 backup
+string, using fixed ID `SEED`, index `S`, threshold `0` and zero padding. This
+reproduces a newly generated backup exactly. An imported backup may have a
+different ID, threshold or padding; the displayed string still encodes the same
+seed and wallet. QR and NFC exports use this MS1 string. Shamir Split creates
+a fresh ID and share set for this wallet.
+To extend an existing set, use Derive Shares with a threshold of its original
+shares instead.
+
+## Limitations
+
+- Only the sizes in [Encodings](#encodings) are supported, even where BIP-93
+ permits additional sizes.
+- Generating a new Codex32 wallet offers 128 or 256 bits only.
+- While Spending Policy is in force, **Generate**, **Derive Shares**, and
+ **Shamir Split** are unavailable. Access to **Temporary Seed** requires the
+ policy's **Related Keys** option to be enabled.
+- A split produces between 2 and 9 shares with a threshold of 2 through 9.
+ BIP-93 has no threshold of 1; threshold `0` encodes a standalone secret.
+- Shamir Split assigns indices in order: `A`, `C`, `D`, `E`, `F`, `G`, `H`, `J`,
+ `K`. Index `s` is reserved for the secret, and `B`, `I`, `O`, and `1` are not
+ characters in the Codex32 alphabet.
+- An MS1 or CX1 wallet is not word-based, so `Export SeedQR` and
+ `Seed XOR > Split Existing` are not offered while one is active.
+
+## Security Notes
+
+- A Codex32 checksum detects recording and entry errors. It does not encrypt
+ the secret or a share.
+- Anyone with the threshold number of matching shares can reconstruct the
+ encoded secret. Accessing a passphrase wallet from CW1 also requires its
+ passphrase. CX1 shares of an active passphrase wallet recover its keys
+ without that passphrase.
+- Fewer than the threshold shares reveal no information about the shared
+ secret when shares are generated and stored correctly.
+- If fewer than the threshold number of shares survive, the original wallet
+ cannot be recovered from that share set.
+- Splitting an existing wallet does not invalidate its original backup. Anyone
+ with the original seed words and any required passphrase, the original XPRV,
+ or another complete backup can still control the wallet. The shares add a
+ recovery method but do not convert the wallet to threshold-only security.
+- Treat each individual share as sensitive.
### docs/menu-tree.txt
@@ -16,6 +16,17 @@
Advanced
12 Word Dice Roll
24 Word Dice Roll
+ Codex32
+ Generate
+ 128-bit
+ 256-bit
+ Advanced
+ 128-bit Dice Roll
+ 256-bit Dice Roll
+ Import Codex32
+ Shamir Recover
+ Derive Shares
+ Calculate Checksum
Import Existing
12 Words
[SEED WORD ENTRY]
@@ -40,6 +51,17 @@
24 Words
12 Word Dice Roll
24 Word Dice Roll
+ Codex32
+ Generate
+ 128-bit
+ 256-bit
+ Advanced
+ 128-bit Dice Roll
+ 256-bit Dice Roll
+ Import Codex32
+ Shamir Recover
+ Derive Shares
+ Calculate Checksum
Import from QR Scan [IF QR SCANNER]
Import Words
12 Words
@@ -343,6 +365,17 @@
24 Words
12 Word Dice Roll
24 Word Dice Roll
+ Codex32
+ Generate
+ 128-bit
+ 256-bit
+ Advanced
+ 128-bit Dice Roll
+ 256-bit Dice Roll
+ Import Codex32
+ Shamir Recover
+ Derive Shares
+ Calculate Checksum
Import from QR Scan [IF QR SCANNER]
Import Words
12 Words
@@ -375,10 +408,11 @@
Danger Zone
Debug Functions
Seed Functions
- View Seed Words
+ View Secret
Seed XOR
Split Existing [IF WORD BASED SEED]
Restore Seed XOR
+ Shamir Split
Destroy Seed [IF SECRET AND NOT TMP SEED]
Lock Down Seed [MAYBE]
Export SeedQR [IF WORD BASED SEED]
@@ -688,6 +722,10 @@
Teleport Multisig PSBT [MAYBE]
View Identity
Temporary Seed [IF SSSP RELATED KEYS ENABLED]
+ Codex32
+ Import Codex32
+ Shamir Recover
+ Calculate Checksum
Import from QR Scan [IF QR SCANNER]
Import Words
12 Words
### docs/rolls_codex32.py
@@ -0,0 +1,64 @@
+# Usage: python3 rolls_codex32.py --bits 128 < rolls.txt
+# Requires Python 3 and nothing else. Keep rolls and output secret.
+# Public domain.
+
+import argparse
+import sys
+from hashlib import sha256
+
+CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
+
+
+def encode_seed(seed, uid):
+ # Encode bytes as five-bit symbols with zero padding.
+ groups = (len(seed) * 8 + 4) // 5
+ value = int.from_bytes(seed, 'big') << (groups * 5 - len(seed) * 8)
+ payload = ''.join(CHARSET[(value >> (5 * i)) & 31]
+ for i in range(groups - 1, -1, -1))
+ body = '0' + uid + 's' + payload
+
+ # Codex32 short checksum, sufficient for 128- and 256-bit secrets.
+ hrp = 'ms'
+ values = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
+ values += [CHARSET.index(c) for c in body] + [0] * 13
+ generators = (0x19dc500ce73fde210, 0x1bfae00def77fe529,
+ 0x1fbd920fffe7bee52, 0x1739640bdeee3fdad, 0x07729a039cfc75f5a)
+ residue = 1
+ for value in values:
+ top = residue >> 60
+ residue = ((residue & 0x0fffffffffffffff) << 5) ^ value
+ for i, generator in enumerate(generators):
+ if (top >> i) & 1:
+ residue ^= generator
+ residue ^= 0x10ce0795c2fd1e62a
+ checksum = ''.join(CHARSET[(residue >> (5 * i)) & 31] for i in range(12, -1, -1))
+ return (hrp + '1' + body + checksum).upper()
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Verify a dice-only Codex32 secret.')
+ parser.add_argument('--bits', type=int, choices=(128, 256), required=True)
+ parser.add_argument('--id', default='seed',
+ help='four-character ID (default: SEED; override for older backups)')
+ args = parser.parse_args()
+ uid = args.id.lower()
+ if len(uid) != 4 or any(c not in CHARSET for c in uid):
+ parser.error('ID must contain four Codex32 characters')
+
+ rolls = ''.join(sys.stdin.read().split())
+ if not rolls or any(c not in '123456' for c in rolls):
+ parser.error('rolls must contain only digits 1-6 (whitespace is ignored)')
+ minimum = 50 if args.bits == 128 else 99
+ if len(rolls) < minimum:
+ parser.error('at least %d rolls required' % minimum)
+ if any(rolls.count(c) / len(rolls) > 0.30 for c in '123456'):
+ parser.error('some numbers occurred more than 30% of the time')
+
+ digest = sha256(rolls.encode('ascii')).digest()
+ print(digest.hex())
+ print()
+ print(encode_seed(digest[:args.bits // 8], uid))
+
+
+if __name__ == '__main__':
+ main()
### docs/verify_seed_mix.py
@@ -1,12 +1,15 @@
# Usage:
#
# python3 verify_seed_mix.py
+# python3 verify_seed_mix.py --codex32 --tmp
#
-# - Verifies New Seed Words using View TRNG Words plus dice or coin entropy.
-# - Use rolls.py or rolls12.py for Advanced dice-only seeds.
+# - Verifies words or Codex32 using View TRNG Words plus dice or coin entropy.
+# - Add --tmp for temporary wallets; defaults to master-wallet generation.
+# - Use rolls.py, rolls12.py or rolls_codex32.py for Advanced dice-only seeds.
# - Requires python3 and nothing else!
# - Public domain.
#
+import argparse
from hashlib import sha256
@@ -189,6 +192,34 @@
'c': ('Coin flips', b'C', '01', 128),
}
+CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
+
+
+def encode_codex32(seed, uid):
+ # Encode bytes as five-bit symbols with zero padding.
+ groups = (len(seed) * 8 + 4) // 5
+ value = int.from_bytes(seed, 'big') << (groups * 5 - len(seed) * 8)
+ payload = ''.join(CHARSET[(value >> (5 * i)) & 31]
+ for i in range(groups - 1, -1, -1))
+ body = '0' + uid + 's' + payload
+
+ # Codex32 short checksum, sufficient for 128- and 256-bit secrets.
+ hrp = 'ms'
+ values = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
+ values += [CHARSET.index(c) for c in body] + [0] * 13
+ generators = (0x19dc500ce73fde210, 0x1bfae00def77fe529,
+ 0x1fbd920fffe7bee52, 0x1739640bdeee3fdad, 0x07729a039cfc75f5a)
+ residue = 1
+ for value in values:
+ top = residue >> 60
+ residue = ((residue & 0x0fffffffffffffff) << 5) ^ value
+ for i, generator in enumerate(generators):
+ if (top >> i) & 1:
+ residue ^= generator
+ residue ^= 0x10ce0795c2fd1e62a
+ checksum = ''.join(CHARSET[(residue >> (5 * i)) & 31] for i in range(12, -1, -1))
+ return (hrp + '1' + body + checksum).upper()
+
def mnemonic24_to_entropy(words):
words = words.split()
@@ -224,7 +255,7 @@ def entropy_to_mnemonic(entropy):
return words
-def derive_seed(base_seed, symbols, method, nwords):
+def derive_seed(base_seed, symbols, method, nwords, tmp=False):
if len(base_seed) != 32:
raise ValueError('TRNG words must encode 256 bits')
if method not in METHODS:
@@ -238,28 +269,46 @@ def derive_seed(base_seed, symbols, method, nwords):
if len(symbols) < minimum:
raise ValueError('%s require at least %d entries' % (title, minimum))
user_entropy = sha256(b'CC\x01' + method_id + symbols.encode()).digest()
- mix = b'CC\x01SM' + method_id + base_seed + user_entropy
+ purpose = b'T' if tmp else b'M'
+ mix = b'CC\x01S' + purpose + method_id + base_seed + user_entropy
seed = sha256(sha256(mix).digest()).digest()
return seed[:16] if nwords == 12 else seed
def main():
- print('KEEP SECRET: TRNG words, entries, and resulting seed words are private.\n')
+ parser = argparse.ArgumentParser(description='Verify seed mixing for words or Codex32.')
+ parser.add_argument('--codex32', metavar='ID', type=str.lower, nargs='?', const='seed',
+ help='output Codex32 (ID defaults to SEED; override for older backups)')
+ parser.add_argument('--tmp', action='store_true', help='verify a temporary wallet')
+ args = parser.parse_args()
+ if args.codex32 is not None:
+ if len(args.codex32) != 4 or any(c not in CHARSET for c in args.codex32):
+ parser.error('ID must contain four Codex32 characters')
+ print('KEEP SECRET: TRNG words, entries, and resulting secret are private.\n')
try:
base_seed = mnemonic24_to_entropy(input('Enter the 24 TRNG words: ').strip())
method = input('Use [d]ice rolls or [c]oin flips? ').strip().lower()
if method not in METHODS:
raise ValueError('entropy source must be d or c')
title = METHODS[method][0]
symbols = ''.join(input('Enter all %s: ' % title.lower()).split())
- nwords = int(input('Final seed length (12 or 24 words): ').strip())
- seed = derive_seed(base_seed, symbols, method, nwords)
+ if args.codex32:
+ bits = int(input('Final seed length (128 or 256 bits): ').strip())
+ if bits not in (128, 256):
+ raise ValueError('seed length must be 128 or 256 bits')
+ nwords = 12 if bits == 128 else 24
+ else:
+ nwords = int(input('Final seed length (12 or 24 words): ').strip())
+ seed = derive_seed(base_seed, symbols, method, nwords, tmp=args.tmp)
except (ValueError, EOFError) as exc:
raise SystemExit('ERROR: %s' % exc)
print('\n' + seed.hex() + '\n')
- print('\n'.join('%4d: %s' % item
- for item in enumerate(entropy_to_mnemonic(seed), 1)))
+ if args.codex32:
+ print(encode_codex32(seed, args.codex32))
+ else:
+ print('\n'.join('%4d: %s' % item
+ for item in enumerate(entropy_to_mnemonic(seed), 1)))
if __name__ == '__main__':
### releases/Next-ChangeLog.md
@@ -10,6 +10,11 @@ your addition and anything else already in this file.**
- Enhancement: Warn before installing firmware signed by an external contributor
or downgrading from the currently installed firmware. Thanks to Huzaifa Jawaid for his suggestion.
+- New Feature: Codex32 (BIP-93) secrets and Shamir secret sharing. Generate or import Codex32 wallets,
+ split the active wallet into two to nine Shamir shares with **Shamir Split**, and restore it with **Shamir Recover**.
+ Word wallets split as `cw1`, raw master seeds as `ms1`, and extended-key wallets as `cx1`.
+ CW1 and CX1 are COLDCARD extensions that require explicit support in recovery software.
+
- Bugfix: Fix device crash when message-signing input is valid JSON but not an
object (NFC / QR / SD `.json` file). Thanks to [@Amiga500](https://github.com/Amiga500).
### shared/actions.py
@@ -4,17 +4,18 @@
#
# Every function here is called directly by a menu item. They should all be async.
#
-import ckcc, pyb, version, uasyncio, sys, uos, chains
+import ckcc, pyb, version, sys, uos, chains, ngu
from uhashlib import sha256
from uasyncio import sleep_ms
from ubinascii import hexlify as b2a_hex
-from utils import imported, problem_file_line, get_filesize, encode_seed_qr
-from utils import xfp2str, B2A, txid_from_fname, wipe_if_deltamode
-from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted
-from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words
-from export import export_contents, make_summary_file, make_descriptor_wallet_export
-from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export
-from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export
+from utils import (imported, problem_file_line, get_filesize, encode_seed_qr, xfp2str, B2A,
+ txid_from_fname, wipe_if_deltamode)
+from ux import (ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted, ux_enter_bip32_index,
+ ux_enter_codex32, ux_enter_number, ux_input_text, import_export_prompt, show_qr_code,
+ OK, X, ux_render_words)
+from export import (export_contents, make_summary_file, make_descriptor_wallet_export,
+ make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export,
+ generate_unchained_export, generate_electrum_wallet, make_key_expression_export)
from files import CardSlot, CardMissingError, needs_microsd
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
from glob import settings
@@ -527,6 +528,10 @@ async def new_from_dice(menu, label, item):
import seed
return await seed.new_from_dice(item.arg)
+async def pick_new_codex32(menu, label, item):
+ import seed
+ return await seed.make_new_codex32_wallet(*item.arg)
+
async def any_active_duress_ux():
from trick_pins import tp
tp.reload()
@@ -643,17 +648,22 @@ def render_master_secrets(mode, raw, node):
elif mode == 'master':
title = "Master Secret" if version.has_qwerty else None
- msg = '%d bytes:\n\n' % len(raw)
+ from codex32 import MS_HRP, SECRET, SECRET_ID, Share
+ from seed import render_codex32
+
qr = str(b2a_hex(raw), 'ascii')
- msg += qr
+ msg = '%d bytes:\n\n%s' % (len(raw), qr)
+ if len(raw) in (16, 32, 64):
+ qr = Share.from_seed(raw, MS_HRP, SECRET_ID, SECRET, 0).to_string()
+ msg = 'Codex32:\n\n' + render_codex32(qr) + '\n\n' + msg
+ qr_alnum = True
else:
raise ValueError(mode)
return title, msg, qr, qr_alnum
async def view_seed_words(*a):
- if not await ux_confirm('The next screen will show the secret seed words'
- ' (or extended private key).'
+ if not await ux_confirm("The next screen will show this wallet's secret."
'\n\nAnyone with knowledge of the secret '
'can control all funds in this wallet.'):
return
@@ -884,11 +894,12 @@ async def start_login_sequence():
# implement idle timeout now that we are logged-in
IMPT.start_task('idle', idle_logout())
- # Populate xfp/xpub values, if missing.
+ # Populate wallet metadata, if missing.
# - can happen for first-time login of duress wallet
# - may indicate lost settings, which we can easily recover from
# - these values are important to USB protocol
- if not (settings.get('xfp', 0) and settings.get('xpub', 0)) and not pa.is_secret_blank():
+ if not (settings.get('xfp', 0) and settings.get('xpub', 0)
+ and settings.get('c32') is not None) and not pa.is_secret_blank():
try:
import stash
@@ -1472,6 +1483,113 @@ def contains_xprv(fname):
await import_extended_key_as_secret(extended_key, ephemeral, origin='Imported XPRV')
# not reached; will do reset.
+async def codex32_calculate_checksum(*a):
+ from codex32 import Share
+
+ if not await ux_confirm('Enter the Codex32 header and payload, without its checksum.\n\n'
+ 'Calculating a checksum cannot detect existing transcription mistakes.',
+ title='Calculate Checksum' if version.has_qwerty else 'Calc Checksum'):
+ return
+
+ value = ""
+ while True:
+ value = await ux_enter_codex32(value, with_checksum=False)
+ if not value: break
+ try:
+ share = Share.from_body(value)
+ except Exception as exc:
+ await ux_show_story('Invalid Codex32 header or payload.\n\n%s' % exc,
+ title='FAILED')
+ continue
+
+ intro = 'Checksum:\n\n%s\n\nCodex32:\n\n' % share.checksum().upper()
+ await show_shamir_share(share.to_string(), share.uid, intro=intro)
+ break
+
+async def import_codex32_as_secret(value, ephemeral, origin=None):
+ from codex32 import Share
+ import seed
+
+ try:
+ share = Share.parse(value.strip().replace(' ', ''))
+ except Exception as exc:
+ await ux_show_story('Unable to parse Codex32 share.\n\n%s' % exc,
+ title='FAILED')
+ return
+
+ try:
+ assert share.is_secret_share(), "Need secret share S. Use Shamir Recover"
+ encoded = seed.SecretStash.encode(codex32=share)
+ if encoded[0] == 0x01: # CX1: raw XPRV key material
+ # Validate and wipe a copy, preserving encoded for activation.
+ with seed.SensitiveValues(secret=bytearray(encoded)) as sv:
+ sv.register(sv.secret)
+
+ if ephemeral:
+ await seed.set_ephemeral_seed(encoded, origin=origin or 'Imported Codex32')
+ else:
+ seed.set_seed_value(encoded=encoded)
+ goto_top_menu(first_time=not ephemeral)
+
+ except Exception as exc:
+ await ux_show_story('Failed to import.\n\n%s\n%s' % (exc, problem_file_line(exc)),
+ title='FAILED')
+
+async def codex32_from_file(choice):
+ def contains_codex32(fname):
+ try:
+ with open(fname, 'rt') as fd:
+ return any(
+ line.strip().replace(' ', '')[:3].lower() in ('ms1', 'cx1', 'cw1')
+ for line in fd
+ )
+ except OSError:
+ return False
+
+ fn = await file_picker(suffix='.txt', min_size=48, max_size=512,
+ taster=contains_codex32,
+ none_msg='Must contain Codex32.', **choice)
+ if not fn:
+ return
+
+ try:
+ with CardSlot(readonly=True, **choice):
+ with open(fn, 'rt') as fd:
+ for line in fd:
+ value = line.strip().replace(' ', '')
+ if value[:3].lower() in ('ms1', 'cx1', 'cw1'):
+ return value
+ except CardMissingError:
+ await needs_microsd()
+
+async def import_codex32_share(intro=None, title=None, input_value=''):
+ from glob import NFC
+
+ choice = await import_export_prompt('Codex32 share', is_import=True, intro=intro or '',
+ title=title, key0='to enter manually',
+ force_prompt=True)
+ if choice == KEY_CANCEL:
+ return False
+ if choice == KEY_NFC:
+ return await NFC.read_codex32()
+ if choice == KEY_QR:
+ from ux_q1 import QRScannerInteraction
+ return await QRScannerInteraction().scan_codex32('Scan Codex32 share')
+ if choice == '0':
+ return await ux_enter_codex32(value=input_value)
+
+ return await codex32_from_file(choice)
+
+async def import_codex32(_1, _2, item):
+ ephemeral = item.arg
+ if not ephemeral:
+ assert not pa.has_secrets()
+
+ value = await import_codex32_share()
+ if not value: return
+
+ await import_codex32_as_secret(value, ephemeral, 'Imported Codex32')
+
async def need_clear_seed(*a):
await ux_show_story('''\
You must clear the wallet seed before restoring a backup because it replaces \
@@ -2566,4 +2684,286 @@ async def edit_custom(menu, picked, xx_self):
return MenuSystem(choices, chosen=cur)
+async def shamir_share_story(menu, label, item):
+ await show_shamir_share(*item.arg)
+
+async def show_shamir_share(value, uid, intro=None):
+ from glob import NFC, dis
+ from seed import render_codex32
+
+ index = value[8]
+ name = "Share '%s'" % index
+ intro = (intro or '') + render_codex32(value)
+ while True:
+ choice = await import_export_prompt(name, title=name, intro=intro,
+ sensitive=True)
+ if choice == KEY_CANCEL: return
+
+ if choice == KEY_QR:
+ await show_qr_code(value, is_alnum=True, msg=name, is_secret=True)
+
+ elif choice == KEY_NFC:
+ await NFC.share_text(value, prompt=name, is_secret=True)
+
+ else:
+ from msgsign import write_sig_file
+ try:
+ dis.fullscreen('Saving...')
+ with CardSlot(**choice) as card:
+ fname, nice = card.pick_filename('%s_share_%s.txt' % (uid, index))
+ with open(fname, 'wt') as fd:
+ fd.write(value)
+
+ signature = ''
+ if pa.has_secrets():
+ digest = ngu.hash.sha256s(value.encode())
+ signature = '\n\nSignature:\n\n' + write_sig_file([(digest, fname)])
+
+ await ux_show_story('%s written:\n\n%s%s' % (name, nice, signature))
+
+ except CardMissingError:
+ await needs_microsd()
+ except Exception as exc:
+ await ux_show_story('Failed to write.\n\n%s\n%s' % (exc, problem_file_line(exc)))
+
+async def codex32_shamir_split(*a):
+ import ngu, stash
+ from codex32 import MS_HRP, CX_HRP, CW_HRP, CHARSET, IDX_ORDER, SECRET, Share, generate_share
+ from glob import dis
+
+ words = settings.get('words', True)
+ if words or not settings.get('c32', False):
+ hrp = 'CW1' if words else 'CX1'
+ intro = ("This split will use %s, COLDCARD's extension to Codex32.\n\n"
+ "To recover, you'll need COLDCARD or software that explicitly"
+ " supports %s.\n\n") % (hrp, hrp)
+ if words:
+ recovery = ("Recovery restores your original English BIP-39 seed words. Any"
+ " BIP-39 passphrase must be backed up separately and entered"
+ " after recovery.")
+ elif stash.bip39_passphrase:
+ recovery = ("Recovery restores your current passphrase wallet's keys, not your"
+ " seed words or passphrase. The passphrase is not needed for"
+ " recovery and cannot be changed on the recovered wallet.")
+ else:
+ recovery = ("Recovery restores an extended-key wallet, not seed words. You"
+ " cannot apply a BIP-39 passphrase to the recovered wallet.")
+ if not await ux_confirm(intro + recovery, title='WARNING'):
+ return
+
+ msg = ("Split the current wallet using Codex32 Shamir sharing. Each split uses fresh"
+ " randomness and a new ID. Fewer than the threshold shares reveal no information"
+ " about the secret.")
+ tmp = None
+ if pa.tmp_value:
+ tmp = 'BIP-39 passphrase' if stash.bip39_passphrase else 'temporary seed'
+ if tmp:
+ msg = ("WARNING: The split will use the wallet derived from the active %s.\n\n" % tmp) + msg
+
+ if not await ux_confirm(msg, title='Shamir Split'):
+ return
+
+ # max split is 9, even tho BIP-93 allows more shares (31) - artificial Coldcard limit
+ # max threshold is 9 as per BIP-93
+ # BIP-93 permits schemes where:
+ # 2 ≤ k ≤ 9
+ # k ≤ n ≤ 31
+ count = await ux_enter_number('Number of shares (2-9):', 9, can_cancel=True)
+ if count is None: return # canceled
+
+ if count < 2:
+ await ux_show_story('Number of shares must be at least 2.', title='FAILED')
+ return
+
+ threshold = await ux_enter_number('Threshold (2-%d):' % count, 9, can_cancel=True)
+ if threshold is None: return # canceled
+
+ if (threshold < 2) or (threshold > count):
+ await ux_show_story('Threshold must be between 2 and %d.' % count, title='FAILED')
+ return
+
+ if (threshold == count) and not await ux_confirm('N-of-N has no redundancy. Consider'
+ ' a lower threshold.', title='WARNING'):
+ return
+
+ dis.fullscreen('Generating...')
+ dis.busy_bar(True)
+ try:
+ uid = ''.join(CHARSET[b & 31] for b in ngu.random.bytes(4))
+ with stash.SensitiveValues(enforce_delta=True) as sv:
+ if sv.mode == 'words':
+ secret_share = Share.from_seed(sv.raw, CW_HRP, uid, SECRET, threshold)
+ elif sv.mode == 'master':
+ assert len(sv.raw) in (16, 32, 64), 'MS1 requires a 128, 256 or 512-bit master seed.'
+ secret_share = Share.from_seed(sv.raw, MS_HRP, uid, SECRET, threshold)
+ else:
+ # CX1 - root key - stripped from metadata
+ secret = sv.node.chain_code() + sv.node.privkey()
+ sv.register(secret)
+ secret_share = Share.from_seed(secret, CX_HRP, uid, SECRET, threshold)
+
+ basis = [secret_share]
+ shares = []
+ for pos in range(1, threshold):
+ index = IDX_ORDER[pos]
+ payload = ''.join(CHARSET[b & 31]
+ for b in ngu.random.bytes(len(secret_share.payload)))
+ share = Share(secret_share.hrp, uid, payload, index, threshold)
+ basis.append(share)
+ shares.append(share.to_string())
+
+ for pos in range(threshold, count + 1):
+ shares.append(generate_share(basis, IDX_ORDER[pos]).to_string())
+
+ # menu for exporting individual shares
+ items = [MenuItem('%d of %d [%s]' % (threshold, len(shares), uid.upper()))]
+ items.extend(MenuItem("Share '%s'" % value[8], f=shamir_share_story, arg=(value, uid))
+ for value in shares)
+ submenu = MenuSystem(items)
+
+ except Exception as exc:
+ dis.busy_bar(False)
+ await ux_show_story('Failed to split.\n\n%s\n%s' %
+ (exc, problem_file_line(exc)), title='FAILED')
+ return
+ finally:
+ dis.busy_bar(False)
+
+ await ux_show_story('Keep threshold-or-more shares on separate devices.\n\n'
+ 'Storing a threshold number of shares on one medium is'
+ ' equivalent to storing your seed there in plaintext.',
+ title='WARNING')
+
+ while True:
+ the_ux.push(submenu)
+ await submenu.interact()
+ if await ux_confirm('This split uses fresh randomness. COLDCARD cannot recreate'
+ ' these shares later.\n\nMake sure you exported all shares.'
+ '\n\nExit anyway?', title='DISCARD?'):
+ return
+
+async def codex32_shamir_recover(menu, label, item):
+ from codex32 import SECRET, generate_share
+ from glob import dis
+
+ ephemeral = item.arg
+ if not ephemeral:
+ assert not pa.has_secrets()
+ else:
+ if not await ux_confirm('The recovered Codex32 seed will be temporary'
+ ' and will not be saved to the Secure Element.',
+ title='WARNING'):
+ return
+
+ shares = await collect_codex32_shares('Shamir Recover')
+ if not shares: return
+
+ dis.fullscreen('Recovering...')
+ try:
+ recovered = generate_share(shares, SECRET)
+ await import_codex32_as_secret(recovered.to_string(), ephemeral, 'Recovered Codex32')
+ except Exception as exc:
+ await ux_show_story('Failed to recover.\n\n%s' % exc, title='FAILED')
+
+async def collect_codex32_shares(title):
+ from codex32 import Share
+
+ if not await ux_confirm('Import shares from one Codex32 set. Their HRP, ID, threshold and '
+ 'length must match. Order does not matter.', title=title):
+ return
+
+ expected = None
+ shares = {Share.parse(s) for s in settings.master_get('c32_shares', [])}
+ if shares:
+ first = next(iter(shares))
+ expected = (first.hrp, first.uid, first.threshold, len(first))
+
+ while expected is None or len(shares) < expected[2]:
+ intro = 'Collected: %d\nThreshold: %s\nID: %s\nHRP: %s' % (
+ len(shares),
+ expected[2] if expected else '?',
+ expected[1].upper() if expected else '?',
+ expected[0].upper() if expected else '?'
+ )
+
+ prefix = ''
+ if expected:
+ # pre-fill the part that we already know (hrp+threshold+id)
+ prefix = (expected[0] + '1' + str(expected[2]) + expected[1]).upper()
+
+ value = await import_codex32_share(intro, title, prefix)
+ if value is False:
+ if not shares: return
+ msg = 'Discard collected shares?\n\nPress (1) to Save & Exit.'
+ if pa.is_secret_blank():
+ msg += ('\n\nWARNING: Without a master wallet, saved shares will not be'
+ ' protected by encryption.')
+ ch = await ux_show_story(msg, escape='1')
+ if ch not in "1y": continue
+ settings.master_set('c32_shares', [s.to_string() for s in shares] if ch == "1" else [])
+ return
+
+ if not value:
+ continue
+
+ try:
+ share = Share.parse(value.strip().replace(' ', ''))
+ except Exception as exc:
+ await ux_show_story('Unable to parse Codex32 share.\n\n%s' % exc,
+ title='FAILED')
+ continue
+ if share.is_secret_share():
+ await ux_show_story("Use 'Import Codex32' for secret share 's'.",
+ title='FAILED')
+ continue
+ details = (share.hrp, share.uid, share.threshold, len(share))
+ if expected and details != expected:
+ await ux_show_story('Share set does not match the first share.',
+ title='FAILED')
+ continue
+ if any(s.index == share.index for s in shares):
+ await ux_show_story('That share index was already collected.', title='FAILED')
+ continue
+
+ # success - add to share set
+ expected = details
+ shares.add(share)
+
+ settings.master_set('c32_shares', [])
+ return list(shares)
+
+async def codex32_derive_shares(*a):
+ from codex32 import IDX_ORDER, generate_share
+
+ if not await ux_confirm('Import a threshold number of shares from one Codex32 set. Then choose'
+ ' additional share indices to interpolate, view and export. These shares'
+ ' can be used with the original shares for Shamir Recover.'
+ '\n\nWARNING: This device will receive enough shares to reconstruct the'
+ " secret share 'S' and recover the combined wallet." +
+ ("\n\nYour active wallet will remain unchanged." if pa.has_secrets() else ""),
+ title="WARNING"):
+ return
+
+ shares = await collect_codex32_shares('Derive Shares')
+ if not shares: return
+
+ async def derive(menu, label, item):
+ value = generate_share(shares, item.arg).to_string()
+ await show_shamir_share(value, shares[0].uid)
+
+ used = {s.index for s in shares}
+ first = shares[0]
+ items = [MenuItem('%d required [%s]' % (first.threshold, first.uid.upper()))]
+ items.extend(MenuItem("Share '%s'" % index.upper(), f=derive, arg=index)
+ for index in IDX_ORDER[1:10] if index not in used)
+ submenu = MenuSystem(items)
+ try:
+ while True:
+ the_ux.push(submenu)
+ await submenu.interact()
+ if await ux_confirm('Exit and discard collected shares?', title='DISCARD?'):
+ return
+ finally:
+ shares.clear()
+
# EOF
### shared/backups.py
@@ -90,6 +90,8 @@ def ADD(key, val):
if k == 'bkpw': continue # confusing/circular
if k == 'sd2fa': continue # do NOT backup SD 2FA (card can be lost or damaged)
if k == 'words': continue # words length is recalculated from secret
+ if k == 'c32': continue # recalculated from secret, like words
+ if k == 'c32_shares': continue # local recovery state, possibly for another wallet
if k == 'ccc': continue # not supported, security issue
if k == 'ktrx': continue # not useful after the fact
if k == 'lfr': continue # temporary error msg value
@@ -204,6 +206,16 @@ def restore_from_dict_ll(vals, raw):
# (would allow replay attacks)
continue
+ if k == 'c32':
+ # recalculated from raw_secret by pa.new_main_secret above; a
+ # restored value could be stale or crafted (like words/bkpw)
+ continue
+
+ if k == 'c32_shares':
+ # Pending recovery shares must not travel with wallet backups,
+ # including older or crafted files which contain this field.
+ continue
+
if k == 'tp':
# restore trick pins, which may involve many ops
from trick_pins import tp
### shared/codex32.py
@@ -0,0 +1,261 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+#
+# BIP-93 Codex32 checksum and Shamir interpolation.
+#
+
+CODEX32_CONST = 0x10ce0795c2fd1e62a
+CODEX32_LONG_CONST = 0x43381e570bf4798ab26
+CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+
+MS_HRP = "ms"
+CW_HRP = "cw" # COLDCARD extension: English BIP-39 entropy
+CX_HRP = "cx" # COLDCARD extension: chaincode + private key
+SEPARATOR = "1"
+SECRET = "s"
+SECRET_ID = "seed" # Fixed ID for standalone master-seed backups
+UX_CHARSET = CHARSET + " " + SEPARATOR
+IDX_ORDER = "sacdefghjklmnpqrtuvwxyz023456789"
+
+def bech32_hrp_expand(s):
+ return [ord(x) >> 5 for x in s] + [0] + [ord(x) & 31 for x in s]
+
+def codex32_polymod(values):
+ generators = [
+ 0x19dc500ce73fde210, 0x1bfae00def77fe529, 0x1fbd920fffe7bee52,
+ 0x1739640bdeee3fdad, 0x07729a039cfc75f5a,
+ ]
+ residue = 1
+ for value in values:
+ top = residue >> 60
+ residue = ((residue & 0x0fffffffffffffff) << 5) ^ value
+ for i in range(5):
+ if (top >> i) & 1:
+ residue ^= generators[i]
+ return residue
+
+def codex32_long_polymod(values):
+ generators = [
+ 0x3d59d273535ea62d897, 0x7a9becb6361c6c51507, 0x543f9b7e6c38d8a2a0e,
+ 0x0c577eaeccf1990d13c, 0x1887f74f8dc71b10651,
+ ]
+ residue = 1
+ for value in values:
+ top = residue >> 70
+ residue = ((residue & 0x3fffffffffffffffff) << 5) ^ value
+ for i in range(5):
+ if (top >> i) & 1:
+ residue ^= generators[i]
+ return residue
+
+# BIP-93 counts the expanded HRP and the complete data part, including checksum.
+def codex32_verify_checksum(hrp, data):
+ values = bech32_hrp_expand(hrp) + data
+ if 96 <= len(values) <= 1023:
+ return codex32_long_polymod(values) == CODEX32_LONG_CONST
+ if len(values) <= 93:
+ return codex32_polymod(values) == CODEX32_CONST
+ return False
+
+def codex32_create_checksum(hrp, data):
+ values = bech32_hrp_expand(hrp) + data
+ if len(values) + 13 > 93:
+ polymod = codex32_long_polymod(values + ([0] * 15)) ^ CODEX32_LONG_CONST
+ return [(polymod >> (5 * (14 - i))) & 31 for i in range(15)]
+
+ polymod = codex32_polymod(values + ([0] * 13)) ^ CODEX32_CONST
+ return [(polymod >> (5 * (12 - i))) & 31 for i in range(13)]
+
+
+BECH32_INV = [
+ 0, 1, 20, 24, 10, 8, 12, 29, 5, 11, 4, 9, 6, 28, 26, 31,
+ 22, 18, 17, 23, 2, 25, 16, 19, 3, 21, 14, 30, 13, 7, 27, 15,
+]
+
+def bech32_mul(a, b):
+ result = 0
+ for i in range(5):
+ if (b >> i) & 1:
+ result ^= a
+ a *= 2
+ if a >= 32:
+ a ^= 41
+ return result
+
+def bech32_lagrange(indices, target):
+ numerator = 1
+ coefficients = []
+ for i in indices:
+ numerator = bech32_mul(numerator, i ^ target)
+ denominator = 1
+ for j in indices:
+ denominator = bech32_mul(denominator, (target if i == j else i) ^ j)
+ coefficients.append(denominator)
+ return [bech32_mul(numerator, BECH32_INV[i]) for i in coefficients]
+
+def codex32_interpolate(shares, target):
+ weights = bech32_lagrange([share[5] for share in shares], target)
+ result = []
+ for i in range(len(shares[0])):
+ value = 0
+ for j in range(len(shares)):
+ value ^= bech32_mul(weights[j], shares[j][i])
+ result.append(value)
+ return result
+
+def bech32_to_array(value):
+ return [CHARSET.index(ch) for ch in value.lower()]
+
+def array_to_bech32(values):
+ return "".join(CHARSET[value] for value in values)
+
+def convertbits(data, frombits, tobits, pad=True, pad_val=0):
+ accumulator = 0
+ bits = 0
+ result = []
+ max_value = (1 << tobits) - 1
+ max_accumulator = (1 << (frombits + tobits - 1)) - 1
+
+ for value in data:
+ assert not (value < 0 or value >> frombits) #, "invalid value"
+ accumulator = ((accumulator << frombits) | value) & max_accumulator
+ bits += frombits
+ while bits >= tobits:
+ bits -= tobits
+ result.append((accumulator >> bits) & max_value)
+ accumulator &= (1 << bits) - 1
+
+ if pad and bits:
+ pad_len = tobits - bits
+ assert 0 <= pad_val < (1 << pad_len) #, "invalid padding"
+ result.append(((accumulator << pad_len) | pad_val) & max_value)
+ else:
+ assert bits < frombits #, "invalid padding"
+
+ return result
+
+def _normalize_case(encoded):
+ assert encoded.lower() == encoded or encoded.upper() == encoded, "mixed case"
+ return encoded.lower()
+
+class Share:
+ def __init__(self, hrp, uid, payload, index, threshold):
+ hrp = hrp.lower()
+ uid = uid.lower()
+ payload = payload.lower()
+ index = index.lower()
+
+ assert hrp in (MS_HRP, CX_HRP, CW_HRP), "unsupported HRP"
+ assert len(uid) == 4 and all(ch in CHARSET for ch in uid), "invalid identifier"
+ assert len(index) == 1 and index in CHARSET, "invalid share index"
+ assert payload and all(ch in CHARSET for ch in payload), "invalid payload"
+ if threshold == 0:
+ assert index == SECRET, "non-secret share with threshold 0"
+ else:
+ assert 1 < threshold < 10, "threshold %d out of bounds" % threshold
+ assert (len(payload) * 5) % 8 <= 4, "incomplete group"
+
+ self.hrp = hrp
+ self.uid = uid
+ self.payload = payload
+ self.index = index
+ self.threshold = threshold
+
+ def __eq__(self, other):
+ return isinstance(other, Share) and self.hrp == other.hrp and self.data() == other.data()
+
+ def __hash__(self):
+ return hash(self.hrp + self.data())
+
+ def __len__(self):
+ # Two-character HRP: 93 - 5 expanded HRP - 6 header - 13 checksum.
+ return 9 + len(self.payload) + (15 if len(self.payload) > 69 else 13)
+
+ def is_secret_share(self):
+ return self.index == SECRET
+
+ @classmethod
+ def from_body(cls, encoded):
+ # Header + payload only. Keep every payload symbol, including padding.
+ encoded = _normalize_case(encoded)
+ hrp, data = encoded.split(SEPARATOR)
+ checksum = array_to_bech32(codex32_create_checksum(hrp, bech32_to_array(data)))
+ return cls.parse(encoded + checksum)
+
+ @classmethod
+ def parse(cls, encoded):
+ encoded = _normalize_case(encoded)
+ assert encoded[:3] in (MS_HRP + SEPARATOR, CX_HRP + SEPARATOR, CW_HRP + SEPARATOR), "unsupported HRP"
+
+ parts = encoded.split(SEPARATOR)
+ assert len(parts) == 2, "invalid separator"
+ hrp, data_and_checksum = parts
+
+ if hrp == MS_HRP:
+ assert len(encoded) in (48, 74, 127), "ms codex32 length"
+ elif hrp == CW_HRP:
+ assert len(encoded) in (48, 61, 74), "cw codex32 length"
+ else:
+ assert len(encoded) == 127, "cx codex32 length"
+
+ threshold = data_and_checksum[0]
+ assert threshold in "023456789", "invalid threshold"
+ data = bech32_to_array(data_and_checksum)
+ assert codex32_verify_checksum(hrp, data), "incorrect checksum"
+
+ # All supported HRPs expand to five values.
+ checksum_len = 13 if 5 + len(data_and_checksum) <= 93 else 15
+ body = data_and_checksum[:-checksum_len]
+ return cls(hrp, body[1:5], body[6:], body[5], int(threshold))
+
+ @classmethod
+ def from_seed(cls, seed, hrp, uid, idx, thres, pad_val=None):
+ if pad_val is None:
+ assert idx.lower() == SECRET, "padding required for non-secret share"
+ pad_val = 0
+ payload = array_to_bech32(convertbits(seed, 8, 5, True, pad_val))
+ return cls(hrp, uid, payload, idx, thres)
+
+ def to_seed_and_pad(self):
+ values = bech32_to_array(self.payload)
+ seed = bytes(convertbits(values, 5, 8, False))
+ pad_len = (len(values) * 5) - (len(seed) * 8)
+ pad_val = values[-1] & ((1 << pad_len) - 1) if pad_len else 0
+ return seed, pad_val
+
+ def data(self):
+ return str(self.threshold) + self.uid + self.index + self.payload
+
+ def data_values(self):
+ return bech32_to_array(self.data())
+
+ def checksum(self):
+ return array_to_bech32(codex32_create_checksum(self.hrp, self.data_values()))
+
+ def to_string(self, upper=True):
+ value = self.hrp + SEPARATOR + self.data() + self.checksum()
+ return value.upper() if upper else value
+
+def generate_share(shares, share_index):
+ assert shares, "no shares"
+ share_index = share_index.lower()
+ assert len(share_index) == 1 and share_index in CHARSET, "invalid share index"
+
+ first = shares[0]
+ indexes = set()
+ for share in shares:
+ assert share.hrp == first.hrp, "hrp not same"
+ assert share.uid == first.uid, "id not same"
+ assert share.threshold == first.threshold, "threshold not same"
+ assert len(share.payload) == len(first.payload), "length not same"
+ indexes.add(share.index)
+
+ assert len(shares) == len(indexes), "indexes not unique"
+ assert share_index not in indexes, "index already taken"
+ assert len(shares) == first.threshold, "need exactly %d shares" % first.threshold
+
+ data = [share.data_values() for share in shares]
+ result = codex32_interpolate(data, CHARSET.index(share_index))
+ return Share(first.hrp, first.uid, array_to_bech32(result[6:]),
+ share_index, first.threshold)
+
+# EOF
### shared/decoders.py
@@ -68,6 +68,15 @@ def decode_secret(got):
return 'xprv', got
+ codex = got.strip().replace(' ', '')
+ if codex[:3].lower() in ('ms1', 'cx1', 'cw1'):
+ from codex32 import Share
+ try:
+ Share.parse(codex)
+ except Exception:
+ raise ValueError('corrupt Codex32?')
+ return 'codex32', codex.lower()
+
if len(got) in (51, 52):
try:
from wif import decode_wif
### shared/flow.py
@@ -10,7 +10,7 @@
from choosers import *
from mk4 import dev_enable_repl
from multisig import make_multisig_menu, import_multisig_nfc
-from seed import make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw
+from seed import make_codex32_menu, make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw
from address_explorer import address_explore
from drv_entro import drv_entro_start, password_entry
from backups import clone_start, clone_write_data
@@ -329,8 +329,9 @@ async def goto_home(*a):
]
SeedFunctionsMenu = [
- MenuItem('View Seed Words', f=view_seed_words), # text is a little wrong sometimes, rare
+ MenuItem('View Secret', f=view_seed_words),
MenuItem('Seed XOR', menu=SeedXORMenu),
+ MenuItem('Shamir Split', f=codex32_shamir_split),
MenuItem("Destroy Seed", f=clear_seed, predicate=has_real_secret),
MenuItem('Lock Down Seed', f=convert_ephemeral_to_master, predicate=is_tmp),
MenuItem('Export SeedQR', f=export_seedqr, predicate=word_based_seed),
@@ -473,6 +474,7 @@ async def goto_home(*a):
EmptyWallet = [
# xxxxxxxxxxxxxxxx
MenuItem('New Seed Words', menu=NewSeedMenu),
+ MenuItem('Codex32', menu=make_codex32_menu, arg=False),
MenuItem('Import Existing', menu=ImportWallet),
MenuItem("Migrate Coldcard", menu=clone_start),
MenuItem("Key Teleport (start)", f=kt_start_rx, predicate=version.has_qr),
### shared/manifest.py
@@ -12,6 +12,7 @@
'chains.py',
'choosers.py',
'compat7z.py',
+ 'codex32.py',
'countdowns.py',
'descriptor.py',
'drv_entro.py',
### shared/nfc.py
@@ -764,6 +764,14 @@ async def read_tapsigner_b64_backup(self):
f = lambda x: a2b_base64(x.decode()) if 150 <= len(x) <= 280 else None
return await self._nfc_reader(f, 'Unable to find base64 encoded TAPSIGNER backup.')
+ async def read_codex32(self):
+ def decode(msg):
+ value = msg.decode().strip().replace(' ', '')
+ if value[:3].lower() in ('ms1', 'cx1', 'cw1'):
+ return value
+
+ return await self._nfc_reader(decode, 'Unable to find Codex32.')
+
async def read_bip322_msg(self):
f = lambda x: x.decode()
return await self._nfc_reader(f, 'Unable to find BIP-322 message.')
### shared/nvstore.py
@@ -26,6 +26,8 @@
# xfp = master xpub's fingerprint (32 bit unsigned)
# xpub = master xpub in base58
# chain = 3-letter codename for chain we are working on (BTC)
+# c32 = (bool) active secret is a raw BIP-32 master seed, not words or XPRV
+# c32_shares = (list of strings) pending Codex32 shares, stored in master or seedless settings
# words = {0/12/18/24} nummber of BIP-39 seed words exist (default: 24, 0=XPRV, etc)
# b39skip = (bool) skip discussion about use of BIP-39 passphrase
# idle_to = idle timeout period (seconds)
@@ -96,7 +98,7 @@
# key value pairs saved directly to master seed settings
# held in RAM for tmp seed sessions
-MASTER_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw", "sssp", "tsn"]
+MASTER_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw", "sssp", "tsn", 'c32_shares']
NUM_SLOTS = const(100)
SLOTS = range(NUM_SLOTS)
### shared/seed.py
@@ -13,7 +13,7 @@
import ngu, uctypes, bip39, random, version
from ucollections import OrderedDict
from menu import MenuItem, MenuSystem
-from utils import xfp2str, parse_extended_key, swab32
+from utils import xfp2str, parse_extended_key, swab32, chunk_address
from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
from utils import to_ascii_printable
from uhashlib import sha256
@@ -433,7 +433,7 @@ async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False)
return ch
-async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
+async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False, nbits=None):
from ux import ux_dice_rolling
low_entropy_msg = "You only provided %d dice rolls, and each roll adds only 2.585 bits of entropy."
@@ -443,13 +443,9 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
low_entropy_msg += ", which is considered the minimum for %d word seeds," % nwords
low_entropy_msg += " you need at least %d rolls."
- # None is for paper wallet private key - as it is 32 bytes of entropy we need 99 D6
- if nwords in (24, None):
- threshold = 99
- sec_bit = 256
- else:
- threshold = 50
- sec_bit = 128
+ # Default to 256 bits for paper wallet private keys (nwords=None).
+ sec_bit = nbits or (256 if nwords in (24, None) else 128)
+ threshold = 99 if sec_bit > 128 else 50
counter = {}
md = sha256(seed)
@@ -659,6 +655,144 @@ def generate_seed():
# hash to combine the sources and mitigate any possible bias
return ngu.hash.sha256d(seed + a + b)
+def render_codex32(c32):
+ parts = chunk_address(c32)
+ if not version.has_qwerty:
+ return '\n'.join('%2d: %s' % (i+1, part)
+ for i, part in enumerate(parts))
+
+ wide = len(parts) > 24
+ widths, separator = ((6, 7, 7, 7), ' ') if wide else ((11, 11, 11), ' ')
+ rows = (len(parts) + len(widths) - 1) // len(widths)
+
+ lines = []
+ for row in range(rows):
+ cells = []
+ for col, pos in enumerate(range(row, len(parts), rows)):
+ number = str(pos + 1) if wide and col == 0 else '%2d' % (pos + 1)
+ cell = '%s:%s' % (number, parts[pos])
+ cells.append(cell + (' ' * (widths[col] - len(cell))))
+
+ lines.append(separator.join(cells).rstrip())
+
+ return '\n'.join(lines)
+
+async def show_codex32(c32, ephemeral=False, is_new=True):
+ from glob import NFC
+
+ title = 'Record Codex32' if is_new else 'Codex32 Share'
+ msg = render_codex32(c32)
+ escape = ''
+
+ if is_new and ephemeral:
+ escape += '6'
+ msg += '\n\nPress (6) to skip the verification.'
+
+ if not version.has_qwerty:
+ escape += '1'
+ msg += '\n\nPress (1) to view as QR.'
+ if NFC:
+ escape += '3'
+ msg += ' Press (3) to share via NFC.'
+
+ while True:
+ ch = await ux_show_story(msg, title=title if version.has_qwerty else None,
+ escape=escape, sensitive=True,
+ hint_icons=KEY_QR + (KEY_NFC if NFC else ''))
+ if ch in ('1' + KEY_QR):
+ await show_qr_code(c32, True, is_secret=True)
+ elif NFC and ch in ('3' + KEY_NFC):
+ await NFC.share_text(c32, is_secret=True)
+ else:
+ return ch
+
+async def codex32_quiz(c32):
+ from codex32 import CHARSET
+
+ alphabet = CHARSET.upper()
+ parts = chunk_address(c32)
+ order = list(range(len(parts)))
+ random.shuffle(order)
+
+ for pos in order:
+ right = parts[pos]
+ choices = [right]
+ while len(choices) < 3:
+ value = ''.join(alphabet[random.randbelow(len(alphabet))]
+ for _ in right)
+ if value not in choices:
+ choices.append(value)
+
+ while True:
+ random.shuffle(choices)
+ msg = '' if not dis.has_lcd else '\n'
+ msg += '\n'.join(' %d: %s' % (i+1, choices[i]) for i in range(3))
+ msg += '\n\nWhich group is right?\n\n%s to give up, %s to see all the groups again.' % (X, OK)
+
+ ch = await ux_show_story(msg, title='Group %d is?' % (pos+1),
+ escape='123', sensitive=True)
+ if ch == 'x':
+ return ch
+ if ch == 'y':
+ await ux_show_story(render_codex32(c32), sensitive=True)
+ continue
+ if ch not in '123':
+ continue
+ if choices[ord(ch) - ord('1')] == right:
+ break
+
+ await ux_dramatic_pause('Wrong!', 2)
+
+async def approve_codex32(seed, ephemeral=False):
+ from codex32 import MS_HRP, SECRET, SECRET_ID, Share
+
+ share = Share.from_seed(seed, MS_HRP, SECRET_ID, SECRET, 0)
+ encoded = share.to_string()
+ while True:
+ ch = await show_codex32(encoded, ephemeral=ephemeral)
+ if ch == 'x':
+ if await ux_confirm('Throw away this secret and stop?'):
+ return
+ continue
+
+ if ch == '6' and ephemeral:
+ if await ux_confirm('Skip verification of the recorded Codex32 share?'):
+ return share
+ continue
+
+ if await codex32_quiz(encoded) == 'x':
+ if await ux_confirm('Throw away this secret and stop?'):
+ return
+ continue
+
+ return share
+
+async def make_new_codex32_wallet(byte_length, ephemeral=False, dice=False):
+ if dice:
+ prompt = '\n\nPress %s to continue, %s to exit.' % (OK, X)
+ if not await ux_confirm(DICE_ONLY_WARNING + prompt, title='WARNING'):
+ return
+ count, seed = await add_dice_rolls(0, b'', True, enforce=True, nbits=byte_length * 8)
+ if not count:
+ return
+ else:
+ purpose = PURPOSE_EPHEMERAL if ephemeral else PURPOSE_MASTER
+ seed = await generate_seed_with_user_entropy(purpose)
+ if seed is None:
+ return
+ seed = seed[:byte_length]
+
+ share = await approve_codex32(seed, ephemeral)
+ if not share:
+ return
+
+ encoded = SecretStash.encode(master_secret=seed)
+ if ephemeral:
+ await set_ephemeral_seed(encoded, origin='Generated Codex32')
+ else:
+ set_seed_value(encoded=encoded)
+ goto_top_menu(first_time=not ephemeral)
+
def update_entropy_screen(title, count, target, unit, action, prompt, mk_title=None):
# progress display while collecting user entropy
if version.has_qwerty:
@@ -975,7 +1109,6 @@ def xprv_to_encoded_secret(xprv):
node.blank()
return nv, chain # need to know chain
-
def set_seed_value(words=None, encoded=None, chain=None):
# Save the seed words (or other encoded private key) into secure element.
# BIP-39 passphrase is not set at this point (empty string).
@@ -1238,7 +1371,7 @@ async def _remove(menu, label, item):
async def _detail(menu, label, item):
rec, encoded = item.arg
- # - first byte represents type of secret (internal encoding flags)
+ # First byte represents the stored secret type.
txt = SecretStash.summary(encoded[0])
detail = "Name:\n%s\n\nMaster XFP: %s\nSecret Type: %s\n\nOrigin:\n%s\n\n" \
@@ -1459,6 +1592,7 @@ def construct(cls):
rv = [
MenuItem("Generate Words", menu=gen_ephemeral_menu, predicate=not_hobbled_mode),
+ MenuItem("Codex32", menu=make_codex32_menu, arg=True),
MenuItem('Import from QR Scan', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
MenuItem("Import Words", menu=import_ephemeral_menu),
@@ -1488,6 +1622,28 @@ async def make_ephemeral_seed_menu(*a):
rv = EphemeralSeedMenu.construct()
return EphemeralSeedMenu(rv)
+async def make_codex32_menu(menu, label, item):
+ from actions import codex32_shamir_recover, codex32_derive_shares, import_codex32, pick_new_codex32
+ from actions import codex32_calculate_checksum
+
+ ephemeral = bool(item.arg)
+ generated = [
+ MenuItem('128-bit', f=pick_new_codex32, arg=(16, ephemeral)),
+ MenuItem('256-bit', f=pick_new_codex32, arg=(32, ephemeral)),
+ MenuItem('Advanced', menu=[
+ MenuItem('128-bit Dice Roll', f=pick_new_codex32, arg=(16, ephemeral, True)),
+ MenuItem('256-bit Dice Roll', f=pick_new_codex32, arg=(32, ephemeral, True)),
+ ]),
+ ]
+ return MenuSystem([
+ MenuItem('Generate', menu=generated, predicate=not_hobbled_mode),
+ MenuItem('Import Codex32', f=import_codex32, arg=ephemeral),
+ MenuItem('Shamir Recover', f=codex32_shamir_recover, arg=ephemeral),
+ MenuItem('Derive Shares', f=codex32_derive_shares, predicate=not_hobbled_mode),
+ MenuItem('Calculate Checksum' if version.has_qwerty else 'Calc Checksum',
+ f=codex32_calculate_checksum),
+ ])
+
async def start_b39_pw(menu, label, item):
# Menu item for top-level "Passphrase" item - take in a BIP-39 passphrase
### shared/stash.py
@@ -61,10 +61,28 @@ class SecretStash:
# a raw master secret, and so on.
@staticmethod
- def encode(seed_phrase=None, master_secret=None, xprv=None):
+ def encode(seed_phrase=None, master_secret=None, xprv=None, codex32=None):
nv = bytearray(72) # AE_SECRET_LEN
- if seed_phrase:
+ if codex32 is not None:
+ from codex32 import CX_HRP, CW_HRP
+
+ assert codex32.is_secret_share()
+ seed, _ = codex32.to_seed_and_pad()
+ if codex32.hrp == CX_HRP:
+ assert len(seed) == 64
+ nv[0] = 1
+ nv[1:65] = seed
+ elif codex32.hrp == CW_HRP:
+ assert len(seed) in (16, 24, 32)
+ nv[0] = 0x80 | ((len(seed) // 8) - 2)
+ nv[1:1+len(seed)] = seed
+ else:
+ assert len(seed) in (16, 32, 64)
+ nv[0] = len(seed)
+ nv[1:1+len(seed)] = seed
+
+ elif seed_phrase:
# typical: packed version of memonic phrase
vlen = len(seed_phrase)
@@ -360,6 +378,8 @@ def capture_xpub(self):
if self.mode == 'words':
nw = len_to_numwords(len(self.raw))
settings.put('words', nw)
+ # Distinguish raw BIP-32 seeds from XPRVs without fetching the secret for menus.
+ settings.put('c32', self.mode == 'master')
return xfp
### shared/tapsigner.py
@@ -10,6 +10,7 @@
from files import CardSlot, CardMissingError, needs_microsd
from charcodes import KEY_NFC, KEY_QR, KEY_CANCEL
from actions import file_picker, import_extended_key_as_secret
+from utils import HEX_DIGITS
def decrypt_tapsigner_backup(backup_key, data):
try:
@@ -90,7 +91,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
return
while True:
- backup_key = await ux_input_text("", confirm_exit=False, hex_only=True,
+ backup_key = await ux_input_text("", confirm_exit=False, charset=HEX_DIGITS,
min_len=32, max_len=32,
prompt='Backup Password (32 hex digits)')
if backup_key is None:
### shared/teleport.py
@@ -4,7 +4,7 @@
# secure environment of two Q's.
#
import ngu, aes256ctr, bip39, json, ndef, chains, stash
-from utils import xfp2str, deserialize_secret, wipe_if_deltamode
+from utils import xfp2str, deserialize_secret, wipe_if_deltamode, HEX_DIGITS
from ubinascii import unhexlify as a2b_hex
from ubinascii import hexlify as b2a_hex
from glob import settings, dis
@@ -163,7 +163,7 @@ async def kt_start_send(rx_data):
while 1:
# - ask for the sender's password -- nearly any value will be accepted
- code = await ux_input_text('', confirm_exit=False, hex_only=True, max_len=8,
+ code = await ux_input_text('', confirm_exit=False, charset=HEX_DIGITS, max_len=8,
prompt='Teleport Password (number)', min_len=8, b39_complete=False, scan_ok=False,
placeholder='########', funct_keys=None, force_xy=None)
if not code: return
@@ -271,7 +271,7 @@ async def kt_decode_rx(is_psbt, payload):
while 1:
# ask for noid key
- pw = await ux_input_text('', confirm_exit=False, hex_only=False, max_len=8,
+ pw = await ux_input_text('', confirm_exit=False, max_len=8,
prompt=prompt, min_len=8, b39_complete=False, scan_ok=False,
placeholder='********', funct_keys=None, force_xy=None)
if not pw: return
@@ -538,13 +538,13 @@ def __init__(self, rx_pubkey):
msg = None
if is_tmp():
- # tmp seed, or maybe bip39 is in effect
- # - share the current master secret, not the real master
+ # Share the current secret, including any active BIP-39 passphrase.
msg = 'Temp Secret (words)' if word_based_seed() else (
- 'XPRV from Seed+Passphrase' if stash.bip39_passphrase else 'Temp XPRV Secret')
+ 'XPRV from Seed+Passphrase' if stash.bip39_passphrase else (
+ 'Temp Master Seed' if settings.get('c32') else 'Temp XPRV Secret'))
elif has_se_secrets():
- # sharing real master secret
- msg = 'Master Seed Words' if word_based_seed() else 'Master XPRV'
+ msg = 'Master Seed Words' if word_based_seed() else (
+ 'Master Seed Bytes' if settings.get('c32') else 'Master XPRV')
if msg:
m.append( MenuItem(msg, f=self.share_master_secret) )
### shared/utils.py
@@ -11,6 +11,7 @@
from public_constants import MAX_PATH_DEPTH, AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2TR
B2A = lambda x: str(b2a_hex(x), 'ascii')
+HEX_DIGITS = '0123456789abcdef'
try:
from font_iosevka import FontIosevka
### shared/ux.py
@@ -357,6 +357,14 @@ async def ux_enter_bip32_index(prompt, can_cancel=True, unlimited=False):
return await ux_enter_number(prompt=prompt, max_value=max_value, can_cancel=can_cancel)
+async def ux_enter_codex32(value='', scan_ok=True, with_checksum=True):
+ # Spaces may be inserted to group the text and are ignored on return.
+ from codex32 import UX_CHARSET
+ rv = await ux_input_text(value, charset=UX_CHARSET.upper(), confirm_exit=True,
+ prompt='Enter Codex32' if with_checksum else 'Header + payload',
+ scan_ok=scan_ok, max_len=191, min_len=48 if with_checksum else 35)
+ return rv.replace(' ', '') if rv else rv
+
def _import_prompt_builder(title, no_qr, no_nfc, slot_b_only=False, key0=None, key6=None):
from glob import NFC, VD
@@ -489,7 +497,7 @@ def import_export_prompt_decode(ch):
async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
no_nfc=False, title=None, intro='', footnotes='',
offer_kt=False, slot_b_only=False, force_prompt=False,
- key0=None, key6=None):
+ key0=None, key6=None, sensitive=False):
# Show story allowing user to select source for importing/exporting
# - return either str(mode) OR dict(file_args)
@@ -517,7 +525,8 @@ async def import_export_prompt(what_it_is, is_import=False, no_qr=False,
hints = ("" if no_qr else KEY_QR) + (KEY_NFC if not no_nfc and NFC else "")
msg_lst = [i for i in (intro, prompt, footnotes) if i]
ch = await ux_show_story("\n\n".join(msg_lst), escape=escape, title=title,
- strict_escape=True, hint_icons=hints)
+ strict_escape=True, hint_icons=hints,
+ sensitive=sensitive)
return import_export_prompt_decode(ch)
### shared/ux_mk4.py
@@ -169,9 +169,11 @@ async def ux_input_digits(val, prompt=None, maxlen=32):
if len(here) < maxlen:
here += ch
-async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_len=0, **_kws):
+async def ux_input_text(pw, confirm_exit=True, charset=None,
+ max_len=100, min_len=0, prompt='Enter value', **_kws):
# Allow them to pick each digit using "D-pad"
# - Q1 version of this function can do much more w/ more keyword args
+ # - charset => ordered choices; first character starts and expands the value
from glob import dis
from display import FontTiny, FontSmall
from ux import ux_show_story
@@ -181,9 +183,10 @@ async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_
# - so really just ascii; not even latin-1
# - 8-bit codepoints only
my_rng = range(32, 127) # FontSmall.code_range
- if hex_only:
- new_expand = "0"
- symbols = b"0123456789abcdef"
+ restricted = bool(charset)
+ if restricted:
+ new_expand = charset[0]
+ symbols = charset.encode()
else:
new_expand = " "
symbols = b' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
@@ -192,15 +195,15 @@ async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_
numbers = b'1234567890'
# assert len(set(symbols+letters+Letters+numbers)) == len(my_rng)
- if hex_only:
- footer1 = "Enter Hexidecimal Number"
+ if restricted:
+ footer1 = prompt
footer2 = "58=Change 9=Next 7=Back"
else:
footer1 = "1=Letters 2=Numbers 3=Symbols"
footer2 = "4=SwapCase 0=HELP"
y = 20
- pw = bytearray(pw or ('0' if hex_only else 'A'))
+ pw = bytearray(pw or (new_expand if restricted else 'A'))
pos = len(pw) - 1 # which part being changed
n_visible = const(9)
@@ -220,7 +223,7 @@ def cycle_set(which, direction=1):
def change(dx):
# next/prev within the same subset of related chars
ch = pw[pos]
- if hex_only:
+ if restricted:
return cycle_set(symbols, dx)
for subset in [symbols, letters, Letters, numbers]:
if ch in subset:
@@ -264,7 +267,7 @@ def change(dx):
if ax == pos:
# draw cursor
- if not hex_only and (len(pw) < 2 * n_visible):
+ if not restricted and (len(pw) < 2 * n_visible):
dis.text(x - 4, y - 19, '0x%02X' % ch, FontTiny)
dis.icon(x - 2, y - 10, 'spin')
@@ -309,8 +312,7 @@ def change(dx):
pos += 1
if pos >= len(pw):
if len(pw) < max_len and pw[-3:] != b' ':
- # expands with space in normal mode
- # expands with 0 in hex_only mode
+ # expands with the first charset character, or space in normal mode
pw += new_expand
else:
pos -= 1 # abort addition
@@ -319,9 +321,9 @@ def change(dx):
change(1)
elif ch == '8': # down
change(-1)
- elif hex_only:
+ elif restricted:
# just got back at the beginning of the loop
- # below branches are unreachable for hex_only mode
+ # below branches are unreachable for restricted modes
pass
elif ch == '1': # alpha
cycle_set(b'Aa')
### shared/ux_q1.py
@@ -132,7 +132,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=True, value=''):
# cleanup leading zeros and such
value = str(min(int(value), max_value))
-async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
+async def ux_input_text(value, confirm_exit=False, charset=None, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
placeholder=None, funct_keys=None, force_xy=None):
# Get a text string.
@@ -141,6 +141,7 @@ async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
# - no control chars allowed either
# - press QR -> do scan and use that text
# - funct_keys => CTA msg, and map of Fn key to async-function which takes and returns new text
+ # - charset => allowed characters; accepts the other case when only one is allowed
# - TODO: regex validation for derviation paths?
# - TODO: arrowing around, insertion cursor, delete-left vs -right, etc
# - if unlimited length, then we allow newlines and CANCEL is only way out.
@@ -157,8 +158,10 @@ async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
# map from what they entered, to allowed char. None if not allowed char
# - can case fold if desired
ch_remap = lambda ch: ch if ' ' <= ch < chr(127) else None
- if hex_only:
- ch_remap = lambda ch: ch.lower() if ch in '0123456789abcdefABCDEF' else None
+ if charset:
+ ch_remap = lambda ch: ch if ch in charset else (
+ ch.lower() if ch.lower() in charset else (
+ ch.upper() if ch.upper() in charset else None))
line_len = CHARS_W-2
y = 2
@@ -879,6 +882,15 @@ def convertor(got):
return decode_qr_result(got, expect_text=True)
return await self.scan_general(prompt, convertor)
+ async def scan_codex32(self, prompt):
+ def convertor(got):
+ what, values = decode_qr_result(got, expect_secret=True)
+ if what != 'codex32':
+ raise QRDecodeExplained('Expected Codex32')
+ return values[0]
+
+ return await self.scan_general(prompt, convertor, enter_quits=True)
+
async def scan_json(self, prompt):
# Scan for a BBQr and a BBQr object. Converts sometimes?
def convertor(got):
@@ -931,7 +943,7 @@ async def scan_anything(self, expect_secret=False, tmp=False, expect_type=None):
problem = None
while 1:
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
- 'Scan XPRV or Seed Words, or CANCEL'
+ 'Scan XPRV/Words/Codex32, or CANCEL'
if expect_type:
label = {'psbt': 'PSBT', 'teleport': 'Key Teleport'}[expect_type]
prompt = 'Scan %s, or CANCEL' % label
@@ -963,7 +975,7 @@ async def scan_anything(self, expect_secret=False, tmp=False, expect_type=None):
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'})
+ whitelist.update({'xprv', 'words', 'codex32'})
if what not in whitelist:
await ux_show_story("Blocked when Spending Policy is in force.", title='Sorry')
@@ -985,6 +997,12 @@ async def scan_anything(self, expect_secret=False, tmp=False, expect_type=None):
return
+ if what == 'codex32':
+ from actions import import_codex32_as_secret
+ value, = vals
+ await import_codex32_as_secret(value, tmp, 'Codex32 from QR')
+ return
+
if what == 'psbt':
decoder, psbt_len, got = vals
await qr_psbt_sign(decoder, psbt_len, got)
### testing/devtest/backup_codex32.py
@@ -0,0 +1,75 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+# Pending shares belong to the local recovery session, not the wallet backup.
+
+def run():
+ import backups, version
+ from glob import settings
+ from pincodes import pa
+
+ # Public share for a wallet unrelated to the simulator's active master.
+ share = 'MS12W7F2AQQQSYQCYQ5RQWZQFPG9SCRGWPUAM077H9XN5W88'
+ original = dict(settings.current)
+ raw = bytes(pa.fetch())
+ try:
+ settings.master_set('c32_shares', [share])
+ settings.set('nick', 'Backup control')
+ contents = backups.render_backup_contents()
+ assert 'setting.c32_shares' not in contents
+ assert share not in contents
+ assert 'raw_secret = ' in contents
+ assert 'setting.nick = "Backup control"' in contents
+ assert settings.master_get('c32_shares') == [share]
+
+ if version.has_qwerty:
+ import teleport
+ captured = []
+
+ async def approve(*a, **kw):
+ return 'y'
+
+ async def capture(rx_pubkey, type_code, raw):
+ assert type_code == 'b'
+ captured.append(raw)
+
+ old_story, old_send = teleport.ux_show_story, teleport.kt_do_send
+ try:
+ teleport.ux_show_story, teleport.kt_do_send = approve, capture
+ # Both awaited UI/transport stubs complete without yielding.
+ coro = teleport.SecretPickerMenu(None).share_full_backup()
+ try:
+ coro.send(None)
+ except StopIteration:
+ pass
+ else:
+ assert False, 'unexpected Teleport suspension'
+ finally:
+ teleport.ux_show_story, teleport.kt_do_send = old_story, old_send
+
+ assert len(captured) == 1
+ assert b'setting.c32_shares' not in captured[0]
+ assert share.encode() not in captured[0]
+ assert b'raw_secret = ' in captured[0]
+ assert b'setting.nick = "Backup control"' in captured[0]
+ assert settings.master_get('c32_shares') == [share]
+
+ # A legacy/crafted backup can still contain this field. Ignore it while
+ # restoring ordinary settings and the wallet successfully.
+ vals = backups.text_bk_parser(contents.encode())
+ vals['setting.c32_shares'] = [share]
+ vals['setting.nick'] = 'Restored control'
+ settings.remove_key('c32_shares')
+ settings.save()
+ error, _ = backups.restore_from_dict_ll(vals, raw)
+ assert error is None
+ assert settings.get('c32_shares') is None
+ assert settings.get('nick') == 'Restored control'
+ assert bytes(pa.fetch()) == raw
+ settings.load()
+ assert settings.get('c32_shares') is None
+ assert settings.get('nick') == 'Restored control'
+ finally:
+ settings.current = original
+ settings.save()
+
+
+run()
### testing/devtest/unit_codex32.py
@@ -0,0 +1,384 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+# https://github.com/bitcoin/bips/blob/master/bip-0093.mediawiki
+
+import chains, ngu
+from ubinascii import unhexlify as a2b_hex
+from utils import B2A, deserialize_secret
+from codex32 import (CHARSET, SECRET, Share, bech32_to_array, codex32_verify_checksum,
+ generate_share)
+from stash import SecretStash
+
+
+def xprv_from_bip32_seed(seed):
+ import chains, ngu
+ chain = chains.get_chain('BTC')
+ node = ngu.hdnode.HDNode().from_master(seed)
+ return chain.serialize_private(node)
+
+
+# TEST VECTOR 1
+v1 = Share.parse('ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw')
+assert v1.threshold == 0
+assert v1.index == SECRET
+assert v1.payload == 'xxxxxxxxxxxxxxxxxxxxxxxxxx'
+assert v1.checksum() == '4nzvca9cmczlw'
+assert v1.to_string() == 'MS10TESTSXXXXXXXXXXXXXXXXXXXXXXXXXX4NZVCA9CMCZLW'
+assert v1.to_string(upper=False) == 'ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw'
+assert B2A(v1.to_seed_and_pad()[0]) == '318c6318c6318c6318c6318c6318c631'
+assert xprv_from_bip32_seed(v1.to_seed_and_pad()[0]) == \
+ 'xprv9s21ZrQH143K3taPNekMd9oV5K6szJ8ND7vVh6fxicRUMDcChr3bFFzuxY8qP3xFFBL6DWc2uEYCfBFZ2nFWbAqKPhtCLRjgv78EZJDEfpL'
+print('Vector 1: OK')
+
+# TEST VECTOR 2
+v2_a = Share.parse('MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM')
+v2_c = Share.parse('MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN')
+v2_d = generate_share([v2_a, v2_c], 'd')
+v2_secret = generate_share([v2_a, v2_d], SECRET)
+assert B2A(v2_secret.to_seed_and_pad()[0]) == 'd1808e096b35b209ca12132b264662a5'
+assert xprv_from_bip32_seed(v2_secret.to_seed_and_pad()[0]) == \
+ 'xprv9s21ZrQH143K2NkobdHxXeyFDqE44nJYvzLFtsriatJNWMNKznGoGgW5UMTL4fyWtajnMYb5gEc2CgaKhmsKeskoi9eTimpRv2N11THhPTU'
+print('Vector 2: OK')
+
+# TEST VECTOR 3
+seed = 'ffeeddccbbaa99887766554433221100'
+v3_secret = Share.from_seed(a2b_hex(seed), 'ms', 'cash', SECRET, 3, pad_val=0)
+assert v3_secret.to_string() == \
+ 'MS13CASHSLLHDMN9M42VCSAMX24ZRXGS3QQJZQUD4M0D6NLN'
+assert B2A(Share.parse(
+ 'ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln').to_seed_and_pad()[0]) == seed
+
+v3_a = Share.parse('ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t')
+v3_c = Share.parse('ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr')
+v3_d = generate_share([v3_secret, v3_a, v3_c], 'd')
+v3_e = generate_share([v3_secret, v3_a, v3_c], 'e')
+v3_f = generate_share([v3_secret, v3_a, v3_c], 'f')
+
+assert v3_d.to_string() == 'MS13CASHD0WSEDSTCDCTS64CD7WVY4M90LM28W4FFUPQS7RM'
+assert v3_e.to_string() == 'MS13CASHEEKGPEMXZSHCRMQHAYDLP6YHMS3WS7320XYXSAR9'
+assert v3_f.to_string() == 'MS13CASHF8JH6SDRKPYRSP5UT94PJ8KTEHHW2HFVYRJ48704'
+assert B2A(generate_share([v3_a, v3_c, v3_d], SECRET).to_seed_and_pad()[0]) == seed
+assert B2A(generate_share([v3_d, v3_e, v3_f], SECRET).to_seed_and_pad()[0]) == seed
+assert B2A(generate_share([v3_a, v3_c, v3_f], SECRET).to_seed_and_pad()[0]) == seed
+assert xprv_from_bip32_seed(v3_secret.to_seed_and_pad()[0]) == \
+ 'xprv9s21ZrQH143K266qUcrDyYJrSG7KA3A7sE5UHndYRkFzsPQ6xwUhEGK1rNuyyA57Vkc1Ma6a8boVqcKqGNximmAe9L65WsYNcNitKRPnABd'
+print('Vector 3: OK')
+
+# TEST VECTOR 4
+seed = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100'
+target = 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma'
+assert Share.from_seed(a2b_hex(seed), 'ms', 'leet', SECRET, 0,
+ pad_val=0).to_string() == target.upper()
+assert B2A(Share.parse(target).to_seed_and_pad()[0]) == seed
+
+alt_encodings = [
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqpj82dp34u6lqtd',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqzsrs4pnh7jmpj5',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqrfcpap2w8dqezy',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqy5tdvphn6znrf0',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq9dsuypw2ragmel',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqx05xupvgp4v6qx',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq8k0h5p43c2hzsk',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqgum7hplmjtr8ks',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqf9q0lpxzt5clxq',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq28y48pyqfuu7le',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqt7ly0paesr8x0f',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqvrvg7pqydv5uyz',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqd6hekpea5n0y5j',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqwcnrwpmlkmt9dt',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq0pgjxpzx0ysaam',
+]
+for encoded in alt_encodings:
+ share = Share.parse(encoded)
+ assert B2A(share.to_seed_and_pad()[0]) == seed
+ assert xprv_from_bip32_seed(share.to_seed_and_pad()[0]) == \
+ 'xprv9s21ZrQH143K3s41UCWxXTsU4TRrhkpD1t21QJETan3hjo8DP5LFdFcB5eaFtV8x6Y9aZotQyP8KByUjgLTbXCUjfu2iosTbMv98g8EQoqr'
+print('Vector 4: OK')
+
+# TEST VECTOR 5
+seed = ('dc5423251cb87175ff8110c8531d0952d8d73e1194e95b5f19d6f9df7c011111'
+ '04c9baecdfea8cccc677fb9ddc8aec5553b86e528bcadfdcc201c17c638c47e9')
+target = ('CX100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6'
+ 'AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06GPUHWUSDF58Y65T8')
+v5 = Share.from_seed(a2b_hex(seed), 'cx', '0c8v', SECRET, 0)
+assert B2A(Share.parse(target).to_seed_and_pad()[0]) == seed
+assert B2A(v5.to_seed_and_pad()[0]) == seed
+assert xprv_from_bip32_seed(v5.to_seed_and_pad()[0]) == \
+ 'xprv9s21ZrQH143K4UYT4rP3TZVKKbmRVmfRqTx9mG2xCy2JYipZbkLV8rwvBXsUbEv9KQiUD7oED1Wyi9evZzUn2rqK9skRgPkNaAzyw3YrpJN'
+print('Vector 5: OK')
+
+# The unreleased former prefix is not an import alias.
+try:
+ Share.parse('cc' + v5.to_string().lower()[2:])
+except AssertionError as exc:
+ assert str(exc) == 'unsupported HRP'
+else:
+ assert False, 'accepted obsolete extended-key prefix'
+
+# Only secret S is stored, without metadata or payload padding.
+stored_shares = [v1, v2_a, v2_secret, Share.parse(target)]
+for encoded in alt_encodings:
+ stored_shares.append(Share.parse(encoded))
+for original in stored_shares:
+ if not original.is_secret_share():
+ try:
+ SecretStash.encode(codex32=original)
+ except AssertionError:
+ pass
+ else:
+ assert False, 'accepted non-secret share'
+ continue
+ encoded = SecretStash.encode(codex32=original)
+ assert len(encoded) == 72
+ assert encoded[65:] == bytes(7)
+ assert deserialize_secret(SecretStash.storage_serialize(encoded)) == encoded
+ assert encoded[0] == (1 if original.hrp == 'cx' else len(original.to_seed_and_pad()[0]))
+
+ mode, raw, node = SecretStash.decode(encoded)
+ assert raw == original.to_seed_and_pad()[0]
+ assert mode == ('xprv' if original.hrp == 'cx' else 'master')
+ node.blank()
+
+padded = Share.parse(alt_encodings[5])
+seed_bytes, pad = padded.to_seed_and_pad()
+assert pad
+assert Share.from_seed(seed_bytes, padded.hrp, padded.uid, padded.index,
+ padded.threshold).to_string() != padded.to_string()
+assert Share.from_seed(seed_bytes, padded.hrp, padded.uid, padded.index,
+ padded.threshold, pad).to_string() == padded.to_string()
+print('Native storage: OK')
+
+invalid_checksum = [
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxve740yyge2ghq',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxve740yyge2ghp',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxlk3yepcstwr',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx6pgnv7jnpcsp',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxx0cpvr7n4geq',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxm5252y7d3lr',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxrd9sukzl05ej',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxc55srw5jrm0',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxgc7rwhtudwc',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx4gy22afwghvs',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxme084q0vpht7pe0',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxme084q0vpht7pew',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxqyadsp3nywm8a',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxzvg7ar4hgaejk',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcznau0advgxqe',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxch3jrc6j5040j',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx52gxl6ppv40mcv',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx7g4g2nhhle8fk',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx63m45uj8ss4x8',
+ 'cx10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxy4r708q7kg65x',
+]
+for encoded in invalid_checksum:
+ try:
+ Share.parse(encoded)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'incorrect checksum' in str(exc)
+print('Invalid checksum: OK')
+
+# These examples use the wrong checksum for their given data sizes. The current
+# parser rejects non-standard lengths first, so also test the checksum primitive.
+invalid_checksum_len = [
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxurfvwmdcmymdufv',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxcsyppjkd8lz4hx3',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx3hmlrmpa4zl0v',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxrfggf88znkaup',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxpt7l4aycv9qzj',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxus27z9xtyxyw3',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcwm4re8fs78vn',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxr335l5tv88js3',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxky0ua3ha84qk8',
+]
+for encoded in invalid_checksum_len:
+ try:
+ Share.parse(encoded)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert str(exc) in ('ms codex32 length', 'incorrect checksum')
+ hrp, data = encoded.split('1')
+ assert not codex32_verify_checksum(hrp, bech32_to_array(data))
+print('Invalid checksum length: OK')
+
+invalid_payload_length = [
+ # Valid long checksums under the expanded-length rule, unsupported payload sizes.
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxu6hwvl5p0l9xf3c',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxwqey9rfs6smenxa',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxv70wkzrjr4ntqet',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxw0a4c70rfefn4',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxk4pavy5n46nea',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxkmfw6jm270mz6ej',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxzhddxw99w7xws',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxx42cux6um92rz',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx02ev7caq6n9fgkf',
+]
+for encoded in invalid_payload_length:
+ try:
+ Share.parse(encoded)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'ms codex32 length' in str(exc)
+ hrp, data = encoded.split('1')
+ assert codex32_verify_checksum(hrp, bech32_to_array(data))
+print('Invalid Codex32 length: OK')
+
+incomplete_group = [
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx9lrwar5zwng4w',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvu7q9nz8p7dj68v',
+ 'ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxpq6k542scdxndq3',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxarja5kqukdhy9',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx9eheesxadh2n2n9',
+ 'ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx9llwmgesfulcj2z',
+]
+for encoded in incomplete_group:
+ try:
+ Share.parse(encoded)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'ms codex32 length' in str(exc)
+
+ hrp, data = encoded.split('1')
+ values = bech32_to_array(data)
+ assert codex32_verify_checksum(hrp, values)
+ checksum_len = 15 if 5 + len(values) >= 96 else 13
+ body = data[:-checksum_len]
+ try:
+ Share(hrp, body[1:5], body[6:], body[5], int(body[0]))
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'incomplete group' in str(exc)
+print('Incomplete group: OK')
+
+try:
+ Share.parse('ms10fauxxxxxxxxxxxxxxxxxxxxxxxxxxxx0z26tfn0ulw3p')
+ raise RuntimeError
+except AssertionError as exc:
+ assert 'non-secret share with threshold 0' in str(exc)
+print('Non-secret share with threshold 0: OK')
+
+try:
+ Share.parse('ms1fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg')
+ raise RuntimeError
+except AssertionError as exc:
+ assert 'invalid threshold' in str(exc)
+print('Threshold is not digit: OK')
+
+malformed_header = [
+ '0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2',
+ '10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2',
+ 'ms0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2',
+ 'm10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2',
+ 's10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2',
+ '0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxhkd4f70m8lgws',
+ '10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxhkd4f70m8lgws',
+ 'm10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxx8t28z74x8hs4l',
+ 's10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxh9d0fhnvfyx3x',
+]
+for encoded in malformed_header:
+ try:
+ Share.parse(encoded)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'unsupported HRP' in str(exc)
+print('Unsupported HRP: OK')
+
+try:
+ Share.from_seed(ngu.random.bytes(16), 'ms', 'cash', 'a', 2)
+ raise RuntimeError
+except AssertionError as exc:
+ assert 'padding required for non-secret share' in str(exc)
+print('Non-secret share padding: OK')
+
+# Split 128-, 256- and 512-bit master secrets as 3-of-5 sets.
+for secret_len in (16, 32, 64):
+ seed_bytes = ngu.random.bytes(secret_len)
+ secret = Share.from_seed(seed_bytes, 'ms', 'cash', SECRET, 3)
+ pad_len = (-secret_len * 8) % 5
+ share_a = Share.from_seed(ngu.random.bytes(secret_len), 'ms', 'cash', 'a', 3,
+ ngu.random.bytes(1)[0] & ((1 << pad_len) - 1))
+ share_c = Share.from_seed(ngu.random.bytes(secret_len), 'ms', 'cash', 'c', 3,
+ ngu.random.bytes(1)[0] & ((1 << pad_len) - 1))
+ share_d = generate_share([secret, share_a, share_c], 'd')
+ share_e = generate_share([secret, share_a, share_c], 'e')
+ share_f = generate_share([secret, share_a, share_c], 'f')
+
+ for share in (secret, share_a, share_c, share_d, share_e, share_f):
+ assert share.to_string()[:3] == 'MS1'
+
+ try:
+ generate_share([share_a, share_c], 'c')
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'index already taken' in str(exc)
+
+ try:
+ generate_share([share_a, share_c, share_c], 'j')
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'indexes not unique' in str(exc)
+
+ mismatched = Share.from_seed(seed_bytes, 'ms', 'cass', 'w', 3, 0)
+ try:
+ generate_share([share_a, share_c, mismatched], 'j')
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'id not same' in str(exc)
+
+ mismatched = Share.from_seed(seed_bytes, 'ms', 'cash', 'w', 4, 0)
+ try:
+ generate_share([share_a, share_c, mismatched], 'j')
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'threshold not same' in str(exc)
+
+ try:
+ generate_share([share_a, share_c], SECRET)
+ raise RuntimeError
+ except AssertionError as exc:
+ assert 'need exactly 3 shares' in str(exc)
+
+ recovered = generate_share([share_a, share_c, share_d], SECRET)
+ assert recovered.to_seed_and_pad()[0] == seed_bytes
+ assert recovered.to_string()[:3] == 'MS1'
+
+ recovered = generate_share([share_d, share_e, share_f], SECRET)
+ assert recovered.to_seed_and_pad()[0] == seed_bytes
+ assert recovered.to_string()[:3] == 'MS1'
+
+# Round-trip serialization/deserialization for MS and CC.
+for secret_len in (16, 32, 64):
+ seed_bytes = ngu.random.bytes(secret_len)
+ original = Share.from_seed(seed_bytes, 'ms', 'k00l', 'c', 3, 0)
+ restored = Share.from_seed(original.to_seed_and_pad()[0], 'ms', 'k00l', 'c', 3, 0)
+ assert seed_bytes == original.to_seed_and_pad()[0] == restored.to_seed_and_pad()[0]
+ assert original.to_string() == restored.to_string()
+
+seed_bytes = ngu.random.bytes(64)
+original = Share.from_seed(seed_bytes, 'cx', 'test', 'a', 3, 0)
+restored = Share.from_seed(original.to_seed_and_pad()[0], 'cx', 'test', 'a', 3, 0)
+assert seed_bytes == original.to_seed_and_pad()[0] == restored.to_seed_and_pad()[0]
+assert original.to_string() == restored.to_string()
+
+# Preserve the interpolation round-trip from
+# https://github.com/coinkite/afirmware/pull/494 with the padding correction from
+# https://github.com/coinkite/afirmware/pull/536.
+originals = [
+ Share.parse('ms13k00lacf8aycvqkftq456thdjm342ky8j5muppfqt97s4'),
+ Share.parse('ms13k00lcdk0hxv6eprwujncmdx96fg9s9qqkgq3vn9hq9yv'),
+ Share.parse('ms13k00lf7wwuv6xlcgltfgjeygul26lwqmgu9hnu65q3fj2'),
+]
+round_tripped = []
+for original in originals:
+ seed_bytes, pad = original.to_seed_and_pad()
+ restored = Share.from_seed(seed_bytes, 'ms', 'k00l', original.index, 3, pad)
+ assert restored.to_seed_and_pad() == (seed_bytes, pad)
+ assert restored.to_string() == original.to_string()
+ round_tripped.append(restored)
+
+assert generate_share(originals, SECRET).to_string() == \
+ generate_share(round_tripped, SECRET).to_string()
+
+print('Codex32: OK')
+
+# EOF
### testing/devtest/unit_codex32_boundaries.py
@@ -0,0 +1,145 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+# Additional coverage; the published vectors remain in unit_codex32.py.
+
+# Keep imports in function scope for the simulator EXEC harness.
+def run():
+ import ngu
+ from codex32 import CHARSET, SECRET, Share, generate_share
+ from stash import SecretStash
+ from utils import deserialize_secret
+
+ def rejected(func, args, case, message=None):
+ try:
+ func(*args)
+ except AssertionError as exc:
+ assert message is None or message in str(exc), (case, str(exc))
+ else:
+ assert False, ('accepted invalid input', case)
+
+ def fields(share):
+ # Compare all encoded data, including padding, without recomputing checksums.
+ return share.hrp, share.uid, share.index, share.threshold, share.payload
+
+ def storage_roundtrip(share, case):
+ if not share.is_secret_share():
+ rejected(SecretStash.encode, (None, None, None, share), case)
+ else:
+ encoded = SecretStash.encode(codex32=share)
+ assert len(encoded) == 72 and encoded[65:] == bytes(7), case
+ restored = deserialize_secret(SecretStash.storage_serialize(encoded))
+ assert restored == encoded, case
+ mode, raw, node = SecretStash.decode(restored)
+ try:
+ assert raw == share.to_seed_and_pad()[0], case
+ assert mode == {'ms': 'master', 'cw': 'words', 'cx': 'xprv'}[share.hrp], case
+ finally:
+ node.blank()
+ return Share.parse(share.to_string())
+
+ FORMATS = (('ms', 16), ('ms', 32), ('ms', 64), ('cx', 64),
+ ('cw', 16), ('cw', 24), ('cw', 32))
+ INDICES = 'acdefghjk'
+
+ for hrp, size in FORMATS:
+ label = '%s1 %d-bit' % (hrp.upper(), size * 8)
+ print('Codex32 %s: testing thresholds, padding and storage...' % label)
+ seed = bytes(range(size))
+ pad_count = 1 << ((-size * 8) % 5)
+
+ # Recover every threshold from distinct subsets and reversed order.
+ for threshold in range(2, 10):
+ case = (hrp, size, 'threshold', threshold)
+ secret = Share.from_seed(seed, hrp, 'test', SECRET, threshold, pad_count - 1)
+ shares = [Share.from_seed(ngu.hash.sha512(bytes([i])).digest()[:size],
+ hrp, 'test', INDICES[i], threshold, i % pad_count)
+ for i in range(threshold - 1)]
+ basis = [secret] + shares
+ for index in INDICES[threshold - 1:]:
+ shares.append(generate_share(basis, index))
+ shares = [storage_roundtrip(share, case) for share in shares]
+ for subset in (shares[:threshold], shares[-threshold:],
+ list(reversed(shares[:threshold]))):
+ recovered = generate_share(subset, SECRET)
+ assert fields(recovered) == fields(secret), (case, [s.index for s in subset])
+
+ # Extra points must be rejected even if they fit the original polynomial.
+ subset = shares[:threshold]
+ extra = generate_share(subset, 'm')
+ bad_payload = CHARSET[CHARSET.index(extra.payload[0]) ^ 1] + extra.payload[1:]
+ inconsistent = Share(hrp, 'test', bad_payload, 'm', threshold)
+ inconsistent = Share.parse(inconsistent.to_string()) # Valid checksum.
+ for target in (SECRET, 'n'):
+ for inputs in (subset[:-1], subset + [extra], subset + [inconsistent]):
+ rejected(generate_share, (inputs, target), (case, target),
+ 'need exactly %d shares' % threshold)
+
+ # Exhaust all padding values for secrets and non-secret shares, including recovery.
+ for pad in range(pad_count):
+ case = (hrp, size, 'padding', pad)
+ secret = Share.from_seed(seed, hrp, 'cash', SECRET, 2, pad)
+ first = Share.from_seed(bytes(reversed(seed)), hrp, 'cash', 'a', 2, pad)
+ for share, expected_seed in ((secret, seed), (first, bytes(reversed(seed)))):
+ assert share.to_seed_and_pad() == (expected_seed, pad), case
+ text = share.to_string()
+ for value in (text, text.lower()):
+ assert fields(Share.parse(value)) == fields(share), case
+ storage_roundtrip(share, case)
+ second = storage_roundtrip(generate_share([secret, first], 'c'), case)
+ assert fields(generate_share([second, first], SECRET)) == fields(secret), case
+
+ # Cover every index and identifier character without multiplying the entire matrix.
+ for pos, index in enumerate(CHARSET):
+ case = (hrp, size, 'index', index)
+ share = Share.from_seed(seed, hrp, 'q' + index + 'l7', index,
+ 2 + pos % 8, pad_count - 1)
+ storage_roundtrip(share, case)
+
+ for pad in (-1, pad_count):
+ rejected(Share.from_seed, (seed, hrp, 'test', SECRET, 2, pad),
+ (hrp, size, 'invalid padding', pad))
+
+ print('Codex32 %s: OK' % label)
+
+ print('Codex32 thresholds, padding, indices and storage rejection: OK')
+
+ # These mismatches must be rejected by interpolation itself, independently of the UI.
+ first = Share.from_seed(bytes(range(64)), 'ms', 'test', 'a', 2, 0)
+ for second, message in (
+ (Share.from_seed(bytes(range(64)), 'cx', 'test', 'c', 2, 0), 'hrp not same'),
+ (Share.from_seed(bytes(range(32)), 'ms', 'test', 'c', 2, 0), 'length not same')):
+ rejected(generate_share, ([first, second], SECRET), message, message)
+ rejected(generate_share, ([], SECRET), 'empty recovery', 'no shares')
+
+ # CW1 preserves the legacy words representation and its passphrase semantics.
+ for size in (16, 24, 32):
+ entropy = bytes(range(size))
+ share = Share.from_seed(entropy, 'cw', 'test', SECRET, 2, 1)
+ encoded = SecretStash.encode(codex32=share)
+ legacy = SecretStash.encode(seed_phrase=entropy)
+ assert encoded == legacy
+ assert SecretStash.is_words(encoded) == size * 3 // 4
+ for pw in ('', 'TREZOR'):
+ mode, raw, node = SecretStash.decode(encoded, pw)
+ _, _, expected = SecretStash.decode(legacy, pw)
+ try:
+ assert mode == 'words' and raw == entropy
+ assert node.chain_code() == expected.chain_code()
+ assert node.privkey() == expected.privkey()
+ finally:
+ node.blank()
+ expected.blank()
+
+ for size in (20, 28, 64):
+ bad = Share.from_seed(bytes(range(size)), 'cw', 'test', SECRET, 2)
+ rejected(Share.parse, (bad.to_string(),), ('cw', size), 'cw codex32 length')
+ rejected(SecretStash.encode, (None, None, None, bad), ('cw storage', size))
+ first = Share.from_seed(bytes(range(16)), 'cw', 'test', 'a', 2, 0)
+ second = Share.from_seed(bytes(range(16)), 'ms', 'test', 'c', 2, 0)
+ rejected(generate_share, ([first, second], SECRET), 'cw/ms mix', 'hrp not same')
+
+ print('Codex32 recovery rejection and legacy storage: OK')
+
+
+run()
+
+# EOF
### testing/run_sim_tests.py
@@ -377,10 +377,11 @@ def main():
sim_args = ["--eject"] + DEFAULT_SIMULATOR_ARGS + ["--set", "vidsk=1"]
if test_module == "test_bip39pw.py":
sim_args = []
- if test_module in ["test_unit.py", "test_se2.py", "test_backup.py", "test_teleport.py",
+ if test_module in ["test_unit.py", "test_se2.py", "test_backup.py", "test_codex32.py", "test_teleport.py",
"test_hobble.py", "test_sssp.py"]:
# test_nvram_mk4 needs to run without --eff
# se2 duress wallet activated as ephemeral seed requires proper `settings.load`
+ # Codex32 recovery tests verify saved shares using real settings files.
sim_args = ["--set", "nfc=1"]
if test_module in ["test_ephemeral.py", "test_notes.py", "test_ccc.py"]:
# proper `settings.load` _ virtual disk
### testing/test_backup.py
@@ -8,6 +8,9 @@
from bip32 import BIP32Node
from mnemonic import Mnemonic
from ckcc_protocol.protocol import CCProtocolPacker
+from test_codex32 import (SHARES, IMPORT_SHARES, CW_SHARES, Share, native_encoding,
+ bip32_node_from_codex32_share, import_codex32_ui,
+ goto_codex32_menu, enter_bech32)
@pytest.fixture
@@ -324,20 +327,41 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre
pass_way=pass_way)
-@pytest.mark.parametrize("stype", ["words12", "words24", "xprv"])
+@pytest.mark.parametrize("stype", [
+ "words12", "words24", "xprv",
+ pytest.param(SHARES[0], id="ms1-128"),
+ pytest.param(SHARES[1], id="ms1-256"),
+ pytest.param(IMPORT_SHARES[11], id="ms1-512"),
+ pytest.param(IMPORT_SHARES[7], id="cx1"),
+ pytest.param(CW_SHARES[0], id="cw1-128"),
+ pytest.param(CW_SHARES[1], id="cw1-192"),
+ pytest.param(CW_SHARES[2], id="cw1-256"),
+])
def test_backup_ephemeral_wallet(stype, pick_menu_item, press_select, goto_home,
cap_story, pass_word_quiz, get_setting,
verify_backup_file, microsd_path, check_and_decrypt_backup,
sim_execfile, unit_test, word_menu_entry, cap_menu,
restore_backup_cs, generate_ephemeral_words, press_cancel,
- import_ephemeral_xprv, reset_seed_words, seed_story_to_words):
+ import_ephemeral_xprv, reset_seed_words, seed_story_to_words,
+ import_codex32_ui, confirm_tmp_seed, sim_exec, dev, get_secrets):
reset_seed_words()
goto_home()
+ codex32 = stype.lower().startswith(('ms1', 'cx1', 'cw1'))
+ mnemonic = None
if "words" in stype:
num_words = int(stype.replace("words", ""))
sec = generate_ephemeral_words(num_words, from_main=True, seed_vault=False)
- else:
+ elif stype == "xprv":
sec = import_ephemeral_xprv("sd", from_main=True, seed_vault=False)
+ else:
+ share = Share.parse(stype)
+ assert share.to_seed_and_pad()[1] # Exercise preservation of nonzero padding.
+ encoded = native_encoding(stype)
+ sec = bip32_node_from_codex32_share(share)
+ if share.hrp == 'cw':
+ mnemonic = Mnemonic('english').to_mnemonic(share.to_seed_and_pad()[0])
+ import_codex32_ui('sd', stype, tmp=True)
+ confirm_tmp_seed(expect_xfp=sec.fingerprint().hex().upper())
target = sim_execfile('devtest/get-secrets.py')
assert 'Error' not in target
@@ -376,12 +400,24 @@ def test_backup_ephemeral_wallet(stype, pick_menu_item, press_select, goto_home,
assert fn.endswith(".7z")
verify_backup_file(fn)
contents = check_and_decrypt_backup(fn, words)
- if "words" in stype:
+ if "words" in stype or mnemonic:
assert "mnemonic" in contents
else:
assert "mnemonic" not in contents
assert simulator_fixed_words not in contents
assert simulator_fixed_tprv not in contents
+ if codex32:
+ values = dict((key, json.loads(value)) for key, value in
+ (line.split(' = ', 1) for line in contents.splitlines()
+ if line and not line.startswith('#')))
+ assert 'codex32' not in values
+ assert values['raw_secret'] == encoded.hex().rstrip('0')
+ if share.hrp == 'ms':
+ assert values['bip32_master_key'] == share.to_seed_and_pad()[0].hex()
+ else:
+ assert 'bip32_master_key' not in values
+ if mnemonic:
+ assert values['mnemonic'] == mnemonic
# assert target == contents
if "words" in stype:
words_str = " ".join(sec)
@@ -404,6 +440,12 @@ def test_backup_ephemeral_wallet(stype, pick_menu_item, press_select, goto_home,
assert target_esk == esk
restore_backup_cs(fn, words)
+ if codex32:
+ assert sim_exec('from utils import B2A; RV.write(B2A(pa.fetch()))') == encoded.hex()
+ assert 'codex32' not in get_secrets()
+ if mnemonic:
+ assert get_secrets()['mnemonic'] == mnemonic
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) == epk
@pytest.mark.parametrize('seedvault', [False, True])
@@ -555,6 +597,11 @@ def test_seed_vault_backup(settings_set, reset_seed_words, generate_ephemeral_wo
assert xfp_ui in sv_xfp_menu
+def test_pending_codex32_shares_excluded(reset_seed_words, unit_test):
+ reset_seed_words()
+ unit_test('devtest/backup_codex32.py')
+
+
def test_seed_vault_backup_frozen(reset_seed_words, settings_set, repl, build_test_seed_vault):
reset_seed_words()
settings_set("seedvault", 1)
### testing/test_bip39pw.py
@@ -488,10 +488,10 @@ def test_bip39pass_on_ephemeral_seed(generate_ephemeral_words, import_ephemeral_
pick_menu_item("Advanced/Tools")
pick_menu_item("Danger Zone")
pick_menu_item("Seed Functions")
- pick_menu_item("View Seed Words")
+ pick_menu_item("View Secret")
time.sleep(.1)
_, story = cap_story()
- assert "secret seed words" in story
+ assert "wallet's secret" in story
press_select()
time.sleep(.1)
_, story = cap_story()
### testing/test_codex32.py
@@ -0,0 +1,1799 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+
+import itertools, os, pytest, re, sys, time
+
+from conftest import enable_nfc
+
+sys.path.append('../shared')
+
+from bip32 import BIP32Node, PrvKeyNode
+from charcodes import KEY_NFC, KEY_QR
+from ckcc.protocol import CCProtocolPacker
+from codex32 import CHARSET, IDX_ORDER, SECRET, UX_CHARSET, Share, generate_share
+from constants import simulator_fixed_tprv, simulator_fixed_words
+from helpers import prandom
+from mnemonic import Mnemonic
+
+
+SHARES = [
+ 'ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq'
+ '9dsuypw2ragmel',
+ 'ms10testsqqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0j'
+ 'qgfzyvjz2f389q5j52ev95hz7vp3xgengdfkxuurjw3m8s7nu0ax3uvrcss9'
+ 'ddwnst',
+ 'CX100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZY'
+ 'GSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06GPUHWUSDF'
+ '58Y65T8',
+]
+
+CW_SHARES = [Share.from_seed(bytes(range(size)), 'cw', 'test', SECRET, 2, 1).to_string()
+ for size in (16, 24, 32)]
+CW_SHARE_A = Share.from_seed(bytes(range(16)), 'cw', 'test', 'a', 2, 3).to_string()
+
+IMPORT_SHARES = [
+ # PR #536: generated with each supported size, HRP and padding value.
+ 'MS10K00LS8ZPDUP22440CHE0CMPA5YD6M5AESJRWAGPE79SR',
+ 'ms13k00ls8zpdup22440che0cmpa5yd6m5a6zc77kr93cj87',
+ 'ms10k00lswyut295tnddaz6hul8svd33v84qpue82vx0kdfgms98ngfew83amqydgzd9w6chwh',
+ 'MS13K00LSWYUT295TNDDAZ6HUL8SVD33V84QPUE82VX0KDFGMS98NGFEW83AMYP43NJFWJRLC4',
+ 'MS10K00LSJ9D5C8LA5YS3HF5RJRX7UVZTXWLRYYM02W3D2MX7NX24908PAJP5642TCPS0VTHTXK9322Y6KM7UGLH8KPNA4HT4N5MPPD0H0TK0TG63QZKZ5N2V06DSLR',
+ 'ms13k00lsj9d5c8la5ys3hf5rjrx7uvztxwlryym02w3d2mx7nx24908pajp5642tcps0vthtxk9322y6km7uglh8kpna4ht4n5mppd0h0tk0tg695las2njqkzxe0q',
+ 'cx10cashsycd6tv7snm3nm2l365apdfpec88pnetqrdueqteemch8g2gz0vqan469hfrp965g0lm8zj8w6szju9tj8ck7sdgehgtnj2y3cjcsqlt3p5k282t0hcth63',
+ 'CX13CASHSYCD6TV7SNM3NM2L365APDFPEC88PNETQRDUEQTEEMCH8G2GZ0VQAN469HFRP965G0LM8ZJ8W6SZJU9TJ8CK7SDGEHGTNJ2Y3CJCSQLT94FACE2NRWQQ72J',
+ # PR #536: valid alternate padding encodings which previously failed round-trip.
+ 'MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW',
+ 'ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma',
+ 'MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK',
+ 'CX100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06GPUHWUSDF58Y65T8',
+ # Non-secret shares: valid for interpolation, rejected as wallet secrets.
+ 'ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm',
+ 'MS13TVDWAPW7963ERA8QZ6GYG9ANN0ASGAKDXSMNGXKR7J25K6YL2ZZ6M4598LDLZWPS9HDGAP',
+ 'ms15khcexcmt8zmtdvty36nwuwpencndgyluve8v3c2xswvrg8wvgw2a74nags4adcpf48f3tnnct53nfaqn8l2jzakj27f5uxy97aqgnsu7qwsv6ua42x2nmvcj0ya',
+ 'cx13k00lcleua295dm29f332szk65mpt6vakx070smsxmzqaeez94wqrs7r4a2gwrjcnyp7wy9mx55vu2madmjmt5qzf9a4zx758kr6c287mxyreavqkkgclympu8q4',
+]
+
+INVALID_C32 = [
+ ('ms10tvdwaujsp0xs8s68tlt8sl0t05qzezapfeh987l2vpl2',
+ 'non-secret share with threshold 0'),
+ ('ms10tvdwafl9jgd4wpdvmzwrw3dd7weqwfwrjuk7w8s6068gkn30ftgcqdu65uf8feaav4n3c3',
+ 'non-secret share with threshold 0'),
+ ('ms10tvdwardhgn06pfkvxad5v3sg0psvljjc4uwwq7np06lsydxxjul63gh783mepr4hn4pa39qlztpztydpexqjschskghk7d6es24cnnqf90p8h6d8weskgdqkrxy',
+ 'non-secret share with threshold 0'),
+ ('ms1xcty8nyr409jwe99yxu8pw9x73ahqe2hpuqlclkk6uctt', 'invalid threshold'),
+ ('ms103372s7d8uuuz5p6lczzfyh9xjrsuqdnau743ct5s7sak', 'incorrect checksum'),
+ ('ms10eyfpsrkmky8ppcf2zrk90tdv5r2zfr52zddyzcd5jcs09qlhnhu2eh77m9tccdklvtz2kc',
+ 'incorrect checksum'),
+ ('ms10qch3snfxxucuw82zn9tra2cugxkamywa647quzv2kpvz0tusqkvvcqhj8yc0lnxzfdse9gahd2mtf2vygz0z5a29k7y44wx5era6utzr0zlralh64jyl8e6zesh',
+ 'incorrect checksum'),
+ ('cx10ccess653rz5e40h6e47h5qfwgluekkvzyemdxymm2c37k3nq5lnk662tul5sz2mjncql8ehvdthr2fzdvenpkr36ejcae2ujuzllsqursh9n07u00x8h5ywvyu6',
+ 'incorrect checksum'),
+ ('cx11c8tesdygmp6dsr7v8kfdhg5dw7r0sk5fr9d6d9z7493eyg4m9q0jy5wz2e4remd2cs9z8kgvnf4rlj35my8g2kelw98n65wfcpwkre9zkhde96t4f4hfnpnsvt4',
+ 'invalid separator'),
+ ('ms11ngygs00c0k3h3v5x7h2rck49muwlxm99x3m3qmmh6u65wzyc7v4qzmfn3lmfgcesy3y57m',
+ 'invalid separator'),
+]
+
+
+def native_encoding(value):
+ # Independent conversion of a secret S into ordinary 72-byte wallet storage.
+ value = value.lower()
+ assert value[8] == 's'
+ hrp, encoded = value.split('1')
+ checksum_len = 15 if len(value) == 127 else 13
+ body = encoded[:-checksum_len]
+ values = [CHARSET.index(ch) for ch in body]
+
+ def convert(items, pad):
+ accumulator = 0
+ bits = 0
+ packed = bytearray()
+ for item in items:
+ accumulator = (accumulator << 5) | item
+ bits += 5
+ while bits >= 8:
+ bits -= 8
+ packed.append((accumulator >> bits) & 0xff)
+ accumulator &= (1 << bits) - 1
+ if pad and bits:
+ packed.append(accumulator << (8 - bits))
+ return packed, accumulator
+
+ seed, pad = convert(values[6:], False)
+ result = bytearray(72)
+ if hrp == 'cx':
+ assert len(seed) == 64
+ result[0] = 1
+ elif hrp == 'cw':
+ result[0] = 0x80 | (len(seed) // 8 - 2)
+ else:
+ result[0] = len(seed)
+ result[1:1+len(seed)] = seed
+ return bytes(result)
+
+
+def parse_rendered_codex32(text):
+ groups = [(int(idx), value) for idx, value in
+ re.findall(r'(\d+):\s?([0-9A-Za-z]+)', text)]
+ return ''.join(value for _, value in sorted(groups))
+
+
+def test_q1_codex32_four_column_spacing(sim_exec, only_q1):
+ rendered = sim_exec(
+ 'from seed import render_codex32; RV.write(render_codex32(%r))' % SHARES[3])
+ lines = rendered.splitlines()
+
+ assert len(lines) == 8
+ assert [len(line) for line in lines] == ([33] * 7) + [32]
+ for row, line in enumerate(lines):
+ assert line.startswith('%d:' % (row + 1))
+ assert line[8:11] == '%2d:' % (row + 9)
+ assert line[17:20] == '%2d:' % (row + 17)
+ assert line[26:29] == '%2d:' % (row + 25)
+
+
+def bip32_node_from_codex32_share(share, testnet=True):
+ seed = share.to_seed_and_pad()[0]
+ netcode = 'XTN' if testnet else 'BTC'
+ if share.hrp == 'cx':
+ node = PrvKeyNode(key=seed[32:64], chain_code=seed[:32], testnet=testnet)
+ return BIP32Node(netcode=netcode, node=node)
+ if share.hrp == 'cw':
+ seed = Mnemonic.to_seed(Mnemonic('english').to_mnemonic(seed))
+ return BIP32Node.from_master_secret(seed, netcode=netcode)
+
+@pytest.fixture
+def active_secret(sim_exec):
+ def doit():
+ return sim_exec(
+ 'from utils import B2A; '
+ 'raw = pa.tmp_value if pa.tmp_value else pa.fetch(); '
+ 'RV.write(B2A(raw))')
+ return doit
+
+@pytest.fixture
+def enter_bech32(is_q1, need_keypress, press_select):
+ # Preserved from both PRs: drive the actual Mk character picker and Q keyboard.
+ def doit(target, charset=UX_CHARSET.upper(), submit=True):
+ if is_q1:
+ for ch in target:
+ need_keypress(ch)
+ time.sleep(.01)
+ else:
+ target = target.upper()
+ half = len(charset) // 2
+ for pos, ch in enumerate(target):
+ if pos:
+ time.sleep(.1)
+ need_keypress('9')
+
+ idx = charset.index(ch)
+ if idx > half:
+ for _ in range(len(charset) - idx):
+ need_keypress('8')
+ time.sleep(.01)
+ else:
+ for _ in range(idx):
+ need_keypress('5')
+ time.sleep(.01)
+
+ if submit:
+ press_select()
+
+ return doit
+
+
+@pytest.fixture
+def goto_codex32_menu(goto_home, pick_menu_item, need_keypress):
+ def doit(tmp=False, seed_vault=False, tmp_active=False):
+ goto_home()
+ if tmp:
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Temporary Seed')
+ if not seed_vault and not tmp_active:
+ need_keypress('4')
+ pick_menu_item('Codex32')
+
+ return doit
+
+
+@pytest.fixture
+def goto_shamir_split(goto_home, pick_menu_item, cap_story, cap_screen, press_select, settings_get):
+ def doit(active=None, words=None):
+ if words is None:
+ words = bool(settings_get('words', True))
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Danger Zone')
+ pick_menu_item('Seed Functions')
+ pick_menu_item('Shamir Split')
+ time.sleep(.1)
+ if words or not settings_get('c32', False):
+ title, story = cap_story()
+ assert title == 'WARNING'
+ hrp = 'CW1' if words else 'CX1'
+ assert story.startswith("This split will use %s, COLDCARD's extension to Codex32.\n\n"
+ "To recover, you'll need COLDCARD or software that explicitly"
+ " supports %s.\n\n" % (hrp, hrp))
+ if words:
+ assert ("Recovery restores your original English BIP-39 seed words. Any"
+ " BIP-39 passphrase must be backed up separately and entered"
+ " after recovery.") in story
+ elif active == 'BIP-39 passphrase':
+ assert ("Recovery restores your current passphrase wallet's keys, not your"
+ " seed words or passphrase. The passphrase is not needed for"
+ " recovery and cannot be changed on the recovered wallet.") in story
+ else:
+ assert ("Recovery restores an extended-key wallet, not seed words. You"
+ " cannot apply a BIP-39 passphrase to the recovered wallet.") in story
+ assert 'current passphrase wallet' not in story
+ press_select()
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == 'Shamir Split'
+ assert 'CX1' not in story
+ assert 'Split the current wallet using Codex32 Shamir sharing.' in story
+ assert 'Each split uses fresh randomness and a new ID.' in story
+ if active:
+ warning = 'WARNING: The split will use the wallet derived from the active %s.' % active
+ assert story.index(warning) < story.index('Split the current wallet')
+ assert 'WARNING' in cap_screen()
+ else:
+ assert 'wallet derived from the active' not in story
+ press_select()
+ time.sleep(.1)
+
+ return doit
+
+
+@pytest.mark.parametrize('text', [SHARES[0], CW_SHARE_A, SHARES[3]])
+def test_calculate_checksum_manual(text, goto_codex32_menu, pick_menu_item, cap_story,
+ cap_screen, press_select, enter_bech32, press_cancel,
+ cap_menu, sim_exec, is_q1, enable_nfc, enable_hw_ux,
+ load_export, need_keypress, microsd_path, virtdisk_path,
+ garbage_collector, is_headless, cap_screen_qr, goto_home):
+ goto_home()
+ enable_nfc()
+ enable_hw_ux('vdisk')
+ goto_codex32_menu(tmp=True)
+ snapshot = ('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value, '
+ 'settings.nvram_key, settings.current)))')
+ before = sim_exec(snapshot)
+ pick_menu_item('Calculate Checksum' if is_q1 else 'Calc Checksum')
+ assert 'cannot detect existing transcription mistakes' in cap_story()[1]
+ press_select()
+ time.sleep(.2)
+ assert 'Header + payload' in cap_screen()
+ checksum_len = 15 if len(text) == 127 else 13
+ body = text[:-checksum_len]
+ if is_q1:
+ # Exercise lowercase and uppercase keystrokes in the same entry.
+ body = ''.join(c.lower() if pos % 2 else c.upper() for pos, c in enumerate(body))
+ # Grouping spaces are allowed, but the payload (including padding) is unchanged.
+ body = ' '.join(body[i:i+4] for i in range(0, len(body), 4))
+ enter_bech32(body, submit=False)
+ if is_q1:
+ time.sleep(.2)
+ screen = ''.join(c for c in cap_screen() if c.isalnum())
+ assert body.upper().replace(' ', '') in screen
+ press_select()
+ time.sleep(.2)
+ title, story = cap_story()
+ assert title == "Share '%s'" % text[8].upper()
+ assert 'Checksum:\n\n' + text[-checksum_len:].upper() in story
+ assert parse_rendered_codex32(story.split('Codex32:', 1)[1]) == text.upper()
+ assert 'to show QR code' in story
+ assert 'to share via NFC' in story
+ for way, path_f in [('sd', microsd_path), ('vdisk', virtdisk_path)]:
+ if way == 'sd':
+ need_keypress('1')
+ exported, fname = load_export(way, label=None, is_json=False, ret_fname=True)
+ garbage_collector.append(path_f(fname))
+ garbage_collector.append(path_f(fname.rsplit('.', 1)[0] + '.sig'))
+ assert exported == text.upper()
+ press_cancel()
+ time.sleep(.2)
+ assert load_export('nfc', label=None, is_json=False) == text.upper()
+ time.sleep(.2)
+ if not is_headless:
+ need_keypress(KEY_QR if is_q1 else '4')
+ time.sleep(.3)
+ assert cap_screen_qr().decode('ascii') == text.upper()
+ press_cancel()
+ time.sleep(.2)
+ assert sim_exec(snapshot) == before
+ press_cancel()
+ time.sleep(.2)
+ assert ('Calculate Checksum' if is_q1 else 'Calc Checksum') in cap_menu()
+
+
+@pytest.mark.parametrize('case', ['lower', 'upper', 'mixed'])
+def test_calculate_checksum_scan_case(case, is_q1, goto_codex32_menu, pick_menu_item,
+ press_select, need_keypress, scan_a_qr, cap_screen,
+ cap_story, press_cancel, cap_menu, active_secret):
+ if not is_q1:
+ pytest.skip('requires Q scanner')
+ before = active_secret()
+ body = 'ms10tests' + 'q' * 26
+ if case == 'upper':
+ body = body.upper()
+ elif case == 'mixed':
+ body = body[:-1] + 'Q'
+ goto_codex32_menu(tmp=True)
+ pick_menu_item('Calculate Checksum')
+ press_select()
+ time.sleep(.2)
+ need_keypress(KEY_QR)
+ time.sleep(.2)
+ scan_a_qr(body)
+ time.sleep(1)
+ # Short scans stay in the editor and must retain their original case.
+ assert body in ''.join(c for c in cap_screen() if c.isalnum())
+ press_select()
+ time.sleep(.2)
+ title, story = cap_story()
+ if case == 'mixed':
+ assert title == 'FAILED'
+ assert 'mixed case' in story
+ press_select()
+ press_cancel()
+ press_cancel()
+ else:
+ assert title == "Share 'S'"
+ assert parse_rendered_codex32(story.split('Codex32:', 1)[1]) == \
+ Share.from_body(body).to_string()
+ press_cancel()
+ assert 'Calculate Checksum' in cap_menu()
+ assert active_secret() == before
+
+
+@pytest.mark.parametrize('at_entry', [False, True])
+def test_calculate_checksum_cancel(at_entry, goto_codex32_menu, pick_menu_item,
+ press_select, press_cancel, cap_menu, is_q1,
+ active_secret):
+ before = active_secret()
+ goto_codex32_menu(tmp=True)
+ pick_menu_item('Calculate Checksum' if is_q1 else 'Calc Checksum')
+ if at_entry:
+ press_select()
+ time.sleep(.2)
+ press_cancel()
+ time.sleep(.2)
+ if is_q1:
+ press_cancel()
+ else:
+ press_select()
+ else:
+ press_cancel()
+ time.sleep(.2)
+ assert ('Calculate Checksum' if is_q1 else 'Calc Checksum') in cap_menu()
+ assert active_secret() == before
+
+
+def test_calculate_checksum_retry(goto_codex32_menu, pick_menu_item, cap_story,
+ press_select, enter_bech32, press_delete,
+ press_cancel, active_secret, is_q1):
+ before = active_secret()
+ goto_codex32_menu(tmp=True)
+ pick_menu_item('Calculate Checksum' if is_q1 else 'Calc Checksum')
+ press_select()
+ time.sleep(.2)
+ body = 'ms10tests' + 'q' * 27
+ enter_bech32(body)
+ time.sleep(.2)
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'ms codex32 length' in story
+ press_select()
+ time.sleep(.2)
+ # The rejected input is retained. Remove the extra payload symbol.
+ press_delete()
+ press_select()
+ time.sleep(.2)
+ title, story = cap_story()
+ assert title == "Share 'S'"
+ assert parse_rendered_codex32(story.split('Codex32:', 1)[1]) == \
+ Share.from_seed(bytes(16), 'ms', 'test', 's', 0).to_string()
+ assert active_secret() == before
+ press_cancel()
+
+
+@pytest.fixture
+def shamir_split_settings(enter_number, cap_screen, cap_story, press_select):
+ def doit(num_shares, threshold):
+ time.sleep(.1)
+ assert 'Number of shares (2-9):' in cap_screen()
+ enter_number(num_shares)
+ time.sleep(.1)
+ assert 'Threshold (2-%d):' % num_shares in cap_screen()
+ enter_number(threshold)
+ time.sleep(.1)
+ if threshold == num_shares:
+ title, story = cap_story()
+ assert title == 'WARNING'
+ assert 'N-of-N has no redundancy. Consider a lower threshold.' in story
+ press_select()
+ time.sleep(.1)
+
+ return doit
+
+
+@pytest.fixture
+def export_shares(cap_story, press_select, cap_menu, pick_menu_item, need_keypress,
+ load_export, press_cancel, microsd_path, virtdisk_path, garbage_collector):
+ def doit(way, num_shares, threshold, hrp=None, sec_length=None):
+ title, story = cap_story()
+ assert title == 'WARNING'
+ assert 'Keep threshold-or-more shares on separate devices.' in story
+ assert 'equivalent to storing your seed there in plaintext.' in story
+ press_select()
+ time.sleep(.2)
+
+ menu = cap_menu()
+ header = re.fullmatch(r'%d of %d \[([0-9A-Z]{4})\]' %
+ (threshold, num_shares), menu[0])
+ assert header
+ uid = header.group(1).lower()
+ assert len(menu) == num_shares + 1
+ shares = []
+ fnames = []
+ for pos, label in enumerate(menu[1:], 1):
+ assert label == "Share '%s'" % IDX_ORDER[pos].upper()
+ pick_menu_item(label)
+ title, story = cap_story()
+ assert title == label
+ value = parse_rendered_codex32(story)
+ share = Share.parse(value)
+ assert share.threshold == threshold
+ assert share.uid == uid
+ if hrp:
+ assert share.hrp == hrp
+ if sec_length:
+ assert len(share.to_seed_and_pad()[0]) == sec_length
+
+ if way == 'sd':
+ need_keypress('1')
+ value = load_export(way, label=None, is_json=False, ret_fname=True)
+ if isinstance(value, tuple):
+ value, fname = value
+ path_f = microsd_path if way == 'sd' else virtdisk_path
+ garbage_collector.append(path_f(fname))
+ garbage_collector.append(path_f(fname.rsplit('.', 1)[0] + '.sig'))
+ fnames.append(fname)
+ assert value == share.to_string()
+ shares.append(share)
+ if way != 'nfc':
+ press_cancel()
+ press_cancel()
+
+ assert len(shares) == num_shares
+ return uid, shares, fnames
+
+ return doit
+
+
+@pytest.fixture
+def generate_shares_from_secret():
+ def doit(num_shares, threshold, mnemonic=None, xprv=None, slen=None, uid='cash'):
+ if mnemonic or xprv:
+ if mnemonic:
+ node = BIP32Node.from_master_secret(Mnemonic.to_seed(mnemonic), netcode='XTN')
+ else:
+ node = BIP32Node.from_wallet_key(xprv)
+ seed = node.node.chain_code + bytes(node.node.private_key)
+ hrp = 'cx'
+ else:
+ seed = os.urandom(slen or 16)
+ node = BIP32Node.from_master_secret(seed, netcode='XTN')
+ hrp = 'ms'
+
+ secret = Share.from_seed(seed, hrp, uid, SECRET, threshold)
+ basis = [secret]
+ shares = []
+ for pos in range(1, threshold):
+ payload = ''.join(CHARSET[b & 31] for b in os.urandom(len(secret.payload)))
+ share = Share(hrp, uid, payload, IDX_ORDER[pos], threshold)
+ basis.append(share)
+ shares.append(share.to_string())
+
+ for pos in range(threshold, num_shares + 1):
+ shares.append(generate_share(basis, IDX_ORDER[pos]).to_string())
+
+ return secret, shares, node
+
+ return doit
+
+
+@pytest.fixture
+def shamir_verify_recover(dev):
+ def doit(shares, threshold):
+ expected = dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000)
+ for combo in itertools.combinations(shares, threshold):
+ recovered = generate_share(combo, SECRET)
+ node = bip32_node_from_codex32_share(
+ recovered, testnet=expected.startswith('tpub'))
+ assert node.hwif() == expected
+
+ return doit
+
+
+@pytest.fixture
+def goto_shamir_recover(goto_codex32_menu, pick_menu_item, cap_story, cap_screen,
+ press_select, need_keypress):
+ def doit(tmp=False, seed_vault=False, tmp_active=False):
+ goto_codex32_menu(tmp=tmp, seed_vault=seed_vault, tmp_active=tmp_active)
+ pick_menu_item('Shamir Recover')
+ time.sleep(.1)
+ if tmp:
+ title, warning = cap_story()
+ assert title == 'WARNING'
+ expected = ('The recovered Codex32 seed will be temporary and will not be saved to '
+ 'the Secure Element.')
+ assert warning == expected
+ assert 'recovered Codex32 seed' in cap_screen().replace('\n', ' ')
+ press_select()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'Import shares from one Codex32 set.' in story
+ assert 'Their HRP, ID, threshold and length must match.' in story
+ assert 'Order does not matter.' in story
+ assert "include this Coldcard's" not in story
+ press_select()
+
+ return doit
+
+
+def check_recover_story(story, threshold=None, uid=None, num_collected=0, hrp=None):
+ assert 'Collected: %d' % num_collected in story
+ assert 'Threshold: %s' % ('?' if threshold is None else threshold) in story
+ assert 'ID: %s' % ('?' if uid is None else uid.upper()) in story
+ assert 'HRP: %s' % ('?' if hrp is None else hrp.upper()) in story
+
+
+@pytest.fixture
+def import_codex32_ui(microsd_path, virtdisk_path, pick_menu_item, cap_story,
+ need_keypress, is_q1, press_nfc, nfc_write_text,
+ scan_a_qr, goto_codex32_menu, garbage_collector,
+ enter_bech32):
+ def doit(way, value, tmp=False, seed_vault=False, tmp_active=False):
+ fname = 'test-c32-import.txt'
+ if way in ('sd', 'vdisk'):
+ path_f = microsd_path if way == 'sd' else virtdisk_path
+ fpath = path_f(fname)
+ garbage_collector.append(fpath)
+ with open(fpath, 'w') as fd:
+ fd.write(value)
+
+ goto_codex32_menu(tmp=tmp, seed_vault=seed_vault, tmp_active=tmp_active)
+ pick_menu_item('Import Codex32')
+ time.sleep(.1)
+ _, story = cap_story()
+ if way == 'sd':
+ need_keypress('1')
+ elif way == 'vdisk':
+ need_keypress('2')
+ elif way == 'nfc':
+ assert ('press %s to import via NFC' %
+ (KEY_NFC if is_q1 else '(3)')) in story
+ press_nfc()
+ time.sleep(.2)
+ nfc_write_text(value)
+ time.sleep(.3)
+ elif way == 'qr':
+ need_keypress(KEY_QR)
+ scan_a_qr(value)
+ time.sleep(1)
+ else:
+ assert '(0) to enter manually' in story
+ need_keypress('0')
+ enter_bech32(value.lower())
+ time.sleep(.5)
+
+ if way in ('sd', 'vdisk'):
+ time.sleep(.1)
+ pick_menu_item(fname)
+
+ return doit
+
+
+@pytest.fixture
+def pass_codex32_quiz(cap_story, need_keypress):
+ def doit(value):
+ parts = [value[i:i+4] for i in range(0, len(value), 4)]
+ for _ in parts:
+ time.sleep(.05)
+ title, story = cap_story()
+ pos = int(re.search(r'Group (\d+) is\?', title).group(1)) - 1
+ choices = dict(re.findall(r' ([123]): ([0-9A-Z]+)', story))
+ need_keypress(next(key for key, part in choices.items() if part == parts[pos]))
+
+ return doit
+
+
+@pytest.mark.parametrize('share', SHARES + CW_SHARES)
+def test_native_secret_survives_backup(share, set_encoded_secret, sim_exec, get_secrets):
+ encoded = native_encoding(share)
+ expected = encoded
+ set_encoded_secret(encoded)
+
+ assert sim_exec('from utils import B2A; RV.write(B2A(pa.fetch()))') == encoded.hex()
+ assert encoded[65:] == bytes(7)
+ backup = get_secrets()
+ assert 'codex32' not in backup
+ parsed = Share.parse(share)
+ if parsed.hrp == 'ms':
+ assert backup['bip32_master_key'] == parsed.to_seed_and_pad()[0].hex()
+ else:
+ assert 'bip32_master_key' not in backup
+ assert ('mnemonic' in backup) == share.lower().startswith('cw1')
+
+ backup_hex = backup['raw_secret']
+ if len(backup_hex) % 2:
+ backup_hex += '0'
+ assert bytes.fromhex(backup_hex).ljust(72, b'\0') == expected
+
+
+@pytest.mark.parametrize('share,display', [
+ *[(share, 'master') for share in SHARES[:3]],
+ (SHARES[3], 'xprv'),
+ *[(share, 'words') for share in CW_SHARES],
+])
+def test_view_seed_words_codex32(share, display, set_encoded_secret, goto_home,
+ pick_menu_item, cap_menu, cap_story, press_select,
+ press_cancel, is_q1, need_keypress, cap_screen_qr,
+ is_headless, seed_story_to_words, settings_set):
+ encoded = native_encoding(share)
+ set_encoded_secret(encoded)
+ settings_set('chain', 'XTN')
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Danger Zone')
+ pick_menu_item('Seed Functions')
+ assert 'View Codex32' not in cap_menu()
+
+ pick_menu_item('View Secret')
+ time.sleep(.01)
+ press_select()
+ time.sleep(.01)
+
+ title, body = cap_story()
+ parsed = Share.parse(share)
+ if display == 'master':
+ raw = parsed.to_seed_and_pad()[0]
+ assert raw.hex() in body
+ expected_qr = Share.from_seed(raw, 'ms', 'seed', SECRET, 0).to_string()
+ assert parse_rendered_codex32(body.split('Codex32:', 1)[1]) == expected_qr
+ assert title == ('Master Secret' if is_q1 else 'NO-TITLE')
+ assert share.upper() not in body
+ elif display == 'xprv':
+ expected_qr = bip32_node_from_codex32_share(parsed).hwif(as_private=True)
+ assert body.startswith(expected_qr)
+ assert title == ('Extended Private Key' if is_q1 else 'NO-TITLE')
+ assert share.upper() not in body
+ else:
+ words = Mnemonic('english').to_mnemonic(parsed.to_seed_and_pad()[0]).split()
+ assert seed_story_to_words(body) == words
+ assert title == ('Seed words (%d):' % len(words) if is_q1 else 'NO-TITLE')
+ expected_qr = ' '.join(word[:4] for word in words).upper()
+ assert share.upper() not in body
+
+ if not is_headless:
+ need_keypress(KEY_QR if is_q1 else '1')
+ assert cap_screen_qr().decode('ascii') == expected_qr
+ press_cancel()
+ time.sleep(.1)
+ assert cap_story() == [title, body]
+ press_cancel()
+
+
+def test_c32_flag_lifecycle(set_encoded_secret, reset_seed_words, settings_get,
+ set_master_key):
+ reset_seed_words()
+ assert not settings_get('c32')
+ set_encoded_secret(native_encoding(SHARES[0]))
+ assert settings_get('c32')
+ set_master_key(simulator_fixed_tprv)
+ assert not settings_get('c32')
+ reset_seed_words()
+ assert not settings_get('c32')
+
+
+@pytest.mark.parametrize('seed_type', ['words', 'xprv', 'ms', 'cx'])
+def test_integration(seed_type, unit_test, set_seed_words,
+ import_codex32_ui, expect_ftux, goto_shamir_split,
+ shamir_split_settings, export_shares,
+ press_cancel, press_select, dev, settings_set, active_secret,
+ recover_codex32_shares, sim_exec, reset_seed_words,
+ microsd_path, garbage_collector, pick_menu_item, need_keypress,
+ fake_txn, try_sign):
+ unit_test('devtest/clear_seed.py')
+ native = seed_type in ('ms', 'cx')
+ if seed_type == 'words':
+ set_seed_words(simulator_fixed_words)
+ elif seed_type == 'xprv':
+ fname = 'integration-xprv.txt'
+ path = microsd_path(fname)
+ garbage_collector.append(path)
+ with open(path, 'w') as fd:
+ fd.write(simulator_fixed_tprv)
+ pick_menu_item('Import Existing')
+ pick_menu_item('Import XPRV')
+ need_keypress('1')
+ pick_menu_item(fname)
+ expect_ftux()
+ else:
+ original = Share.from_seed(bytes(range(64)), seed_type, 'test', SECRET, 0, 1)
+ import_codex32_ui('sd', original.to_string())
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ expected = dev.send_recv(CCProtocolPacker.get_xpub())
+ psbt = fake_txn(2, 2, master_xpub=expected, segwit_in=True)
+ _, signed_before = try_sign(psbt, finalize=True)
+
+ goto_shamir_split()
+ shamir_split_settings(3, 2)
+ hrp = 'cw' if seed_type == 'words' else seed_type if native else 'cx'
+ uid, shares, fnames = export_shares('sd', 3, 2, hrp=hrp,
+ sec_length=32 if seed_type == 'words' else 64)
+ press_cancel()
+ press_select()
+
+ unit_test('devtest/clear_seed.py')
+ # Recover the master seed directly from the device's exported files.
+ recover_codex32_shares([shares[2].to_string(), shares[0].to_string()], 'sd',
+ fnames=[fnames[2], fnames[0]])
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert sim_exec('RV.write(repr(pa.tmp_value))') == 'None'
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) == expected
+ if native:
+ assert active_secret() == native_encoding(original.to_string()).hex()
+ _, signed_after = try_sign(psbt, finalize=True)
+ assert signed_after == signed_before
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('hrp,size', [('ms', 32), ('cx', 64), ('cw', 24)])
+def test_recover_then_resplit(hrp, size, unit_test, recover_codex32_shares, expect_ftux,
+ settings_set, dev, active_secret, sim_exec, goto_shamir_split,
+ shamir_split_settings, export_shares, press_cancel, press_select,
+ reset_seed_words):
+ original = Share.from_seed(bytes(range(size)), hrp, 'cash', SECRET, 2, pad_val=1)
+ first = Share.from_seed(bytes(reversed(range(size))), hrp, 'cash', 'a', 2, pad_val=0)
+ old_shares = [first] + [generate_share([original, first], index) for index in ('c', 'd')]
+
+ unit_test('devtest/clear_seed.py')
+ recover_codex32_shares([s.to_string() for s in old_shares[:2]], 'sd')
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert active_secret() == native_encoding(original.to_string()).hex()
+ xpub = dev.send_recv(CCProtocolPacker.get_xpub())
+ snapshot = 'RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))'
+ before = sim_exec(snapshot)
+
+ goto_shamir_split()
+ shamir_split_settings(5, 3)
+ _, shares, fnames = export_shares('sd', 5, 3, hrp=hrp, sec_length=size)
+ uid = shares[0].uid
+
+ assert uid != original.uid
+ assert sim_exec(snapshot) == before
+ assert active_secret() == native_encoding(original.to_string()).hex()
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) == xpub
+ expected = Share.from_seed(original.to_seed_and_pad()[0], hrp, uid, SECRET, 3)
+ for subset in itertools.combinations(shares, 3):
+ recovered = generate_share(subset, SECRET)
+ assert recovered.to_string() == expected.to_string()
+ assert recovered.to_seed_and_pad() == (original.to_seed_and_pad()[0], 0)
+ assert bip32_node_from_codex32_share(recovered).hwif() == xpub
+ for subset in itertools.combinations(old_shares, 2):
+ assert generate_share(subset, SECRET).to_string() == original.to_string()
+ press_cancel()
+ press_select()
+
+ unit_test('devtest/clear_seed.py')
+ selected = (4, 0, 2)
+ recover_codex32_shares([shares[i].to_string() for i in selected], 'sd',
+ fnames=[fnames[i] for i in selected])
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert active_secret() == native_encoding(expected.to_string()).hex()
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) == xpub
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('sec_type,m_n,way,passphrase', [
+ ('words12', (2, 3), 'sd', ''),
+ ('cw12', (2, 3), 'sd', ''),
+ ('words12', (7, 9), 'nfc', ''),
+ ('words24', (2, 3), 'qr', ''),
+ ('words24', (7, 9), 'sd', ''),
+ ('xprv', (2, 3), 'nfc', ''),
+ ('xprv', (7, 9), 'qr', ''),
+ ('xprv', (2, 3), 'sd', ''),
+ ('words12', (2, 3), 'sd', 'test'),
+ ('words24', (2, 3), 'sd', 'test'),
+])
+def test_bip32_compat_shamir_split(sec_type, m_n, way, passphrase, set_seed_words, set_master_key,
+ set_encoded_secret,
+ reset_seed_words, goto_shamir_split, shamir_split_settings,
+ shamir_verify_recover, export_shares, skip_if_useless_way, press_cancel,
+ press_select, cap_story, is_headless, set_bip39_pw, dev, enable_nfc):
+ if way == 'qr' and is_headless:
+ pytest.skip('headless mode: QR tests disabled')
+
+ enable_nfc()
+ skip_if_useless_way(way)
+ if sec_type == 'cw12':
+ set_encoded_secret(native_encoding(CW_SHARES[0]))
+ elif sec_type == 'words12':
+ set_seed_words('record castle hammer issue crumble foil clap upper wealth mutual '
+ 'giraffe charge')
+ elif sec_type == 'xprv':
+ set_master_key(
+ 'tprv8ZgxMBicQKsPe2yGEX7PePdnNDMPe38D4Zm6zySg12VqFzxHjW4ZuVVYwhD65oH6eFPPozhX9YcB3Su8AScVV4584GRk1te63awwJFGS941')
+ else:
+ reset_seed_words()
+
+ if passphrase:
+ master_xpub = dev.send_recv(CCProtocolPacker.get_xpub())
+ set_bip39_pw(passphrase, reset=False)
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) != master_xpub
+
+ threshold, num_shares = m_n
+ goto_shamir_split(active='BIP-39 passphrase' if passphrase else None)
+ shamir_split_settings(num_shares, threshold)
+ word_backup = not passphrase and sec_type != 'xprv'
+ size = 16 if sec_type in ('words12', 'cw12') else 32
+ _, shares, _ = export_shares(way, num_shares, threshold,
+ hrp='cw' if word_backup else 'cx',
+ sec_length=size if word_backup else 64)
+ shamir_verify_recover(shares, threshold)
+
+ press_cancel()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'This split uses fresh randomness.' in story
+ assert 'Make sure you exported all shares.' in story
+ press_select()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('size', [16, 24, 32])
+@pytest.mark.parametrize('threshold,total', [(2, 3), (7, 9)])
+def test_cw1_words_roundtrip(size, threshold, total, set_seed_words, goto_shamir_split,
+ shamir_split_settings, export_shares, press_cancel, press_select,
+ recover_codex32_shares, unit_test, expect_ftux, settings_set,
+ get_secrets, active_secret, set_bip39_pw, dev, reset_seed_words,
+ goto_home, pick_menu_item, cap_story):
+ entropy = bytes(range(size))
+ words = Mnemonic('english').to_mnemonic(entropy)
+ set_seed_words(words)
+ original = dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000)
+ goto_shamir_split()
+ shamir_split_settings(total, threshold)
+ _, shares, _ = export_shares('sd', total, threshold, hrp='cw', sec_length=size)
+ for subset in itertools.combinations(shares, threshold):
+ assert generate_share(subset, SECRET).to_seed_and_pad() == (entropy, 0)
+ assert len(shares[0].to_string()) == {16: 48, 24: 61, 32: 74}[size]
+ press_cancel()
+ press_select()
+
+ unit_test('devtest/clear_seed.py')
+ recover_codex32_shares([s.to_string() for s in shares[-threshold:]], 'sd')
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == original
+ assert get_secrets()['mnemonic'] == words
+ recovered = generate_share(shares[-threshold:], SECRET)
+ assert active_secret() == native_encoding(recovered.to_string()).hex()
+ assert recovered.to_seed_and_pad() == (entropy, 0)
+
+ goto_home()
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Danger Zone')
+ pick_menu_item('Seed Functions')
+ pick_menu_item('View Secret')
+ press_select()
+ body = cap_story()[1]
+ for word in words.split():
+ assert word in body
+ press_cancel()
+
+ # Restored words still support the ordinary passphrase flow, independently
+ # checked against the BIP39 implementation on the host.
+ set_bip39_pw('TREZOR', reset=False)
+ expected = BIP32Node.from_master_secret(Mnemonic.to_seed(words, 'TREZOR'), netcode='XTN')
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == expected.hwif()
+ assert expected.hwif() != original
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('kind,missing_words', [
+ ('words', False), ('words', True), ('xprv', False),
+ ('ms', False), ('cx', False), ('cw', False),
+])
+def test_shamir_split_warning_settings(kind, missing_words, reset_seed_words, set_master_key,
+ set_encoded_secret, sim_exec, goto_shamir_split,
+ shamir_split_settings, export_shares,
+ shamir_verify_recover, press_cancel, press_select):
+ reset_seed_words()
+ if kind == 'xprv':
+ set_master_key(simulator_fixed_tprv)
+ elif kind in ('ms', 'cx', 'cw'):
+ value = {'ms': SHARES[0], 'cx': SHARES[3], 'cw': CW_SHARES[0]}[kind]
+ set_encoded_secret(native_encoding(value))
+
+ words = kind in ('words', 'cw')
+ if missing_words:
+ # Legacy words wallets may not have this setting yet.
+ sim_exec("settings.remove_key('words')")
+
+ goto_shamir_split(words=words)
+ shamir_split_settings(3, 2)
+ hrp = 'cw' if words else 'cx' if kind == 'xprv' else kind
+ _, shares, _ = export_shares('sd', 3, 2, hrp=hrp)
+ shamir_verify_recover(shares, 2)
+ press_cancel()
+ press_select()
+ reset_seed_words()
+
+
+def test_shamir_split_storage_warning(reset_seed_words, goto_shamir_split,
+ shamir_split_settings, cap_story, press_cancel,
+ cap_menu, press_select):
+ reset_seed_words()
+ goto_shamir_split()
+ shamir_split_settings(3, 2)
+
+ title, story = cap_story()
+ assert title == 'WARNING'
+ assert 'Keep threshold-or-more shares on separate devices.' in story
+ press_cancel()
+ time.sleep(.2)
+
+ assert re.fullmatch(r'2 of 3 \[[0-9A-Z]{4}\]', cap_menu()[0])
+
+ press_cancel()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'This split uses fresh randomness.' in story
+ press_select()
+
+
+@pytest.mark.parametrize('threshold', [1, 9])
+def test_shamir_split_rejects_threshold_outside_share_range(threshold, reset_seed_words, goto_shamir_split,
+ enter_number, cap_screen, cap_story, press_select):
+ reset_seed_words()
+ goto_shamir_split()
+ assert 'Number of shares (2-9):' in cap_screen()
+ enter_number(3)
+ time.sleep(.1)
+ assert 'Threshold (2-3):' in cap_screen()
+ enter_number(threshold)
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'Threshold must be between 2 and 3.' in story
+ press_select()
+
+
+@pytest.mark.parametrize('num_shares', [0, 1])
+def test_shamir_split_rejects_too_few_shares(num_shares, reset_seed_words, goto_shamir_split,
+ enter_number, cap_screen, cap_story, press_select):
+ reset_seed_words()
+ goto_shamir_split()
+ assert 'Number of shares (2-9):' in cap_screen()
+ enter_number(num_shares)
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'Number of shares must be at least 2.' in story
+ press_select()
+
+
+def test_shamir_split_m_of_m_warning_cancel(reset_seed_words, goto_shamir_split,
+ enter_number, cap_screen, cap_story, press_cancel):
+ reset_seed_words()
+ goto_shamir_split()
+ assert 'Number of shares (2-9):' in cap_screen()
+ enter_number(3)
+ time.sleep(.1)
+ assert 'Threshold (2-3):' in cap_screen()
+ enter_number(3)
+ time.sleep(.1)
+
+ title, story = cap_story()
+ assert title == 'WARNING'
+ assert 'N-of-N has no redundancy. Consider a lower threshold.' in story
+ press_cancel()
+
+
+@pytest.mark.parametrize('hrp,sec_len,m_n,way,initial_threshold', [
+ ('ms', 16, (3, 5), 'sd', 0),
+ ('ms', 16, (9, 9), 'nfc', 0),
+ ('ms', 32, (3, 5), 'qr', 0),
+ ('ms', 32, (9, 9), 'sd', 0),
+ ('ms', 64, (3, 5), 'nfc', 0),
+ ('ms', 64, (9, 9), 'qr', 0),
+ ('cx', 64, (3, 5), 'sd', 0),
+ ('ms', 16, (3, 5), 'vdisk', 0),
+ ('ms', 16, (3, 5), 'sd', 2),
+ ('cx', 64, (9, 9), 'sd', 0),
+])
+def test_codex32_shamir_split(hrp, sec_len, m_n, way, initial_threshold, set_encoded_secret, goto_shamir_split,
+ shamir_split_settings, shamir_verify_recover, export_shares, skip_if_useless_way,
+ press_cancel, press_select, cap_story, is_headless, enable_nfc):
+
+ if way == 'qr' and is_headless:
+ pytest.skip('headless mode: QR tests disabled')
+
+ enable_nfc() # can be disabled by previous fixtures
+ skip_if_useless_way(way)
+ secret = Share.from_seed(prandom(sec_len), hrp, 'cash', SECRET, initial_threshold)
+ set_encoded_secret(native_encoding(secret.to_string()))
+
+ threshold, num_shares = m_n
+ goto_shamir_split()
+ shamir_split_settings(num_shares, threshold)
+ _, shares, _ = export_shares(way, num_shares, threshold, hrp=hrp, sec_length=sec_len)
+ shamir_verify_recover(shares, threshold)
+
+ press_cancel()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'This split uses fresh randomness.' in story
+ press_select()
+
+
+@pytest.fixture
+def recover_codex32_shares(goto_shamir_recover, cap_story, need_keypress, is_q1, press_nfc,
+ nfc_write_text, scan_a_qr, enter_bech32, microsd_path,
+ virtdisk_path, garbage_collector, pick_menu_item, cap_screen):
+
+ def doit(shares, way, tmp=False, seed_vault=False, fnames=None, spaced=False):
+ def format_share(value):
+ return ' '.join(value[i:i+4] for i in range(0, len(value), 4)) if spaced else value
+
+ first = Share.parse(shares[0])
+ threshold = first.threshold
+ uid = first.uid
+ goto_shamir_recover(tmp=tmp, seed_vault=seed_vault)
+ time.sleep(.1)
+ _, story = cap_story()
+ check_recover_story(story)
+
+ if way in ('sd', 'vdisk') and fnames is None:
+ path_f = microsd_path if way == 'sd' else virtdisk_path
+ fnames = []
+ for value in shares[:threshold]:
+ fname = '%s_share_%s.txt' % (uid, value[8])
+ fpath = path_f(fname)
+ with open(fpath, 'w') as fd:
+ fd.write(format_share(value))
+ garbage_collector.append(fpath)
+ fnames.append(fname)
+
+ for pos, value in enumerate(shares[:threshold], 1):
+ if way == 'nfc':
+ press_nfc()
+ time.sleep(.1)
+ nfc_write_text(format_share(value))
+ time.sleep(.4)
+ elif way == 'qr':
+ assert is_q1
+ need_keypress(KEY_QR)
+ scan_a_qr(format_share(value))
+ time.sleep(1)
+ elif way == 'input':
+ need_keypress('0')
+ if pos > 1:
+ time.sleep(.1)
+ if is_q1:
+ assert value[:8].upper() in cap_screen()
+ else:
+ # Mk text capture only includes the selected picker character.
+ assert cap_screen().splitlines()[-1] == value[7].upper()
+ if not is_q1:
+ need_keypress('9')
+ value = value[8:]
+ value = format_share(value)
+ # Q must accept lowercase keystrokes with the uppercase prefix.
+ value = value.lower() if is_q1 else value.upper()
+ enter_bech32(value)
+ else:
+ need_keypress('1' if way == 'sd' else '2')
+ time.sleep(.1)
+ pick_menu_item(fnames[pos-1])
+
+ if pos < threshold:
+ time.sleep(.1)
+ _, story = cap_story()
+ check_recover_story(story, threshold, uid, pos, first.hrp)
+
+ return doit
+
+
+@pytest.mark.parametrize('sec_type', ['mnemonic', 'xprv', 'ms16', 'ms32', 'ms64'])
+@pytest.mark.parametrize('m_n', [(2, 3), (7, 9)])
+def test_shamir_recover_secret_types(sec_type, m_n, generate_shares_from_secret,
+ recover_codex32_shares, unit_test, expect_ftux,
+ settings_set, dev, sim_exec, reset_seed_words, active_secret):
+ unit_test('devtest/clear_seed.py')
+ threshold, num_shares = m_n
+ if sec_type == 'mnemonic':
+ secret, shares, node = generate_shares_from_secret(
+ num_shares, threshold, mnemonic=simulator_fixed_words)
+ elif sec_type == 'xprv':
+ secret, shares, node = generate_shares_from_secret(
+ num_shares, threshold, xprv=simulator_fixed_tprv)
+ else:
+ secret, shares, node = generate_shares_from_secret(
+ num_shares, threshold, slen=int(sec_type[2:]))
+
+ recover_codex32_shares(shares, 'sd')
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == node.hwif()
+ assert active_secret() == native_encoding(secret.to_string()).hex()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('hrp,size,way', [
+ ('ms', 16, 'sd'),
+ ('ms', 16, 'nfc'),
+ ('ms', 16, 'qr'),
+ ('ms', 64, 'vdisk'),
+ ('ms', 16, 'input'), # Mk character picker must retain the next share's prefix.
+ ('ms', 64, 'input'),
+ ('cx', 64, 'input'),
+ ('cx', 64, 'vdisk'),
+ ('cx', 64, 'nfc'),
+ ('cx', 64, 'qr'),
+])
+def test_shamir_recover_import_ways(hrp, size, way, is_q1, skip_if_useless_way, set_seed_words, generate_shares_from_secret,
+ recover_codex32_shares, confirm_tmp_seed, verify_ephemeral_secret_ui, dev, enable_nfc,
+ sim_exec, enable_hw_ux, settings_set, reset_seed_words, active_secret):
+ if way == 'input' and size == 64 and not is_q1:
+ pytest.skip('long manual entry covered on Q')
+ set_seed_words('extra sport youth surge capital category kid ginger extend way cause hamster')
+ settings_set('seedvault', False)
+
+ enable_nfc()
+ skip_if_useless_way(way)
+ if hrp == 'cx':
+ secret, shares, node = generate_shares_from_secret(3, 2, mnemonic='abandon ' * 11 + 'about')
+ else:
+ secret, shares, node = generate_shares_from_secret(3, 2, slen=size)
+ recover_codex32_shares(shares, way, tmp=True, spaced=way in ('input', 'vdisk'))
+ confirm_tmp_seed(expect_xfp=node.fingerprint().hex().upper())
+ verify_ephemeral_secret_ui(xpub=node.hwif(),
+ expected_xfp=node.fingerprint().hex().upper())
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == node.hwif()
+ assert active_secret() == native_encoding(secret.to_string()).hex()
+ reset_seed_words()
+
+
+def test_shamir_recover_warning_with_only_temporary_seed(unit_test, import_codex32_ui,
+ confirm_tmp_seed, goto_shamir_recover,
+ cap_story, press_cancel, sim_exec, reset_seed_words):
+ unit_test('devtest/clear_seed.py')
+ try:
+ import_codex32_ui('sd', IMPORT_SHARES[0], tmp=True)
+ confirm_tmp_seed()
+ state = sim_exec(
+ 'from pincodes import pa; '
+ 'RV.write(repr(pa.is_secret_blank() and bool(pa.tmp_value)))')
+ assert state == 'True'
+
+ goto_shamir_recover(tmp=True, tmp_active=True)
+ time.sleep(.1)
+ _, story = cap_story()
+ check_recover_story(story)
+ press_cancel()
+ finally:
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('tmp', [False, True])
+def test_shamir_recover_seedless_saved_shares(tmp, unit_test, import_codex32_ui,
+ confirm_tmp_seed, goto_shamir_recover,
+ cap_story, press_cancel, press_select,
+ need_keypress, microsd_path, garbage_collector,
+ pick_menu_item, sim_exec, master_settings_get,
+ reset_seed_words):
+ unit_test('devtest/clear_seed.py')
+ sim_exec('settings.load()')
+ try:
+ if tmp:
+ import_codex32_ui('sd', IMPORT_SHARES[0], tmp=True)
+ confirm_tmp_seed()
+
+ share = Share.from_seed(os.urandom(16), 'ms', 'name', 'a', 2, 0)
+ fname = 'seedless_share.txt'
+ path = microsd_path(fname)
+ garbage_collector.append(path)
+ with open(path, 'w') as fd:
+ fd.write(share.to_string())
+
+ goto_shamir_recover(tmp=tmp, tmp_active=tmp)
+ need_keypress('1')
+ pick_menu_item(fname)
+ press_cancel()
+ story = cap_story()[1]
+ assert 'Press (1) to Save & Exit.' in story
+ assert ('WARNING: Without a master wallet, saved shares will not be'
+ ' protected by encryption.') in story
+ need_keypress('1')
+ time.sleep(.1)
+ assert master_settings_get('c32_shares') == [share.to_string()]
+
+ # Reload the seedless settings from flash, including when a temporary seed is active.
+ reload_saved = ('from nvstore import SettingsObject; '
+ 'saved = SettingsObject(bytes(32)); saved.load(); '
+ 'RV.write(repr(saved.get("c32_shares"))); ')
+ if tmp:
+ reload_saved += 'SettingsObject.master_sv_data["c32_shares"] = saved.get("c32_shares")'
+ else:
+ reload_saved += 'settings.load()'
+ assert eval(sim_exec(reload_saved)) == [share.to_string()]
+
+ goto_shamir_recover(tmp=tmp, tmp_active=tmp)
+ check_recover_story(cap_story()[1], 2, 'name', 1, 'ms')
+ press_cancel()
+ press_cancel() # Keep collecting.
+ check_recover_story(cap_story()[1], 2, 'name', 1, 'ms')
+ press_cancel()
+ press_select() # Discard the saved collection.
+ time.sleep(.1)
+ assert master_settings_get('c32_shares') == []
+ assert sim_exec(reload_saved) == '[]'
+ finally:
+ reset_seed_words()
+
+
+def test_shamir_recover_failures(reset_seed_words, goto_shamir_recover, generate_shares_from_secret,
+ microsd_path, garbage_collector, need_keypress, pick_menu_item, cap_story,
+ enable_nfc, press_nfc, nfc_write_text, press_select, press_cancel, cap_menu,
+ import_ephemeral_xprv, confirm_tmp_seed, sim_exec, active_secret,
+ master_settings_get):
+ reset_seed_words()
+ import_ephemeral_xprv('sd', from_main=True, seed_vault=False)
+ snapshot = 'RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))'
+ before = sim_exec(snapshot)
+ enable_nfc()
+ secret, shares, _ = generate_shares_from_secret(3, 2, slen=16, uid='ua7l')
+ goto_shamir_recover(tmp=True, tmp_active=True)
+ _, story = cap_story()
+ check_recover_story(story)
+
+ fname = 'ua7l_share_a.txt'
+ fpath = microsd_path(fname)
+ with open(fpath, 'w') as fd:
+ fd.write(shares[0])
+ garbage_collector.append(fpath)
+ need_keypress('1')
+ pick_menu_item(fname)
+ time.sleep(.1)
+ _, story = cap_story()
+ check_recover_story(story, 2, 'ua7l', 1, 'ms')
+ assert not master_settings_get('c32_shares')
+
+ def send_bad(value, message):
+ press_nfc()
+ time.sleep(.1)
+ nfc_write_text(value)
+ time.sleep(.3)
+ title, body = cap_story()
+ assert title == 'FAILED'
+ assert message in body
+ assert sim_exec(snapshot) == before
+ press_select()
+ time.sleep(.1)
+ _, body = cap_story()
+ check_recover_story(body, 2, 'ua7l', 1, 'ms')
+
+ send_bad(Share.from_seed(os.urandom(32), 'ms', 'ua7l', 'c', 2, 0).to_string(),
+ 'Share set does not match the first share.')
+ send_bad(Share.from_seed(os.urandom(64), 'cx', 'ua7l', 'c', 2, 0).to_string(),
+ 'Share set does not match the first share.')
+ send_bad(Share.from_seed(os.urandom(16), 'ms', 'ua7l', 'c', 3, 0).to_string(),
+ 'Share set does not match the first share.')
+ send_bad(Share.from_seed(os.urandom(16), 'ms', 'cash', 'c', 2, 0).to_string(),
+ 'Share set does not match the first share.')
+ send_bad(secret.to_string(), "Use 'Import Codex32' for secret share 's'.")
+ send_bad(shares[0], 'That share index was already collected.')
+ send_bad(Share.from_seed(os.urandom(16), 'ms', 'ua7l', 'a', 2, 0).to_string(),
+ 'That share index was already collected.')
+
+ for value, error in INVALID_C32:
+ send_bad(value, error)
+
+ press_cancel()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'Discard collected shares?' in story
+ assert 'Without a master wallet' not in story
+ press_cancel()
+ time.sleep(.1)
+ _, story = cap_story()
+ check_recover_story(story, 2, 'ua7l', 1, 'ms')
+ assert sim_exec(snapshot) == before
+ # Canceling discard keeps collecting; only Save & Exit writes the partial set.
+ assert not master_settings_get('c32_shares')
+ press_cancel()
+ assert 'Press (1) to Save & Exit.' in cap_story()[1]
+ need_keypress('1')
+ time.sleep(.1)
+ assert 'Shamir Recover' in cap_menu()
+ sim_exec('from nvstore import SettingsObject; '
+ 'saved = SettingsObject(SettingsObject.master_nvram_key); saved.load(); '
+ 'SettingsObject.master_sv_data["c32_shares"] = saved.get("c32_shares", [])')
+ goto_shamir_recover(tmp=True, tmp_active=True)
+ check_recover_story(cap_story()[1], 2, 'ua7l', 1, 'ms')
+ press_nfc()
+ time.sleep(.1)
+ nfc_write_text(shares[1])
+ time.sleep(.3)
+ confirm_tmp_seed()
+ assert active_secret() == native_encoding(secret.to_string()).hex()
+ assert master_settings_get('c32_shares') == []
+
+ # Confirming discard clears the collection without replacing the active wallet.
+ before = sim_exec(snapshot)
+ goto_shamir_recover(tmp=True, tmp_active=True)
+ need_keypress('1')
+ pick_menu_item(fname)
+ time.sleep(.1)
+ press_cancel()
+ press_select()
+ time.sleep(.1)
+ assert 'Shamir Recover' in cap_menu()
+ assert sim_exec(snapshot) == before
+ assert master_settings_get('c32_shares') == []
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('hrp,size', [('ms', 16), ('ms', 32), ('cx', 64), ('cw', 24)])
+@pytest.mark.parametrize('state', ['blank', 'current', 'temporary', 'blank_temporary'])
+@pytest.mark.parametrize('threshold', [2, 7])
+def test_derive_codex32_shares(hrp, size, state, threshold, reset_seed_words, unit_test, set_encoded_secret,
+ import_ephemeral_xprv, goto_codex32_menu, pick_menu_item,
+ cap_story, cap_menu, press_select, press_cancel, need_keypress,
+ microsd_path, garbage_collector, sim_exec, master_settings_get):
+ reset_seed_words()
+ output_indices = IDX_ORDER[threshold + 1:10].upper()
+ if (hrp, size, threshold) == ('ms', 16, 2):
+ # Published BIP-93 vector: A + C -> D, including nonzero padding.
+ a = Share.parse('MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM')
+ c = Share.parse('MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN')
+ shares = [a, c]
+ expected = 'MS12NAMEDLL4F8JLH4E5VDVULDLFXU2JHDNLSM97XVENRXEG'
+ else:
+ shares = [Share.from_seed(os.urandom(size), hrp, 'name', idx, threshold, 1)
+ for idx in IDX_ORDER[1:threshold + 1]]
+ expected = generate_share(shares, output_indices[0].lower()).to_string()
+ if state in ('blank', 'blank_temporary'):
+ unit_test('devtest/clear_seed.py')
+ sim_exec('settings.load()')
+ if state == 'blank_temporary':
+ import_ephemeral_xprv('sd', from_main=True, seed_vault=False)
+ elif state == 'current':
+ set_encoded_secret(native_encoding(SHARES[0]))
+ else:
+ import_ephemeral_xprv('sd', from_main=True, seed_vault=False)
+ snapshot = 'RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))'
+ before = sim_exec(snapshot)
+ goto_codex32_menu(tmp=state != 'blank', tmp_active=state in ('temporary', 'blank_temporary'))
+ pick_menu_item('Derive Shares')
+ title, story = cap_story()
+ assert title == 'WARNING'
+ assert 'Import a threshold number of shares from one Codex32 set.' in story
+ assert "enough shares to reconstruct the secret share 'S' and recover the combined wallet." in story
+ assert ('Your active wallet will remain unchanged.' in story) == (state != 'blank')
+ press_select()
+ assert "include this Coldcard's" not in cap_story()[1]
+ press_select()
+
+ for pos, share in enumerate(shares):
+ if pos:
+ check_recover_story(cap_story()[1], threshold, 'name', pos, hrp)
+ else:
+ check_recover_story(cap_story()[1])
+ name = 'derive_%s.txt' % share.index
+ path = microsd_path(name)
+ garbage_collector.append(path)
+ with open(path, 'w') as fd:
+ fd.write(share.to_string())
+ need_keypress('1')
+ pick_menu_item(name)
+
+ if share == shares[0]:
+ press_cancel()
+ assert 'Press (1) to Save & Exit.' in cap_story()[1]
+ need_keypress('1')
+ time.sleep(.1)
+ assert master_settings_get('c32_shares') == [share.to_string()]
+ pick_menu_item('Derive Shares')
+ press_select() # warning
+ press_select() # collection introduction
+ check_recover_story(cap_story()[1], threshold, 'name', 1, hrp)
+
+ assert master_settings_get('c32_shares') == []
+ menu = cap_menu()
+ assert menu[0] == '%d required [NAME]' % threshold
+ assert menu[1:] == ["Share '%s'" % idx for idx in output_indices]
+ secret = generate_share(shares, SECRET).to_string()
+ for index in output_indices[:2]:
+ pick_menu_item("Share '%s'" % index)
+ value = parse_rendered_codex32(cap_story()[1])
+ if index == output_indices[0]:
+ assert value == expected
+ assert value == generate_share(shares, index.lower()).to_string()
+ derived = Share.parse(value)
+ for omitted in range(threshold):
+ combo = shares[:omitted] + shares[omitted + 1:] + [derived]
+ assert generate_share(combo, SECRET).to_string() == secret
+ need_keypress('1')
+ story = cap_story()[1]
+ assert 'written:' in story
+ assert ('Signature:' in story) == (state != 'blank')
+ path = microsd_path(story.split('\n\n')[1])
+ garbage_collector.append(path)
+ if state != 'blank':
+ garbage_collector.append(microsd_path(story.split('\n\n')[-1]))
+ with open(path) as fd:
+ assert fd.read() == value
+ press_cancel() # export result
+ press_cancel() # share display
+ press_cancel()
+ title, story = cap_story()
+ assert title == 'DISCARD?'
+ assert 'Exit and discard collected shares?' in story
+ press_cancel() # keep the session, including its original inputs
+ pick_menu_item("Share '%s'" % output_indices[0])
+ assert parse_rendered_codex32(cap_story()[1]) == expected
+ press_cancel()
+ press_cancel()
+ press_select()
+ assert 'Derive Shares' in cap_menu()
+ assert sim_exec(snapshot) == before
+ reset_seed_words()
+
+
+def test_shamir_recover_invalid_key(reset_seed_words, recover_codex32_shares,
+ cap_story, sim_exec, settings_get):
+ reset_seed_words()
+ snapshot = 'RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))'
+ before = sim_exec(snapshot)
+ invalid = Share.from_seed(bytes(range(32)) + bytes(32), 'cx', 'zerq', SECRET, 2)
+ first = Share.from_seed(bytes(range(64)), 'cx', 'zerq', 'a', 2, 0)
+ second = generate_share([invalid, first], 'c')
+ recover_codex32_shares([first.to_string(), second.to_string()], 'sd', tmp=True)
+ title, body = cap_story()
+ assert title == 'FAILED'
+ assert 'bip32 lottery winner' in body
+ assert sim_exec(snapshot) == before
+ assert settings_get('c32_shares', []) == []
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('size', ['128-bit', '256-bit'])
+@pytest.mark.parametrize('tmp', [False, True])
+@pytest.mark.parametrize('dice', [False, True])
+def test_new_codex32_wallet(size, tmp, dice, unit_test, goto_codex32_menu, pick_menu_item,
+ enter_mash_entropy, cap_story, is_q1, need_keypress, cap_screen_qr, active_secret,
+ press_cancel, press_nfc, nfc_read_text, enable_nfc,
+ press_select, pass_codex32_quiz, expect_ftux, confirm_tmp_seed,
+ verify_ephemeral_secret_ui, dev, sim_exec, reset_seed_words, is_headless):
+ if not tmp:
+ unit_test('devtest/clear_seed.py')
+ enable_nfc()
+ goto_codex32_menu(tmp=tmp)
+ pick_menu_item('Generate')
+ if dice:
+ pick_menu_item('Advanced')
+ pick_menu_item(size + ' Dice Roll')
+ assert 'only source of randomness' in cap_story()[1]
+ press_select()
+ rolls = ('123456' * 17)[:50 if size == '128-bit' else 99]
+ for ch in rolls[:-1]:
+ need_keypress(ch)
+ press_select()
+ time.sleep(.1)
+ story = cap_story()[1]
+ assert 'need at least %d rolls' % len(rolls) in story
+ assert 'word seeds' not in story
+ press_select()
+ need_keypress(rolls[-1])
+ press_select()
+ else:
+ pick_menu_item(size)
+ time.sleep(3.2)
+ enter_mash_entropy()
+ time.sleep(1.2)
+
+ title, story = cap_story()
+ assert title == ('Record Codex32' if is_q1 else 'NO-TITLE')
+ value = parse_rendered_codex32(story.split('\n\n', 1)[0])
+ assert value == value.upper()
+ share = Share.parse(value)
+ assert len(share.to_seed_and_pad()[0]) == (16 if size == '128-bit' else 32)
+ if dice:
+ from hashlib import sha256
+ assert share.to_seed_and_pad()[0] == sha256(rolls.encode('ascii')).digest()[:16 if size == '128-bit' else 32]
+ assert (share.uid, share.index, share.threshold) == ('seed', SECRET, 0)
+ assert share.to_seed_and_pad()[1] == 0
+ assert 'ID:' not in story
+ assert 'change ID' not in story
+ if tmp:
+ assert 'Press (6) to skip the verification.' in story
+
+ need_keypress(KEY_QR if is_q1 else '1')
+ if not (is_q1 and is_headless):
+ assert cap_screen_qr().decode('ascii') == value
+ press_cancel()
+ press_nfc()
+ time.sleep(.2)
+ assert nfc_read_text() == value
+ time.sleep(.1)
+ press_cancel()
+
+ if tmp:
+ need_keypress('6')
+ time.sleep(.1)
+ _, story = cap_story()
+ assert 'Skip verification of the recorded Codex32 share?' in story
+ press_select()
+ confirm_tmp_seed()
+ else:
+ press_select()
+ pass_codex32_quiz(value)
+ expect_ftux()
+
+ actual_xpub = dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000)
+ node = bip32_node_from_codex32_share(
+ share, testnet=actual_xpub.startswith('tpub'))
+ assert actual_xpub == node.hwif()
+ assert active_secret() == native_encoding(value).hex()
+ displayed = sim_exec(
+ 'from stash import SensitiveValues; from actions import render_master_secrets\n'
+ 'with SensitiveValues() as sv:\n'
+ ' RV.write(render_master_secrets(sv.mode, sv.raw, sv.node)[2])')
+ assert displayed == value
+ if tmp:
+ verify_ephemeral_secret_ui(xpub=actual_xpub)
+ reset_seed_words()
+
+
+@pytest.fixture
+def start_codex32_generation(reset_seed_words, unit_test, settings_set, sim_exec,
+ goto_codex32_menu, pick_menu_item, enter_mash_entropy,
+ cap_story):
+ def doit(tmp):
+ reset_seed_words()
+ settings_set('seedvault', False)
+ if not tmp:
+ unit_test('devtest/clear_seed.py')
+ before = sim_exec('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))')
+ goto_codex32_menu(tmp=tmp)
+ pick_menu_item('Generate')
+ pick_menu_item('128-bit')
+ time.sleep(3.2)
+ enter_mash_entropy()
+ time.sleep(1.2)
+ value = parse_rendered_codex32(cap_story()[1].split('\n\n')[0])
+ assert len(value) == 48
+ return before, value
+ return doit
+
+
+@pytest.mark.parametrize('tmp', [False, True])
+def test_codex32_quiz_wrong_answer_and_review(tmp, start_codex32_generation,
+ press_select, cap_story, need_keypress, pass_codex32_quiz,
+ confirm_tmp_seed, expect_ftux, sim_exec, reset_seed_words,
+ active_secret):
+ before, value = start_codex32_generation(tmp)
+ press_select()
+ title, story = cap_story()
+ group = int(re.search(r'Group (\d+) is\?', title).group(1)) - 1
+ right = value[group*4:(group+1)*4]
+ choices = dict(re.findall(r' ([123]): ([0-9A-Z]+)', story))
+ need_keypress(next(k for k, text in choices.items() if text != right))
+ time.sleep(2.3)
+ assert cap_story()[0] == title
+ press_select() # Review the recorded groups without passing this question.
+ assert parse_rendered_codex32(cap_story()[1]) == value
+ assert sim_exec('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))') == before
+ press_select()
+ assert cap_story()[0] == title
+ pass_codex32_quiz(value)
+ if tmp:
+ confirm_tmp_seed()
+ else:
+ expect_ftux()
+ assert active_secret() == native_encoding(value).hex()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('tmp', [False, True])
+@pytest.mark.parametrize('stage', ['record', 'quiz'])
+def test_codex32_generation_discard(tmp, stage, start_codex32_generation,
+ press_select, press_cancel, cap_story,
+ sim_exec, cap_menu, reset_seed_words):
+ before, value = start_codex32_generation(tmp)
+ if stage == 'quiz':
+ press_select()
+ assert cap_story()[0].startswith('Group ')
+ press_cancel()
+ assert 'Throw away this secret and stop?' in cap_story()[1]
+ press_cancel() # Keep the same secret and return to its recording screen.
+ assert parse_rendered_codex32(cap_story()[1].split('\n\n')[0]) == value
+ assert sim_exec('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))') == before
+ if stage == 'quiz':
+ press_select()
+ press_cancel()
+ assert 'Throw away this secret and stop?' in cap_story()[1]
+ press_select()
+ assert sim_exec('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))') == before
+ assert '128-bit' in cap_menu()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('value', IMPORT_SHARES[:13] + CW_SHARES)
+def test_import_codex32_vectors(value, unit_test, import_codex32_ui,
+ expect_ftux, sim_exec, dev, settings_set, reset_seed_words, active_secret):
+ unit_test('devtest/clear_seed.py')
+ import_codex32_ui('sd', value)
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ share = Share.parse(value)
+ assert active_secret() == native_encoding(value).hex()
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == \
+ bip32_node_from_codex32_share(share).hwif()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('value', SHARES + CW_SHARES)
+@pytest.mark.parametrize('way,tmp', [
+ ('sd', True),
+ ('vdisk', False),
+ ('nfc', True),
+ ('qr', False),
+ ('input', True),
+])
+def test_import_codex32(way, tmp, value, is_q1, skip_if_useless_way,
+ enable_nfc, enable_hw_ux, import_codex32_ui, expect_ftux,
+ confirm_tmp_seed, verify_ephemeral_secret_ui, sim_exec, dev, settings_set,
+ reset_seed_words, unit_test, set_seed_words, active_secret):
+ if way == 'input' and len(value) == 127 and not is_q1:
+ pytest.skip('long manual entry covered on Q')
+ settings_set('seedvault', False)
+ if tmp:
+ set_seed_words('extra sport youth surge capital category kid ginger extend way cause hamster')
+ else:
+ unit_test('devtest/clear_seed.py')
+ if way == 'nfc':
+ enable_nfc()
+ elif way == 'vdisk':
+ enable_hw_ux('vdisk')
+ skip_if_useless_way(way)
+
+ entered = ' '.join(value[i:i+4] for i in range(0, len(value), 4)) if len(value) == 127 else value
+ import_codex32_ui(way, entered, tmp=tmp)
+ share = Share.parse(value)
+ node = bip32_node_from_codex32_share(share)
+ if tmp:
+ confirm_tmp_seed(expect_xfp=node.fingerprint().hex().upper())
+ verify_ephemeral_secret_ui(xpub=node.hwif())
+ else:
+ expect_ftux()
+ settings_set('chain', 'XTN')
+ assert active_secret() == native_encoding(value).hex()
+ assert dev.send_recv(CCProtocolPacker.get_xpub(), timeout=5000) == node.hwif()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('key', [0,
+ 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141,
+ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff,
+], ids=['zero', 'order', 'above-order'])
+@pytest.mark.parametrize('state,vault', [
+ ('blank', False), ('main', False), ('temporary', False),
+ ('main', True), ('temporary', True),
+])
+def test_import_codex32_invalid_key_preserves_state(key, state, vault,
+ unit_test, reset_seed_words, settings_set,
+ sim_exec, import_codex32_ui, cap_story):
+ reset_seed_words()
+ if state == 'blank':
+ unit_test('devtest/clear_seed.py')
+ settings_set('seedvault', vault)
+ if state == 'temporary':
+ sim_exec('from stash import SecretStash; '
+ 'pa.tmp_secret(SecretStash.encode(master_secret=bytes(range(32))))')
+
+ snapshot = (
+ 'RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value, '
+ 'pa.is_secret_blank(), settings.nvram_key, settings.current, '
+ 'settings.master_get("seeds", []))))')
+ before = sim_exec(snapshot)
+ value = Share.from_seed(bytes(32) + key.to_bytes(32, 'big'),
+ 'cx', 'test', SECRET, 0).to_string()
+ try:
+ import_codex32_ui('sd', value, tmp=state != 'blank',
+ seed_vault=vault, tmp_active=state == 'temporary')
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'Failed to import.' in story
+ assert 'bip32 lottery winner' in story
+ assert sim_exec(snapshot) == before
+ finally:
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('value,error', INVALID_C32)
+def test_import_codex32_garbage(value, error, unit_test, import_codex32_ui, cap_story, reset_seed_words):
+ unit_test('devtest/clear_seed.py')
+ import_codex32_ui('sd', value)
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'Unable to parse Codex32 share.' in story
+ assert error in story
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('value', IMPORT_SHARES[13:] + [CW_SHARE_A])
+@pytest.mark.parametrize('state,vault', [('blank', False), ('main', True), ('temporary', True)])
+def test_non_secret_import_preserves_state(value, state, vault, unit_test, reset_seed_words,
+ settings_set, sim_exec, import_codex32_ui, cap_story):
+ reset_seed_words()
+ if state == 'blank':
+ unit_test('devtest/clear_seed.py')
+ settings_set('seedvault', vault)
+ if state == 'temporary':
+ sim_exec('from stash import SecretStash; '
+ 'pa.tmp_secret(SecretStash.encode(master_secret=bytes(range(32))))')
+ snapshot = ('RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value, '
+ 'pa.is_secret_blank(), settings.nvram_key, settings.current, '
+ 'settings.master_get("seeds", []))))')
+ before = sim_exec(snapshot)
+ try:
+ import_codex32_ui('sd', value, tmp=state != 'blank', seed_vault=vault,
+ tmp_active=state == 'temporary')
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'Failed to import.' in story
+ assert 'Need secret share S. Use Shamir Recover' in story
+ assert sim_exec(snapshot) == before
+ finally:
+ reset_seed_words()
+
+
+def test_recover_does_not_use_seed_vault(reset_seed_words, settings_set, goto_shamir_recover,
+ cap_story, press_cancel):
+ reset_seed_words()
+ settings_set('seedvault', True)
+ goto_shamir_recover(tmp=True, seed_vault=True)
+ assert 'Seed Vault' not in cap_story()[1]
+ press_cancel()
+ reset_seed_words()
+
+
+@pytest.mark.parametrize('size', [24, 48])
+def test_shamir_split_unsupported_master_size(size, reset_seed_words, set_encoded_secret,
+ goto_shamir_split, shamir_split_settings,
+ cap_story, sim_exec):
+ encoded = bytes([size]) + bytes(range(size)) + bytes(71-size)
+ set_encoded_secret(encoded)
+ goto_shamir_split()
+ shamir_split_settings(3, 2)
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'MS1 requires a 128, 256 or 512-bit master seed.' in story
+ assert sim_exec('from utils import B2A; RV.write(B2A(pa.fetch()))') == encoded.hex()
+ reset_seed_words()
### testing/test_codex32_extensions.py
@@ -0,0 +1,227 @@
+# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
+# Host-only checks for the published, fixed interoperability vectors.
+# Run without simulator fixtures: pytest --noconftest testing/test_codex32_extensions.py
+
+import itertools
+import json
+from pathlib import Path
+import sys
+
+import pytest
+from mnemonic import Mnemonic
+from bip32 import BIP32Node, PrvKeyNode
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'shared'))
+from codex32 import (Share, generate_share, bech32_to_array,
+ codex32_create_checksum, codex32_verify_checksum)
+
+VECTORS = json.loads((ROOT / 'docs/codex32-extension-vectors.json').read_text())
+SETS = VECTORS['valid_sets']
+
+
+@pytest.mark.parametrize('body_length,checksum,valid', [
+ (75, 'daqeydmnn8erj', True), # Expanded length 93: last regular codeword.
+ (76, 'gta055y8whlfr6l', True), # Expanded length 96: first long codeword.
+ (77, '86ylp05xy3rmumt', True),
+ (78, '3773k4f8q367s6f', True),
+ (79, 'c4nkwt4vc4swlj3', True),
+ (80, 'ls6mre8thxl9486', True),
+ (81, '7x2z8ksk6ex5esy', True),
+ (1003, 'k5jmj358y6v4y0v', True), # Expanded length 1023: last long codeword.
+ (1004, 'kxyxut96dnfpnt0', False), # Correct residue, but beyond the period.
+])
+def test_checksum_expanded_boundaries(body_length, checksum, valid):
+ # Fixed outputs from the BIP-93 reference after bitcoin/bips#2258.
+ # Exercise the checksum helpers independently of supported wallet sizes.
+ body = [0] * body_length
+ expected = bech32_to_array(checksum)
+ assert codex32_create_checksum('ms', body) == expected
+ assert codex32_verify_checksum('ms', body + expected) == valid
+
+
+@pytest.mark.parametrize('body_length,checksum', [
+ (76, 'zy2qlkz0nxqul'), # Forbidden expanded length 94.
+ (77, '366cy959nt6he'), # Forbidden expanded length 95.
+ (78, '2qjkhkmq2m8j6'), # Long checksum required from here.
+ (79, 'z9fzytvtqttl5'),
+ (80, 'secretsk7qeue'),
+])
+def test_checksum_rejects_legacy_short_boundaries(body_length, checksum):
+ assert not codex32_verify_checksum('ms', [0] * body_length + bech32_to_array(checksum))
+
+
+@pytest.mark.parametrize('hrp', ['ms', 'cw', 'cx'])
+@pytest.mark.parametrize('payload_length,checksum_length', [(69, 13), (71, 15), (74, 15), (76, 15)])
+def test_share_length_at_checksum_boundary(hrp, payload_length, checksum_length):
+ share = Share(hrp, 'test', 'q' * payload_length, 's', 0)
+ encoded = share.to_string()
+ assert len(share.checksum()) == checksum_length
+ assert len(share) == len(encoded) == 9 + payload_length + checksum_length
+ # Fixing checksum selection must not enable unsupported wallet sizes.
+ with pytest.raises(AssertionError, match='codex32 length'):
+ Share.parse(encoded)
+
+
+@pytest.mark.parametrize('text', [
+ 'ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq9dsuypw2ragmel',
+ 'ms10testsqqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0j'
+ 'qgfzyvjz2f389q5j52ev95hz7vp3xgengdfkxuurjw3m8s7nu0ax3uvrcss9ddwnst',
+ 'MS12W7F2AQQQSYQCYQ5RQWZQFPG9SCRGWPUAM077H9XN5W88',
+ *[text for vector in SETS
+ for text in [vector['secret'], vector['standalone'], *vector['shares']]],
+])
+def test_calculate_checksum_vectors(text):
+ checksum_len = 15 if len(text) == 127 else 13
+ body = text[:-checksum_len]
+ for value in (body.lower(), body.upper()):
+ share = Share.from_body(value)
+ assert share.to_string() == text.upper()
+ assert share.payload == body[9:].lower()
+ assert share.checksum() == text[-checksum_len:].lower()
+ # Import still requires a complete, checksummed string.
+ with pytest.raises((AssertionError, ValueError)):
+ Share.parse(body)
+
+
+@pytest.mark.parametrize('hrp,lengths', [('ms', (26, 52, 103)),
+ ('cw', (26, 39, 52)), ('cx', (103,))])
+@pytest.mark.parametrize('length', [0, 25, 26, 27, 32, 33, 38, 39, 40,
+ 45, 46, 47, 51, 52, 53, 74, 75, 80, 81, 102, 103, 104])
+def test_calculate_checksum_lengths(hrp, lengths, length):
+ body = hrp + '12tests' + 'q' * length
+ if length in lengths:
+ assert Share.from_body(body).payload == 'q' * length
+ else:
+ with pytest.raises((AssertionError, ValueError)):
+ Share.from_body(body)
+
+
+@pytest.mark.parametrize('header', ['', 'ms', 'ms1', 'ms12', 'ms12tes',
+ 'ms12test', 'zz12tests', 'ms02tests', 'ms112tests', 'ms12tes1s',
+ 'ms1xtests', 'ms11tests', 'ms1-tests', 'ms10testa', 'ms10testq',
+ 'ms12tebs', 'ms12testb', 'mS12tests', 'MS12testS'])
+def test_calculate_checksum_invalid_header(header):
+ with pytest.raises((AssertionError, ValueError)):
+ Share.from_body(header + 'q' * 26)
+
+
+@pytest.mark.parametrize('character', ['b', 'i', 'o', '1', '!', '\n', '\t', 'é', 'Q'])
+def test_calculate_checksum_invalid_payload(character):
+ with pytest.raises((AssertionError, ValueError)):
+ Share.from_body('ms12tests' + 'q' * 25 + character)
+
+
+@pytest.mark.parametrize('index', ['s', 'a', 'q', 'l'])
+@pytest.mark.parametrize('threshold', [0, 2, 9])
+def test_calculate_checksum_threshold_index(threshold, index):
+ body = 'ms1%dtest%s' % (threshold, index) + 'q' * 25 + 'l'
+ if threshold == 0 and index != 's':
+ with pytest.raises(AssertionError, match='non-secret share with threshold 0'):
+ Share.from_body(body)
+ else:
+ share = Share.from_body(body)
+ assert (share.threshold, share.index) == (threshold, index)
+ assert share.to_seed_and_pad()[1] == 3
+
+
+@pytest.mark.parametrize('vector', SETS, ids=lambda v: v['id'])
+def test_extension_encoding(vector):
+ raw = bytes.fromhex(vector['payload_hex'])
+ for field, threshold in (('secret', 2), ('standalone', 0)):
+ for text in (vector[field], vector[field].lower()):
+ share = Share.parse(text)
+ assert (share.hrp, share.uid, share.index, share.threshold) == (
+ vector['hrp'], 'test', 's', threshold)
+ assert share.to_seed_and_pad() == (raw, vector['padding'])
+ assert share.to_string() == vector[field]
+ assert Share.from_seed(raw, vector['hrp'], 'test', 's', threshold,
+ vector['padding']).to_string() == vector[field]
+
+
+@pytest.mark.parametrize('vector', SETS, ids=lambda v: v['id'])
+def test_extension_recovery(vector):
+ shares = [Share.parse(text) for text in vector['shares']]
+ for share, text in zip(shares, vector['shares']):
+ assert share.to_string() == text
+ assert not share.is_secret_share()
+ for pair in itertools.combinations(shares, 2):
+ for ordered in (pair, pair[::-1]):
+ assert generate_share(ordered, 's').to_string() == vector['secret']
+ missing = next(s for s in shares if s not in pair)
+ assert generate_share(ordered, missing.index).to_string() == missing.to_string()
+
+
+@pytest.mark.parametrize('vector', [v for v in SETS if v['hrp'] == 'cw'],
+ ids=lambda v: v['id'])
+def test_extension_words_wallet(vector):
+ entropy = bytes.fromhex(vector['payload_hex'])
+ mnemonic = Mnemonic('english').to_mnemonic(entropy)
+ assert mnemonic == vector['mnemonic']
+ for expected in vector['wallets']:
+ node = BIP32Node.from_master_secret(
+ Mnemonic.to_seed(mnemonic, expected['passphrase']), netcode='BTC')
+ assert node.hwif(as_private=True) == expected['xprv']
+ assert node.fingerprint().hex().upper() == expected['fingerprint']
+
+
+@pytest.mark.parametrize('vector', [v for v in SETS if v['hrp'] == 'cx'],
+ ids=lambda v: v['id'])
+def test_extension_extended_key_wallet(vector):
+ raw = bytes.fromhex(vector['payload_hex'])
+ assert raw[:32].hex() == vector['chain_code_hex']
+ assert raw[32:].hex() == vector['private_key_hex']
+ node = BIP32Node(PrvKeyNode(key=raw[32:], chain_code=raw[:32]), netcode='BTC')
+ assert node.hwif(as_private=True) == vector['xprv']
+ assert node.fingerprint().hex().upper() == vector['fingerprint']
+
+
+@pytest.mark.parametrize('vector', VECTORS['invalid_encodings'], ids=lambda v: v['id'])
+def test_extension_invalid_encoding(vector):
+ with pytest.raises((AssertionError, ValueError)):
+ Share.parse(vector['encoded'])
+
+
+@pytest.mark.parametrize('vector', VECTORS['invalid_wallets'], ids=lambda v: v['id'])
+def test_extension_wallet_rejection_vectors(vector):
+ # Check the vector classification. Actual firmware activation/state-preservation
+ # is exercised by the simulator tests in test_codex32.py.
+ share = Share.parse(vector['encoded'])
+ assert share.to_string() == vector['encoded']
+ if vector['reason'] == 'non-secret index':
+ assert not share.is_secret_share()
+ else:
+ assert vector['reason'] == 'invalid private scalar'
+ assert share.hrp == 'cx' and share.is_secret_share()
+ raw = share.to_seed_and_pad()[0]
+ order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
+ assert not 1 <= int.from_bytes(raw[32:], 'big') < order
+
+
+@pytest.mark.parametrize('vector', VECTORS['invalid_recoveries'], ids=lambda v: v['id'])
+def test_extension_invalid_recovery(vector):
+ shares = [Share.parse(text) for text in vector['shares']]
+ with pytest.raises(AssertionError):
+ generate_share(shares, 's')
+
+
+@pytest.mark.parametrize('target', ['s', 'j'])
+@pytest.mark.parametrize('case', ['insufficient', 'excess_consistent', 'excess_inconsistent'])
+def test_interpolation_requires_exact_threshold(target, case):
+ a = Share.parse('MS12W7F2AQQQSYQCYQ5RQWZQFPG9SCRGWPUAM077H9XN5W88')
+ c = Share.parse('MS12W7F2CFFFGRFURFZ6FJVFTLA4GU6AJL3SM7JJ23UZRHJU')
+ assert generate_share([a, c], 's').to_string() == \
+ 'MS12W7F2SXXXXXXXXXXXXXXXXXXXXXXXXXY9ML44VCLR4TFD'
+
+ if case == 'insufficient':
+ shares = [a]
+ elif case == 'excess_consistent':
+ shares = [a, c, generate_share([a, c], 'd')]
+ else:
+ # Same metadata and valid checksum, but not on the original polynomial.
+ extra = Share.from_seed(bytes(16), 'ms', a.uid, 'd', a.threshold, 0)
+ shares = [a, c, Share.parse(extra.to_string())]
+
+ with pytest.raises(AssertionError, match='need exactly 2 shares'):
+ generate_share(shares, target)
### testing/test_decoders.py
@@ -125,6 +125,19 @@ def test_detector_secrets(num_words, encoding, case, try_decode):
got_words, = vals
assert got_words == expect
+@pytest.mark.parametrize('share', [
+ 'ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw',
+ 'ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq9dsuypw2ragmel',
+ 'ms10testsqqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j52ev95hz7vp3xgengdfkxuurjw3m8s7nu0ax3uvrcss9ddwnst',
+ 'CX100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06GPUHWUSDF58Y65T8',
+ 'CW12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPA3HA0NYPT0468L',
+ 'CW12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9E97M4WG0QA8MXD',
+ 'CW12TESTSQQQSYQCYQ5RQWZQFPG9SCRGWPUGPZYSNZS23V9CCRYDPK8QARC03HC0TSDSAFY5ZR',
+], ids=['ms1-128', 'ms1-256', 'ms1-512', 'cx1', 'cw1-128', 'cw1-192', 'cw1-256'])
+def test_detector_codex32(share, try_decode):
+ assert try_decode(share) == ('codex32', (share.lower(),))
+
+
@pytest.mark.parametrize('code', [
'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb',
'xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH',
### testing/test_ephemeral.py
@@ -11,6 +11,10 @@
from bip32 import BIP32Node
from helpers import xfp2str, a2b_hex
from charcodes import KEY_CLEAR, KEY_NFC, KEY_QR
+from mnemonic import Mnemonic
+from test_codex32 import (CW_SHARES, Share, native_encoding,
+ bip32_node_from_codex32_share, import_codex32_ui,
+ goto_codex32_menu, enter_bech32, active_secret)
WORDLISTS = {
@@ -96,7 +100,7 @@ def doit(nfc=False):
pick_menu_item("Advanced/Tools")
pick_menu_item("Danger Zone")
pick_menu_item("Seed Functions")
- pick_menu_item('View Seed Words')
+ pick_menu_item('View Secret')
time.sleep(.1)
title, body = cap_story()
assert ('Are you SURE' in body) or ('Are you SURE' in title)
@@ -844,6 +848,48 @@ def test_ephemeral_seed_import_xprv(way, testnet, reset_seed_words, goto_eph_see
restore_main_seed(preserve_settings)
+@pytest.mark.parametrize('value', CW_SHARES, ids=['cw1-128', 'cw1-192', 'cw1-256'])
+@pytest.mark.parametrize('seed_vault', [False, True])
+def test_ephemeral_seed_import_cw1(value, seed_vault, reset_seed_words,
+ seed_vault_enable, import_codex32_ui,
+ confirm_tmp_seed, verify_ephemeral_secret_ui,
+ active_secret, settings_set, master_settings_get,
+ restore_main_seed, seed_vault_delete,
+ goto_home, pick_menu_item, cap_menu):
+ reset_seed_words()
+ settings_set('seeds', [])
+ seed_vault_enable(seed_vault)
+ share = Share.parse(value)
+ mnemonic = Mnemonic('english').to_mnemonic(share.to_seed_and_pad()[0]).split()
+ node = bip32_node_from_codex32_share(share)
+ expected_xfp = node.fingerprint().hex().upper()
+ encoded = native_encoding(value)
+
+ import_codex32_ui('sd', value, tmp=True, seed_vault=seed_vault)
+ confirm_tmp_seed(seedvault=seed_vault, expect_xfp=expected_xfp)
+ xfp = verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=node.hwif(),
+ expected_xfp=expected_xfp, seed_vault=seed_vault)
+ assert active_secret() == encoded.hex()
+
+ if seed_vault:
+ saved = master_settings_get('seeds')
+ assert len(saved) == 1
+ assert saved[0][0] == xfp
+ assert saved[0][1] == encoded.hex().rstrip('0')
+ restore_main_seed(seed_vault=True)
+ goto_home()
+ pick_menu_item('Seed Vault')
+ pick_menu_item(next(item for item in cap_menu() if xfp in item))
+ pick_menu_item('Use This Seed')
+ confirm_tmp_seed(expect_xfp=expected_xfp)
+ verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=node.hwif(),
+ expected_xfp=expected_xfp, seed_vault=True)
+ assert active_secret() == encoded.hex()
+ seed_vault_delete(xfp)
+ else:
+ restore_main_seed()
+
+
@pytest.mark.parametrize("seed_vault", [True, False])
def test_activate_current_tmp_secret(reset_seed_words, goto_eph_seed_menu,
ephemeral_seed_disabled, cap_story,
### testing/test_hobble.py
@@ -260,11 +260,12 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
m = cap_menu()
assert 'Seed Vault' not in m
-@pytest.mark.parametrize('mode', [ 'words', 'qr', 'xprv', 'tapsigner', 'coldcard', 'b39pass'])
+@pytest.mark.parametrize('mode', [ 'words', 'qr', 'xprv', 'tapsigner', 'coldcard', 'b39pass', 'c32'])
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):
+ verify_ephemeral_secret_ui, scan_a_qr, tapsigner_encrypted_backup, press_cancel,
+ need_keypress, enter_hex, open_microsd, microsd_path, go_to_passphrase,
+ garbage_collector):
'''
- can import and use a key for signing
- NOT offered chance to save into seedvault
@@ -282,7 +283,14 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
m = cap_menu()
assert 'Generate Words' not in m
- assert all((i.startswith("Import ") or i.endswith(' Backup') or i == 'Restore Seed XOR')
+ assert 'Codex32' in m # Codex32 still there, generation not allowed
+ pick_menu_item("Codex32")
+ mm = cap_menu()
+ assert "Generate" not in mm
+ assert "Import Codex32" in mm
+ assert "Shamir Recover" in mm
+ press_cancel()
+ assert all((i.startswith("Import ") or i.endswith(' Backup') or i == 'Restore Seed XOR' or i == "Codex32")
for i in m), m
words, expect_xfp = WORDLISTS[12]
@@ -348,10 +356,11 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
elif mode == 'xprv':
fname = "ek.txt"
+ pth = microsd_path(fname)
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:
+ with open(pth, "w") as f:
f.write(ek)
pick_menu_item("Import XPRV")
@@ -382,6 +391,20 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
assert "store temporary seed into Seed Vault" not in story
time.sleep(.1)
+ elif mode == "c32":
+ s_share = "MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW"
+ fname = "sshare.txt"
+ pth = microsd_path(fname)
+ with open(pth, "w") as f:
+ f.write(s_share)
+ xprv = "xprv9s21ZrQH143K2NkobdHxXeyFDqE44nJYvzLFtsriatJNWMNKznGoGgW5UMTL4fyWtajnMYb5gEc2CgaKhmsKeskoi9eTimpRv2N11THhPTU"
+ node = BIP32Node.from_hwif(xprv)
+ expect_xfp = node.fingerprint().hex().upper()
+ pick_menu_item("Codex32")
+ pick_menu_item("Import Codex32")
+ need_keypress("1")
+ pick_menu_item(fname)
+
else:
raise pytest.fail(mode)
@@ -439,7 +462,8 @@ def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel,
words, _ = WORDLISTS[12]
keys = [
' '.join(w[0:4] for w in words.split()),
- simulator_fixed_xprv]
+ simulator_fixed_xprv,
+ 'MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW']
for ss in keys:
need_keypress(KEY_QR)
### testing/test_rolls.py
@@ -1,9 +1,16 @@
import sys
+import shutil
+import subprocess
+from hashlib import sha256
+from pathlib import Path
import pytest
sys.path.append("..")
from docs.rolls import entropy_to_mnemonic24, wl as rolls_wl
from docs.rolls12 import entropy_to_mnemonic12
from docs.verify_seed_mix import derive_seed, entropy_to_mnemonic, mnemonic24_to_entropy, wl as trng_wl
+from docs.verify_seed_mix import encode_codex32
+from docs.rolls_codex32 import encode_seed
+from shared.codex32 import Share
bip39_vectors_12 = [
@@ -142,3 +149,87 @@ def test_trng_coin_mix(nwords, expected):
assert seed.hex() == expected
convert = entropy_to_mnemonic12 if nwords == 12 else entropy_to_mnemonic24
assert entropy_to_mnemonic(seed) == convert(seed)
+
+
+@pytest.mark.parametrize('encode', [encode_seed, encode_codex32])
+@pytest.mark.parametrize('seed, uid, expected', [
+ (bytes(16), 'test', 'MS10TESTSQQQQQQQQQQQQQQQQQQQQQQQQQQS75SVV7JAL8P5'),
+ (bytes.fromhex('ffeeddccbbaa99887766554433221100' * 2), 'leet',
+ 'MS10LEETSLLHDMN9M42VCSAMX24ZRXGS3QRL7AHWVHW4FNZRHVE25GVEZZYQQTUM9PGV99YCMA'),
+])
+def test_codex32_encoders(encode, seed, uid, expected):
+ assert encode(seed, uid) == expected
+
+
+@pytest.fixture
+def run_rolls_script(tmp_path):
+ def run(name, args, data):
+ script = tmp_path / name
+ shutil.copyfile(Path(__file__).resolve().parents[1] / 'docs' / name, script)
+ return subprocess.run([sys.executable, '-I', str(script), *args], input=data,
+ text=True, capture_output=True, cwd=tmp_path)
+ return run
+
+
+@pytest.mark.parametrize('codex32', [None, 'seed', 'test'])
+@pytest.mark.parametrize('bits', [128, 256])
+@pytest.mark.parametrize('method, tmp, expected', [
+ ('d', False, '67c8b6d836d47f88dfb88a1bb5a534cf28b437cd345e8c7fa59f1982f9248da5'),
+ ('d', True, 'c890c265e37b1da69636a6ed93bcfa2734232bcdcfd95908d7c1623f46af17cd'),
+ ('c', False, '8216d06056e31315bec14171d1f09345eef5cbba16fdd3498354951dd3356dab'),
+ ('c', True, '11e2749d5953a01b6d293fe3b3023fa2ab13426063f60e2529ebf195ad47b800'),
+])
+def test_seed_mix_script(run_rolls_script, codex32, bits, method, tmp, expected):
+ args = ([] if codex32 is None else ['--codex32'] if codex32 == 'seed'
+ else ['--codex32', 'TeSt']) + (['--tmp'] if tmp else [])
+ symbols = '123456' * 8 + '12' if method == 'd' else '01' * 64
+ size = bits if codex32 else (12 if bits == 128 else 24)
+ data = '\n'.join([bip39_vectors_24[0][1], method, ' '.join(symbols), str(size)]) + '\n'
+ result = run_rolls_script('verify_seed_mix.py', args, data)
+ assert result.returncode == 0, result.stderr
+ seed = bytes.fromhex(expected)[:bits // 8]
+ assert '\n' + seed.hex() + '\n' in result.stdout
+ if codex32:
+ share = Share.parse(result.stdout.splitlines()[-1])
+ assert (share.hrp, share.uid, share.index, share.threshold) == ('ms', codex32, 's', 0)
+ assert share.to_seed_and_pad() == (seed, 0)
+ else:
+ convert = entropy_to_mnemonic12 if bits == 128 else entropy_to_mnemonic24
+ expected_words = '\n'.join('%4d: %s' % item for item in enumerate(convert(seed), 1))
+ assert result.stdout.endswith(expected_words + '\n')
+
+
+@pytest.mark.parametrize('bits, minimum', [(128, 50), (256, 99)])
+@pytest.mark.parametrize('uid', ['seed', 'test'])
+def test_codex32_dice_script(run_rolls_script, bits, minimum, uid):
+ args = ['--bits', str(bits)] + (['--id', 'TeSt'] if uid == 'test' else [])
+ rolls = ('123456' * 17)[:minimum]
+ result = run_rolls_script('rolls_codex32.py', args, ' \n' + '\t '.join(rolls) + '\n')
+ assert result.returncode == 0, result.stderr
+ digest = sha256(rolls.encode()).digest()
+ assert result.stdout.splitlines()[0] == digest.hex()
+ share = Share.parse(result.stdout.splitlines()[-1])
+ assert (share.hrp, share.uid, share.index, share.threshold) == ('ms', uid, 's', 0)
+ assert share.to_seed_and_pad() == (digest[:bits // 8], 0)
+
+ for invalid, error in [
+ ('', 'only digits 1-6'),
+ (rolls + '0', 'only digits 1-6'),
+ (rolls[:-1], 'at least %d rolls required' % minimum),
+ ('1' * minimum, 'more than 30%'),
+ ]:
+ result = run_rolls_script('rolls_codex32.py', args, invalid)
+ assert result.returncode == 2
+ assert error in result.stderr
+ assert not result.stdout
+
+
+@pytest.mark.parametrize('script, args', [
+ ('rolls_codex32.py', ['--bits', '128', '--id', 'tesb']),
+ ('verify_seed_mix.py', ['--codex32', 'tesb']),
+])
+def test_codex32_scripts_invalid_id(run_rolls_script, script, args):
+ result = run_rolls_script(script, args, '')
+ assert result.returncode == 2
+ assert 'ID must contain four Codex32 characters' in result.stderr
+ assert not result.stdout
### testing/test_sign.py
@@ -28,11 +28,57 @@
from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint
from ckcc_protocol.constants import STXN_VISUALIZE, STXN_SIGNED
from charcodes import KEY_QR, KEY_RIGHT, KEY_LEFT
+from test_codex32 import (SHARES, IMPORT_SHARES, Share, native_encoding,
+ bip32_node_from_codex32_share)
SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22)
+@pytest.mark.parametrize('value', [
+ pytest.param(SHARES[0], id='ms1-128'),
+ pytest.param(SHARES[1], id='ms1-256'),
+ pytest.param(IMPORT_SHARES[11], id='ms1-512'),
+ pytest.param(IMPORT_SHARES[7], id='cx1'),
+])
+@pytest.mark.parametrize('style', ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh'])
+@pytest.mark.parametrize('finalize', [False, True])
+def test_codex32_signing_matches_xprv(value, style, finalize, set_master_key,
+ set_encoded_secret, fake_txn, try_sign, dev, sim_exec):
+ share = Share.parse(value)
+ assert share.to_seed_and_pad()[1] # Include otherwise-lost padding bits.
+ node = bip32_node_from_codex32_share(share)
+ encoded = native_encoding(value)
+
+ # Establish the signing result using the equivalent ordinary XPRV wallet.
+ set_master_key(node.hwif(as_private=True))
+ psbt = fake_txn(2, 2, master_xpub=node.hwif(), segwit_in=style != 'p2pkh',
+ wrapped=style == 'p2wpkh-p2sh', outstyles=[style], change_outputs=[1])
+ _, expected = try_sign(psbt, finalize=finalize)
+ if finalize:
+ txn = CTransaction()
+ txn.deserialize(BytesIO(expected))
+ assert len(txn.vin) == 2
+ if style == 'p2pkh':
+ assert all(inp.scriptSig for inp in txn.vin)
+ else:
+ assert len(txn.wit.vtxinwit) == 2
+ assert all(len(wit.scriptWitness.stack) == 2 for wit in txn.wit.vtxinwit)
+ else:
+ signed = BasicPSBT().parse(expected)
+ assert len(signed.inputs) == 2
+ for i, inp in enumerate(signed.inputs):
+ assert set(inp.part_sigs) == {node.subkey_for_path('0/%d' % i).sec()}
+
+ # Reuse identical PSBT bytes: deterministic signatures must match exactly.
+ set_encoded_secret(encoded)
+ assert dev.send_recv(CCProtocolPacker.get_xpub()) == node.hwif()
+ _, actual = try_sign(psbt, finalize=finalize)
+ assert actual == expected
+ assert sim_exec('from utils import B2A; RV.write(B2A(pa.fetch()))') == encoded.hex()
+ assert encoded[65:] == bytes(7)
+
+
@pytest.mark.parametrize('finalize', [ False, True ])
def test_sign1(dev, finalize):
in_psbt = a2b_hex(open('data/p2pkh-in-scriptsig.psbt', 'rb').read())
### testing/test_teleport.py
@@ -18,8 +18,8 @@
# All tests in this file are exclusively meant for Q
#
@pytest.fixture(autouse=True)
-def THIS_FILE_requires_q1(is_q1, is_headless):
- if not is_q1 or is_headless:
+def THIS_FILE_requires_q1(is_q1, is_headless, request):
+ if not is_q1 or (is_headless and request.node.originalname != 'test_tx_master_secret_label'):
raise pytest.skip('Q1 only (not headless)')
@pytest.fixture
@@ -242,6 +242,32 @@ def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
press_select()
+@pytest.mark.parametrize('words,c32,expected', [
+ (24, 0, 'Master Seed Words'),
+ (0, 0, 'Master XPRV'),
+ (0, 1, 'Master Seed Bytes'),
+])
+def test_tx_master_secret_label(words, c32, expected, sim_exec,
+ settings_get, settings_set, settings_remove):
+ old_words = settings_get('words')
+ old_c32 = settings_get('c32')
+
+ try:
+ settings_set('words', words)
+ settings_set('c32', c32)
+
+ labels = sim_exec(
+ 'from teleport import SecretPickerMenu; '
+ 'RV.write("\\n".join(item.label for item in SecretPickerMenu(None).items))')
+ assert expected in labels.splitlines()
+ finally:
+ for key, value in [('words', old_words), ('c32', old_c32)]:
+ if value is None:
+ settings_remove(key)
+ else:
+ settings_set(key, value)
+
+
@pytest.mark.parametrize('testcase', [ 'weak', 'strong'])
def test_tx_master_send(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item,
grab_payload, rx_complete, cap_story, press_cancel, press_select, main_do_over):
### testing/test_unit.py
@@ -155,6 +155,12 @@ def test_decoding(unit_test):
# utils.py Hex/Base64 streaming decoders
unit_test('devtest/unit_decoding.py')
+def test_codex32(unit_test):
+ unit_test('devtest/unit_codex32.py')
+
+def test_codex32_boundaries(unit_test):
+ unit_test('devtest/unit_codex32_boundaries.py')
+
@pytest.mark.parametrize('hasher', ['sha256', 'sha1', 'sha512'])
@pytest.mark.parametrize('msg', [b'123', b'b'*78])
@pytest.mark.parametrize('key', [b'3245', b'b'*78])
### testing/test_ux.py
@@ -1007,13 +1007,12 @@ def test_show_seed(mode, b39_word, goto_home, pick_menu_item, cap_story, need_ke
pick_menu_item('Advanced/Tools')
pick_menu_item('Danger Zone')
pick_menu_item('Seed Functions')
- pick_menu_item('View Seed Words')
+ pick_menu_item('View Secret')
time.sleep(.01)
title, body = cap_story()
where = title if is_q1 else body
assert 'Are you SURE' in where
- assert 'secret seed words' in body
- assert 'or extended private key' in body
+ assert "wallet's secret" in body
assert 'can control all funds' in body
press_select() # skip warning
time.sleep(0.01)
@@ -1661,7 +1660,7 @@ def test_q1_24_8char_words(set_seed_words, is_q1, goto_home, pick_menu_item, pre
pick_menu_item("Advanced/Tools")
pick_menu_item("Danger Zone")
pick_menu_item("Seed Functions")
- pick_menu_item('View Seed Words')
+ pick_menu_item('View Secret')
time.sleep(.01)
press_select() # skip warning
time.sleep(0.01)Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.