From 763464d27ba5823da6914adbca279fce292b663f Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 3 Jul 2026 15:47:05 -0400 Subject: [PATCH 1/5] refactor(keycard_wallet): replace keycard-py (pyo3) with native keycard-rs Rewrites the Keycard integration to talk to the LEE-flavored applet directly from Rust via keycard-rs (pinned git dependency), dropping the pyo3 shim, python_path.rs, and the vendored keycard-py Python library entirely. Verified end-to-end against real hardware: pairing, PIN verification, mnemonic loading, BIP340 Schnorr signing, and transfers between keycard and public/private accounts. Also cleans up the pyo3 usage that had leaked into wallet's signing path (signing.rs, account_manager.rs) beyond the keycard CLI itself, and trims wallet_with_keycard.sh's Python setup down to just pyscard, which is only needed by the force_unpower.py test helper now. --- Cargo.lock | 556 +++++++++++++++----- Cargo.toml | 3 +- lez/keycard_wallet/Cargo.toml | 5 +- lez/keycard_wallet/python/keycard_wallet.py | 221 -------- lez/keycard_wallet/src/lib.rs | 399 ++++++++------ lez/keycard_wallet/src/python_path.rs | 69 --- lez/keycard_wallet/tests/force_unpower.py | 5 +- lez/keycard_wallet/tests/keycard_test_3.sh | 2 - lez/keycard_wallet/tests/keycard_tests.sh | 2 - lez/keycard_wallet/tests/keycard_tests_2.sh | 1 - lez/keycard_wallet/wallet_with_keycard.sh | 10 +- lez/wallet/Cargo.toml | 1 - lez/wallet/src/account_manager.rs | 27 +- lez/wallet/src/cli/keycard.rs | 110 +--- lez/wallet/src/lib.rs | 2 - lez/wallet/src/signing.rs | 18 +- 16 files changed, 728 insertions(+), 703 deletions(-) delete mode 100644 lez/keycard_wallet/python/keycard_wallet.py delete mode 100644 lez/keycard_wallet/src/python_path.rs diff --git a/Cargo.lock b/Cargo.lock index 84b5e1d6..7227deda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,16 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -56,16 +66,27 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "ghash", "subtle", ] @@ -268,7 +289,7 @@ dependencies = [ "digest 0.10.7", "fnv", "merlin", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -716,7 +737,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "log", "url", @@ -813,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -910,6 +931,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base256emoji" version = "1.0.2" @@ -926,6 +953,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1048,6 +1081,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.20.2" @@ -1055,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "bitflags 2.12.1", "bollard-buildkit-proto", "bollard-stubs", @@ -1112,7 +1154,7 @@ version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ - "base64", + "base64 0.22.1", "bollard-buildkit-proto", "bytes", "prost 0.14.3", @@ -1279,6 +1321,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cbindgen" version = "0.29.3" @@ -1310,6 +1361,18 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead 0.6.1", + "cipher 0.5.2", + "ctr 0.10.1", + "subtle", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1541,7 +1604,7 @@ version = "0.1.0" dependencies = [ "anyhow", "authenticated_transfer_core", - "base64", + "base64 0.22.1", "borsh", "clock_core", "hex", @@ -1552,7 +1615,7 @@ dependencies = [ "programs", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "system_accounts", "thiserror 2.0.18", ] @@ -1765,6 +1828,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1885,6 +1954,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1926,6 +2011,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1933,6 +2027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2231,7 +2326,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", + "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2310,7 +2407,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -2361,13 +2458,28 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "serdect", - "signature", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "serdect 0.2.0", + "signature 2.2.0", "spki 0.7.3", ] +[[package]] +name = "ecdsa" +version = "0.17.0-rc.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" +dependencies = [ + "der 0.8.0", + "digest 0.11.3", + "elliptic-curve 0.14.0", + "rfc6979 0.6.0-pre.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2376,7 +2488,7 @@ checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -2389,7 +2501,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2434,17 +2546,38 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array 0.14.7", - "group", + "group 0.13.0", "pem-rfc7468", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", - "serdect", + "sec1 0.7.3", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3273f1195b6f6253ebda493d6742c8baa9b26a291674cd96d92a0f09e90e9b46" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2724,6 +2857,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "ff_derive" version = "0.13.1" @@ -3137,11 +3280,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "guardian" version = "1.3.0" @@ -3349,7 +3503,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -3361,6 +3524,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha512" version = "1.1.12" @@ -3481,7 +3653,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3589,7 +3763,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3909,7 +4083,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base58", - "base64", + "base64 0.22.1", "common", "hex", "lee", @@ -3981,6 +4155,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -4266,7 +4441,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf36eb27f8e13fa93dcb50ccb44c417e25b818cfa1a481b5470cd07b19c60b98" dependencies = [ - "base64", + "base64 0.22.1", "futures-channel", "futures-util", "gloo-net", @@ -4319,7 +4494,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" dependencies = [ - "base64", + "base64 0.22.1", "http-body", "hyper", "hyper-rustls", @@ -4421,12 +4596,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", - "serdect", - "sha2", - "signature", + "serdect 0.2.0", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "k256" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905d38bdbb43bb506efa0a428b3e969ff244549832a86b18591492f503adfe37" +dependencies = [ + "cpubits", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.0", + "primeorder", + "sha2 0.11.0", + "signature 3.0.0", + "wnaf", ] [[package]] @@ -4471,25 +4661,53 @@ dependencies = [ "hex", "hmac-sha512", "itertools 0.14.0", - "k256", + "k256 0.13.4", "lee", "lee_core", "ml-kem", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] +[[package]] +name = "keycard-rs" +version = "0.1.0" +source = "git+https://github.com/keycard-tech/keycard-rs?rev=98abc3ff1b26785a5631634d5bf24da54da541bf#98abc3ff1b26785a5631634d5bf24da54da541bf" +dependencies = [ + "aes 0.9.1", + "base64 0.21.7", + "cbc", + "ccm", + "generic-array 0.14.7", + "getrandom 0.2.17", + "getrandom 0.4.2", + "hkdf 0.13.0", + "hmac 0.13.0", + "k256 0.14.0-rc.14", + "pbkdf2", + "pcsc", + "rand_core 0.6.4", + "sha2 0.11.0", + "sha3 0.12.0", + "thiserror 1.0.69", + "typenum", + "zeroize", +] + [[package]] name = "keycard_wallet" version = "0.1.0" dependencies = [ + "bip39", + "keycard-rs", "lee", "log", - "pyo3", + "rand 0.8.6", "serde", "serde_json", + "thiserror 2.0.18", "zeroize", ] @@ -4556,7 +4774,7 @@ dependencies = [ "env_logger", "hex", "hex-literal 1.1.0", - "k256", + "k256 0.13.4", "lee_core", "log", "rand 0.8.6", @@ -4564,7 +4782,7 @@ dependencies = [ "risc0-zkvm", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "test-case", "test_methods", "thiserror 2.0.18", @@ -4595,7 +4813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa3982e7fe36c1de68f91f3c9083124f389a975523881f3d7e3363362feda41" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "cfg-if", "either_of", "futures", @@ -4797,7 +5015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "codee", "futures", "hydration_context", @@ -4988,7 +5206,7 @@ checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" dependencies = [ "async-channel", "asynchronous-codec", - "base64", + "base64 0.22.1", "byteorder", "bytes", "either", @@ -5007,7 +5225,7 @@ dependencies = [ "rand 0.8.6", "regex", "serde", - "sha2", + "sha2 0.10.9", "tracing", "web-time", ] @@ -5042,13 +5260,13 @@ dependencies = [ "asn1_der", "bs58", "ed25519-dalek", - "hkdf", - "k256", + "hkdf 0.12.4", + "k256 0.13.4", "multihash", "prost 0.14.3", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "tracing", "zeroize", @@ -5074,7 +5292,7 @@ dependencies = [ "quick-protobuf-codec", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tracing", @@ -7109,13 +7327,42 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags 2.12.1", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -7330,6 +7577,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" +dependencies = [ + "elliptic-curve 0.14.0", + "once_cell", + "primefield", + "serdect 0.4.3", +] + [[package]] name = "privacy_preserving_circuit_program" version = "0.1.0" @@ -7575,63 +7848,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "pyo3" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" -dependencies = [ - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-build-config" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" -dependencies = [ - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "quick-protobuf" version = "0.8.1" @@ -8012,7 +8228,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -8065,10 +8281,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689" +dependencies = [ + "ctutils", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -8145,7 +8371,7 @@ dependencies = [ "directories", "hex", "rayon", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -8205,7 +8431,7 @@ dependencies = [ "risc0-sys", "risc0-zkp", "serde", - "sha2", + "sha2 0.10.9", "tracing", "zip", ] @@ -8342,7 +8568,7 @@ dependencies = [ "bytemuck", "cfg-if", "digest 0.10.7", - "ff", + "ff 0.13.1", "hex", "hex-literal 0.4.1", "metal", @@ -8356,7 +8582,7 @@ dependencies = [ "risc0-sys", "risc0-zkvm-platform", "serde", - "sha2", + "sha2 0.10.9", "stability", "tracing", ] @@ -8403,7 +8629,7 @@ dependencies = [ "rzup", "semver", "serde", - "sha2", + "sha2 0.10.9", "stability", "tempfile", "tracing", @@ -8507,7 +8733,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki 0.7.3", "subtle", "zeroize", @@ -8751,7 +8977,7 @@ dependencies = [ "semver", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "strum", "tempfile", "thiserror 2.0.18", @@ -8835,11 +9061,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", "subtle", "zeroize", ] @@ -9109,7 +9349,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -9154,7 +9394,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -9165,7 +9415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d60e4c1dfccd91fe0990141f69f1d5cf5679797ad53aa1b45e5bd658eb119f0" dependencies = [ "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "const-str 1.1.0", "const_format", @@ -9245,6 +9495,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -9265,6 +9526,17 @@ dependencies = [ "keccak 0.2.0", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -9306,6 +9578,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -9375,7 +9657,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures", "http 1.4.1", @@ -9414,6 +9696,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "spongefish" version = "0.2.0" @@ -9682,12 +9970,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.27.0" @@ -10200,7 +10482,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.1", @@ -10651,7 +10933,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "percent-encoding", @@ -10668,7 +10950,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "httparse", "log", @@ -10858,13 +11140,12 @@ dependencies = [ "log", "optfield", "programs", - "pyo3", "rand 0.8.6", "rpassword", "sequencer_service_rpc", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "system_accounts", "tempfile", "testnet_initial_state", @@ -11050,7 +11331,7 @@ checksum = "d0a659ffe5c7f4538aa6357c07e3d73221cc61eba03bd9a081e14bc91ed09b8c" dependencies = [ "base16", "quote", - "sha2", + "sha2 0.10.9", "syn 2.0.117", ] @@ -11550,6 +11831,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86421f2a70c9e6cab8d84c99fb62d8761d355bd1285443a7e7ccad15aa515f2" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 3e71f293..2d269ab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,6 +163,8 @@ logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/lo logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } +keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "98abc3ff1b26785a5631634d5bf24da54da541bf" } + rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", "bindgen-runtime", @@ -183,7 +185,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [ ] } clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } -pyo3 = { version = "0.29", features = ["auto-initialize"] } zeroize = "1" criterion = { version = "0.8", features = ["html_reports"] } diff --git a/lez/keycard_wallet/Cargo.toml b/lez/keycard_wallet/Cargo.toml index 4abff1f1..7a062d8b 100644 --- a/lez/keycard_wallet/Cargo.toml +++ b/lez/keycard_wallet/Cargo.toml @@ -9,8 +9,11 @@ workspace = true [dependencies] lee.workspace = true -pyo3.workspace = true +keycard-rs.workspace = true +bip39.workspace = true log.workspace = true +rand.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +thiserror.workspace = true zeroize.workspace = true diff --git a/lez/keycard_wallet/python/keycard_wallet.py b/lez/keycard_wallet/python/keycard_wallet.py deleted file mode 100644 index 21e966cb..00000000 --- a/lez/keycard_wallet/python/keycard_wallet.py +++ /dev/null @@ -1,221 +0,0 @@ -from smartcard.System import readers -from keycard.exceptions import APDUError, TransportError -from ecdsa import VerifyingKey, SECP256k1 - -from keycard.keycard import KeyCard -from keycard.commands.export_lee_key import export_lee_key -from mnemonic import Mnemonic -from keycard import constants - -import os -import secrets - -DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" - -def _pairing_password() -> str: - return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD) - -class KeycardWallet: - def __init__(self): - self.card = KeyCard() - - def _is_smart_card_reader_detected(self) -> bool: - try: - return len(readers()) > 0 - except Exception: - return False - - def _is_keycard_detected(self) -> bool: - try: - KeyCard().select() - return True - except (TransportError, APDUError, Exception): - # No readers, no card, or card doesn't respond. - return False - - def is_unpaired_keycard_available(self) -> bool: - if not self._is_smart_card_reader_detected(): - return False - elif not self._is_keycard_detected(): - return False - return True - - def initialize(self, pin: str, pairing_password: str | None = None) -> bool: - try: - self.card.select() - - if self.card.is_initialized: - raise RuntimeError("Card is already initialized") - - puk = ''.join(secrets.choice('0123456789') for _ in range(12)) - self.card.init(pin, puk, pairing_password or _pairing_password()) - print(f"Keycard PUK: {puk}") - print("Record this PUK and store it somewhere safe. It cannot be recovered.") - return True - except Exception as e: - raise RuntimeError(f"Error initializing keycard: {e}") from e - - def _reconnect(self) -> None: - self.card = KeyCard() - self.card.select() - - def _pair(self, pin: str, password: str) -> tuple[int, bytes]: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") - - pairing_index, pairing_key = self.card.pair(password) - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - try: - self.card.unpair(pairing_index) - except Exception: - pass - raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e - - return pairing_index, pairing_key - - def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]: - password = password or _pairing_password() - try: - return self._pair(pin, password) - except TransportError as e: - print(f"Transport error during fresh pair ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._pair(pin, password) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") - - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e - - return True - - def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - try: - return self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - except TransportError as e: - print(f"Transport error during stored pairing ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def close_session(self) -> bool: - return True - - def load_mnemonic(self, mnemonic: str) -> bool: - try: - # Convert mnemonic to seed - mnemo = Mnemonic("english") - if not mnemo.check(mnemonic): - raise RuntimeError("Invalid mnemonic phrase — check spelling and word count") - seed = mnemo.to_seed(mnemonic) - - # Load the LEE seed onto the card - result = self.card.load_key( - key_type = constants.LoadKeyType.LEE_SEED, - lee_seed = seed - ) - return True - except Exception as e: - raise RuntimeError(f"Error loading mnemonic: {e}") from e - - def disconnect(self) -> bool: - try: - if not self.card.is_secure_channel_open: - return False - - self.card.unpair(self.pairing_index) - - return True - except Exception as e: - raise RuntimeError(f"Error during disconnect: {e}") from e - - def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - public_key = self.card.export_key( - derivation_option = constants.DerivationOption.DERIVE, - public_only = True, - keypath = path - ) - - public_key = public_key.public_key - public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1) - public_key = public_key.to_string("compressed")[1:] - - return public_key - - except Exception as e: - raise RuntimeError(f"Error getting public key: {e}") from e - - - def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - signature = self.card.sign_with_path( - digest = message, - path = path, - algorithm = constants.SigningAlgorithm.SCHNORR_BIP340, - make_current = False - ) - - return signature.signature - - except Exception as e: - raise RuntimeError(f"Error signing message: {e}") from e - - def get_private_keys_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - private_keys = export_lee_key( - self.card, - constants.DerivationOption.DERIVE, - path - ) - - nsk = private_keys.lee_nsk - vsk = private_keys.lee_vsk - - return (nsk, vsk) - - except Exception as e: - raise RuntimeError(f"Error getting private keys: {e}") from e - diff --git a/lez/keycard_wallet/src/lib.rs b/lez/keycard_wallet/src/lib.rs index a3ecd140..6d3f9777 100644 --- a/lez/keycard_wallet/src/lib.rs +++ b/lez/keycard_wallet/src/lib.rs @@ -1,16 +1,48 @@ -use std::path::PathBuf; +use std::{path::PathBuf, str::FromStr as _}; +use keycard_rs::{ + KeycardCommandSet, PcscChannel, + constants::sign_p2, + parsing::Bip32KeyPair, + secure_channel::Pairing, + tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE}, +}; use lee::{AccountId, PublicKey, Signature}; -use pyo3::{prelude::*, types::PyAny}; +use rand::Rng as _; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -pub mod python_path; +const DEFAULT_PAIRING_PASSWORD: &str = "KeycardDefaultPairing"; + +/// LEE-applet extension tags. These aren't part of upstream `keycard-rs`'s `tlv` module (which +/// only knows the standard Status Keycard tags) since they're specific to the LEE-flavored +/// applet (`LEE_keycard.cap`). +const TLV_LEE_NSK: u8 = 0x83; +const TLV_LEE_VSK: u8 = 0x84; +/// Raw Schnorr signature (64 bytes: `r || s`, no ASN.1/DER wrapping) nested inside the standard +/// `TLV_SIGNATURE_TEMPLATE` alongside the usual `TLV_PUB_KEY`. Confirmed against real hardware — +/// the LEE applet's `SIGN` response for `sign_p2::BIP340_SCHNORR` is +/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }`. +const TLV_LEE_RAW_SIGNATURE: u8 = 0x88; /// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte /// arrays. type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>); +#[derive(Debug, thiserror::Error)] +pub enum KeycardWalletError { + #[error(transparent)] + Keycard(#[from] keycard_rs::Error), + #[error("keycard is already initialized")] + AlreadyInitialized, + #[error("invalid mnemonic phrase: {0}")] + InvalidMnemonic(String), + #[error("invalid key material from keycard: {0}")] + InvalidKeyMaterial(String), + #[error("keycard returned a signature that does not verify against its own public key")] + SignatureVerificationFailed, +} + // TODO: encrypt at rest alongside broader wallet storage encryption work. #[derive(Serialize, Deserialize)] pub struct KeycardPairingData { @@ -24,136 +56,195 @@ impl KeycardPairingData { } } -/// Rust wrapper around the Python `KeycardWallet` class. +/// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC. pub struct KeycardWallet { - instance: Py, + command_set: KeycardCommandSet, } impl KeycardWallet { - /// Create a new Python `KeycardWallet` instance. - pub fn new(py: Python) -> PyResult { - let module = py.import("keycard_wallet")?; - let class = module.getattr("KeycardWallet")?; - - let instance = class.call0()?; - + /// Connects to the first available PC/SC reader. Does not select the applet yet — callers + /// that need application info (`initialize`, `pair`, `connect`, ...) do that themselves. + pub fn new() -> Result { + let channel = PcscChannel::connect()?; Ok(Self { - instance: instance.into(), + command_set: KeycardCommandSet::new(channel), }) } - pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { - self.instance - .bind(py) - .call_method0("is_unpaired_keycard_available")? - .extract() + /// Returns whether a smart card reader and a selectable Keycard are both present. + #[must_use] + pub fn is_unpaired_keycard_available() -> bool { + let Ok(channel) = PcscChannel::connect() else { + return false; + }; + KeycardCommandSet::new(channel) + .select() + .is_ok_and(|resp| resp.is_ok()) } - pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult { - self.instance - .bind(py) - .call_method1("initialize", (pin,))? - .extract() + fn select(&mut self) -> Result<(), KeycardWalletError> { + self.command_set.select()?.check_ok()?; + Ok(()) } - pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec)> { - self.instance - .bind(py) - .call_method1("pair", (pin,))? - .extract() + fn open_and_verify(&mut self, pin: &str) -> Result<(), KeycardWalletError> { + self.command_set.auto_open_secure_channel()?; + self.command_set.verify_pin(pin)?.check_auth_ok()?; + Ok(()) + } + + /// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a + /// transport-level error (e.g. the card lost power mid-session). Mirrors the reconnect-once + /// behavior the previous `keycard-py`-based wallet had for `TransportError`. + fn with_reconnect_on_transport_error( + &mut self, + op: impl Fn(&mut Self) -> Result, + ) -> Result { + match op(self) { + Err(KeycardWalletError::Keycard(keycard_rs::Error::Io(io_err))) => { + log::warn!( + "transport error during keycard operation ({io_err}), reconnecting and retrying once" + ); + *self = Self::new()?; + op(self) + } + result => result, + } + } + + /// Initializes an uninitialized card, returning the generated PUK. The caller is responsible + /// for surfacing the PUK to the operator — it cannot be recovered afterward. + pub fn initialize(&mut self, pin: &str) -> Result { + self.select()?; + let already_initialized = self + .command_set + .app_info() + .expect("select() populates app_info on success") + .is_initialized(); + if already_initialized { + return Err(KeycardWalletError::AlreadyInitialized); + } + + let puk = generate_puk(); + self.command_set + .init(pin, &puk, &pairing_password())? + .check_ok()?; + Ok(puk) + } + + pub fn pair(&mut self, pin: &str) -> Result<(u8, [u8; 32]), KeycardWalletError> { + self.select()?; + self.command_set.auto_pair(&pairing_password())?; + let pairing = self + .command_set + .pairing() + .expect("auto_pair sets pairing data on success") + .clone(); + + if let Err(err) = self.open_and_verify(pin) { + drop(self.command_set.auto_unpair()); + return Err(err); + } + + Ok((pairing.pairing_index(), *pairing.pairing_key())) } pub fn setup_communication_with_pairing( - &self, - py: Python<'_>, + &mut self, pin: &str, index: u8, - key: &[u8], - ) -> PyResult { - self.instance - .bind(py) - .call_method1( - "setup_communication_with_pairing", - (pin, index, key.to_vec()), - )? - .extract() - } - - pub fn close_session(&self, py: Python<'_>) -> PyResult { - self.instance - .bind(py) - .call_method0("close_session")? - .extract() + key: &[u8; 32], + ) -> Result<(), KeycardWalletError> { + self.select()?; + self.command_set.set_pairing(Pairing::new(*key, index)); + self.open_and_verify(pin) } /// Connect using a stored pairing if available, falling back to a fresh pair. /// Saves any newly established pairing to disk. - pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> { - if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) - && self - .setup_communication_with_pairing(py, pin, pairing.index, &pairing.key) - .is_ok() - { - return Ok(()); + pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> { + if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) { + let key: [u8; 32] = pairing + .key + .clone() + .try_into() + .expect("KeycardPairingData::is_valid checked the key is 32 bytes"); + let reconnected = self.with_reconnect_on_transport_error(|wallet| { + wallet.setup_communication_with_pairing(pin, pairing.index, &key) + }); + if reconnected.is_ok() { + return Ok(()); + } } - let (index, key) = self.pair(py, pin)?; - save_pairing(&KeycardPairingData { index, key }); + + let (index, key) = self.with_reconnect_on_transport_error(|wallet| wallet.pair(pin))?; + save_pairing(&KeycardPairingData { + index, + key: key.to_vec(), + }); Ok(()) } - pub fn disconnect(&self, py: Python) -> PyResult { - self.instance.bind(py).call_method0("disconnect")?.extract() + /// Unpairs the current session. Returns `false` if there was nothing to unpair. + pub fn disconnect(&mut self) -> Result { + if self.command_set.pairing().is_none() { + return Ok(false); + } + self.command_set.auto_unpair()?; + Ok(true) } - pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult { - let public_key: Vec = self - .instance - .bind(py) - .call_method1("get_public_key_for_path", (path,))? - .extract()?; + pub fn get_public_key_for_path(&mut self, path: &str) -> Result { + let resp = self.command_set.export_key(path, false, true)?; + resp.check_ok()?; + let keypair = Bip32KeyPair::from_tlv(resp.data())?; - let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "expected 32-byte public key from keycard, got {} bytes", - vec.len() - )) - })?; + // Uncompressed SEC1 point (0x04 || X || Y); the BIP340 x-only public key is just its X + // coordinate, since secp256k1 (what the card signs with) and k256's Schnorr verifying key + // are the same curve. + let public_key = keypair.public_key(); + let x_only: [u8; 32] = match public_key.split_first() { + Some((&0x04, xy)) if xy.len() == 64 => xy + .split_at(32) + .0 + .try_into() + .expect("split_at(32) of a 64-byte slice"), + _ => { + return Err(KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 65-byte uncompressed secp256k1 public key from keycard, got {} bytes", + public_key.len() + ))); + } + }; - PublicKey::try_new(public_key) - .map_err(|e| PyErr::new::(e.to_string())) + PublicKey::try_new(x_only).map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string())) } - pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let pub_key = wallet.get_public_key_for_path(py, path); - drop(wallet.close_session(py)); - pub_key - }) + pub fn get_public_key_for_path_with_connect( + pin: &str, + path: &str, + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.get_public_key_for_path(path) } pub fn sign_message_for_path( - &self, - py: Python, + &mut self, path: &str, message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - let py_signature: Vec = self - .instance - .bind(py) - .call_method1("sign_message_for_path", (message, path))? - .extract()?; + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let resp = + self.command_set + .sign_with_path_and_algo(message, path, sign_p2::BIP340_SCHNORR, false)?; + resp.check_ok()?; let sig = Signature { - value: normalize_keycard_signature(py_signature)?, + value: parse_schnorr_signature(resp.data())?, }; - let pub_key = self.get_public_key_for_path(py, path)?; + let pub_key = self.get_public_key_for_path(path)?; if !sig.is_valid_for(message, &pub_key) { - return Err(PyErr::new::( - "keycard returned a signature that does not verify against its own public key", - )); + return Err(KeycardWalletError::SignatureVerificationFailed); } Ok((sig, pub_key)) } @@ -162,39 +253,37 @@ impl KeycardWallet { pin: &str, path: &str, message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.sign_message_for_path(py, path, message); - drop(wallet.close_session(py)); - result - }) + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.sign_message_for_path(path, message) } - pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> { - self.instance - .bind(py) - .call_method1("load_mnemonic", (mnemonic,))?; + pub fn load_mnemonic(&mut self, mnemonic: &str) -> Result<(), KeycardWalletError> { + let mnemonic = bip39::Mnemonic::from_str(mnemonic) + .map_err(|e| KeycardWalletError::InvalidMnemonic(e.to_string()))?; + let seed = mnemonic.to_seed(""); + self.command_set.load_lee_key(&seed)?.check_ok()?; Ok(()) } pub fn get_public_account_id_for_path_with_connect( pin: &str, key_path: &str, - ) -> PyResult { + ) -> Result { let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?; Ok(format!("Public/{}", AccountId::from(&public_key))) } - pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult { - let (raw_nsk, raw_vsk): (Vec, Vec) = self - .instance - .bind(py) - .call_method1("get_private_keys_for_path", (path,))? - .extract()?; + pub fn get_private_keys_for_path(&mut self, path: &str) -> Result { + let resp = self.command_set.export_lee_key(path)?; + resp.check_ok()?; + + let mut reader = BerTlvReader::new(resp.data()); + reader.enter_constructed(TLV_KEY_TEMPLATE)?; + let raw_nsk = reader.read_primitive(TLV_LEE_NSK)?; + let raw_vsk = reader.read_primitive(TLV_LEE_VSK)?; let nsk = zeroizing_fixed_bytes::<32>("nullifier secret key", Zeroizing::new(raw_nsk))?; let vsk = zeroizing_fixed_bytes::<64>("view secret key", Zeroizing::new(raw_vsk))?; @@ -205,46 +294,54 @@ impl KeycardWallet { pub fn get_private_keys_for_path_with_connect( pin: &str, path: &str, - ) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.get_private_keys_for_path(py, path); - drop(wallet.disconnect(py)); - result - }) + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + let result = wallet.get_private_keys_for_path(path); + drop(wallet.disconnect()); + result } } -/// The keycard Python library strips leading zeros from S when S < 2^(8k) for some k. -/// Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S). -#[expect( - clippy::arithmetic_side_effects, - reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]" -)] -fn normalize_keycard_signature(py_signature: Vec) -> PyResult<[u8; 64]> { - if py_signature.len() < 64 { - if py_signature.len() < 32 { - return Err(PyErr::new::(format!( - "signature from keycard too short: {} bytes", - py_signature.len() - ))); - } - let s_stripped = &py_signature[32..]; - let mut padded = [0_u8; 64]; - padded[..32].copy_from_slice(&py_signature[..32]); - padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped); - Ok(padded) - } else { - py_signature.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})", - vec.len(), - vec - )) - }) +fn generate_puk() -> String { + let mut rng = rand::rngs::OsRng; + std::iter::repeat_with(|| char::from(rng.gen_range(b'0'..=b'9'))) + .take(12) + .collect() +} + +fn pairing_password() -> String { + std::env::var("KEYCARD_PAIRING_PASSWORD").unwrap_or_else(|_| DEFAULT_PAIRING_PASSWORD.to_owned()) +} + +/// Parses a BIP340 Schnorr signature from a LEE `SIGN` response. +/// +/// Confirmed against real hardware: `TLV_SIGNATURE_TEMPLATE` (0xA0) contains the usual +/// `TLV_PUB_KEY` (0x80, 65 bytes, unused here — the caller fetches the pubkey separately via +/// `export_key`) followed by `TLV_LEE_RAW_SIGNATURE` (0x88, 64 bytes: `r || s` with no ASN.1/DER +/// wrapping). `keycard-rs`'s own `RecoverableSignature` parser doesn't apply — it's ECDSA-only +/// (attempts point recovery, which Schnorr doesn't have). +fn parse_schnorr_signature(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + parse_schnorr_signature_inner(data).map_err(|e| { + KeycardWalletError::InvalidKeyMaterial(format!( + "failed to parse schnorr signature response ({e}); raw response bytes: {data:02x?}" + )) + }) +} + +fn parse_schnorr_signature_inner(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + let mut reader = BerTlvReader::new(data); + reader.enter_constructed(TLV_SIGNATURE_TEMPLATE)?; + if reader.next_tag_is(TLV_PUB_KEY) { + reader.read_primitive(TLV_PUB_KEY)?; } + let sig = reader.read_primitive(TLV_LEE_RAW_SIGNATURE)?; + sig.try_into().map_err(|v: Vec| { + KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 64-byte raw schnorr signature, got {} bytes", + v.len() + )) + }) } #[expect( @@ -254,9 +351,9 @@ fn normalize_keycard_signature(py_signature: Vec) -> PyResult<[u8; 64]> { fn zeroizing_fixed_bytes( label: &str, raw: Zeroizing>, -) -> PyResult> { +) -> Result, KeycardWalletError> { if raw.len() != N { - return Err(PyErr::new::(format!( + return Err(KeycardWalletError::InvalidKeyMaterial(format!( "expected {N}-byte {label} from keycard, got {} bytes", raw.len() ))); diff --git a/lez/keycard_wallet/src/python_path.rs b/lez/keycard_wallet/src/python_path.rs deleted file mode 100644 index 61196ad5..00000000 --- a/lez/keycard_wallet/src/python_path.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::{env, path::PathBuf}; - -use pyo3::{prelude::*, types::PyList}; - -fn collect_python_paths() -> Vec { - let current_dir = env::current_dir().expect("Failed to get current working directory"); - - let python_base = env::var("VIRTUAL_ENV") - .ok() - .and_then(|v| PathBuf::from(v).parent().map(PathBuf::from)) - .unwrap_or_else(|| current_dir.clone()); - - let mut paths = vec![ - python_base - .join("lez") - .join("keycard_wallet") - .join("python"), - python_base - .join("lez") - .join("keycard_wallet") - .join("python") - .join("keycard-py"), - ]; - - // pyo3's embedded interpreter does not inherit sys.path from the shell, - // so venv site-packages must be added explicitly. - if let Ok(venv) = env::var("VIRTUAL_ENV") { - let lib = PathBuf::from(&venv).join("lib"); - if let Ok(entries) = std::fs::read_dir(&lib) { - for entry in entries.flatten() { - let site_packages = entry.path().join("site-packages"); - if site_packages.exists() { - paths.push(site_packages); - } - } - } - } - - paths -} - -/// Adds the project's `python/` directory and venv site-packages to Python's sys.path. -pub fn add_python_path(py: Python<'_>) -> PyResult<()> { - let paths = collect_python_paths(); - - for path in &paths { - if !path.exists() { - log::info!("Warning: Python path does not exist: {}", path.display()); - } - } - - let sys = PyModule::import(py, "sys")?; - let binding = sys.getattr("path")?; - let sys_path = binding.cast::()?; - - for path in &paths { - let path_str = path.to_str().expect("Invalid path"); - - let already_present = sys_path - .iter() - .any(|p| p.extract::<&str>().is_ok_and(|s| s == path_str)); - - if !already_present { - sys_path.insert(0, path_str)?; - } - } - - Ok(()) -} diff --git a/lez/keycard_wallet/tests/force_unpower.py b/lez/keycard_wallet/tests/force_unpower.py index 427d2028..c6789015 100755 --- a/lez/keycard_wallet/tests/force_unpower.py +++ b/lez/keycard_wallet/tests/force_unpower.py @@ -7,8 +7,9 @@ the power-loss condition reported on some USB reader/driver combinations. Either: - pcscd re-powers the card on the next SCardConnect, so wallet commands will succeed without triggering the retry path. -- the card stays unpowered, triggering TransportError -and exercising the retry wrapper in pair() / setup_communication_with_pairing(). +- the card stays unpowered, triggering a PC/SC transport error +(keycard_rs::Error::Io) and exercising the reconnect-and-retry wrapper in +KeycardWallet::pair() / KeycardWallet::setup_communication_with_pairing(). """ import sys from smartcard.scard import ( diff --git a/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh index d80e2aca..f4aa57fe 100755 --- a/lez/keycard_wallet/tests/keycard_test_3.sh +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -5,8 +5,6 @@ # 1. Run wallet_with_keycard.sh once to install dependencies. # 2. Keycard reader inserted with card loaded (wallet keycard load has been run). -source venv/bin/activate - cargo install --path lez/wallet --force --features keycard-debug export KEYCARD_PIN=111111 diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index dfa30461..3faa48c9 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/keycard_wallet/tests/keycard_tests.sh @@ -1,8 +1,6 @@ #!/bin/bash # Run wallet_with_keycard.sh first -source venv/bin/activate # Load the appropriate virtual environment - export KEYCARD_PIN=111111 # Tests wallet keycard available diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh index cbff19fe..9804fecb 100755 --- a/lez/keycard_wallet/tests/keycard_tests_2.sh +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -23,7 +23,6 @@ # amm-lee-fund → public LEE holding used to seed the AMM pool # (LP holding for amm new is created fresh each run — no persistent label) -source venv/bin/activate export KEYCARD_PIN=111111 # ============================================================================= diff --git a/lez/keycard_wallet/wallet_with_keycard.sh b/lez/keycard_wallet/wallet_with_keycard.sh index 7a43bb57..f77fd790 100755 --- a/lez/keycard_wallet/wallet_with_keycard.sh +++ b/lez/keycard_wallet/wallet_with_keycard.sh @@ -2,11 +2,9 @@ cargo install --path lez/wallet --force -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. +# `pyscard` is only needed by tests/force_unpower.py, a test-only helper that simulates a card +# power loss via PC/SC — the wallet CLI itself is pure Rust and talks to the card directly via +# `keycard-rs`. python3 -m venv venv source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py \ No newline at end of file +pip install pyscard \ No newline at end of file diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index 974d6a71..c03c1505 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -25,7 +25,6 @@ system_accounts.workspace = true associated_token_account_core.workspace = true bip39.workspace = true -pyo3.workspace = true rpassword = "7" zeroize.workspace = true diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index dae8fd31..0be192c5 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -2,7 +2,7 @@ use core::fmt; use anyhow::Result; use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; -use keycard_wallet::{KeycardWallet, python_path}; +use keycard_wallet::KeycardWallet; use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ CommitmentSetDigest, DUMMY_COMMITMENT_HASH, Identifier, InputAccountIdentity, MembershipProof, @@ -213,14 +213,7 @@ impl AccountManager { if pin.is_none() { pin = Some( crate::helperfunctions::read_pin() - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::< - pyo3::exceptions::PyRuntimeError, - _, - >( - e.to_string() - )) - })? + .map_err(ExecutionFailureKind::SignError)? .as_str() .to_owned(), ); @@ -438,17 +431,11 @@ impl AccountManager { .collect(); if let Some(pin) = self.pin.clone() { - pyo3::Python::attach(|py| -> pyo3::PyResult<()> { - python_path::add_python_path(py)?; - let wallet = KeycardWallet::new(py)?; - wallet.connect(py, &pin)?; - for path in keycard_paths { - sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?); - } - let _res = wallet.close_session(py); - Ok(()) - }) - .map_err(anyhow::Error::from)?; + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + for path in keycard_paths { + sigs.push(wallet.sign_message_for_path(path, &message_hash)?); + } } Ok(sigs) diff --git a/lez/wallet/src/cli/keycard.rs b/lez/wallet/src/cli/keycard.rs index 8c42bfd1..65ad0b77 100644 --- a/lez/wallet/src/cli/keycard.rs +++ b/lez/wallet/src/cli/keycard.rs @@ -5,8 +5,7 @@ use anyhow::Result; use clap::Subcommand; -use keycard_wallet::{KeycardWallet, clear_pairing, python_path}; -use pyo3::prelude::*; +use keycard_wallet::{KeycardWallet, clear_pairing}; use crate::{ WalletCore, @@ -39,22 +38,11 @@ pub enum KeycardSubcommand { impl KeycardSubcommand { fn handle_available(_wallet_core: &mut WalletCore) -> SubcommandReturnValue { - Python::attach(|py| { - python_path::add_python_path(py) - .expect("`wallet::keycard::available`: unable to setup python path"); - - let wallet = KeycardWallet::new(py) - .expect("`wallet::keycard::available`: invalid data received for pin"); - let available = wallet - .is_unpaired_keycard_available(py) - .expect("`wallet::keycard::available`: received invalid data from Keycard wrapper"); - - if available { - println!("\u{2705} Keycard is available."); - } else { - println!("\u{274c} Keycard is not available."); - } - }); + if KeycardWallet::is_unpaired_keycard_available() { + println!("\u{2705} Keycard is available."); + } else { + println!("\u{274c} Keycard is not available."); + } SubcommandReturnValue::Empty } @@ -62,20 +50,9 @@ impl KeycardSubcommand { fn handle_connect(_wallet_core: &mut WalletCore) -> Result { let pin = read_pin()?; - Python::attach(|py| { - python_path::add_python_path(py) - .expect("`wallet::keycard::connect`: unable to setup python path"); - - let wallet = KeycardWallet::new(py) - .expect("`wallet::keycard::connect`: invalid keycard wallet provided"); - - wallet - .connect(py, &pin) - .expect("`wallet::keycard::connect`: failed to connect to keycard"); - - println!("\u{2705} Keycard paired and ready."); - drop(wallet.close_session(py)); - }); + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + println!("\u{2705} Keycard paired and ready."); Ok(SubcommandReturnValue::Empty) } @@ -83,24 +60,12 @@ impl KeycardSubcommand { fn handle_disconnect(_wallet_core: &mut WalletCore) -> Result { let pin = read_pin()?; - Python::attach(|py| { - python_path::add_python_path(py) - .expect("`wallet::keycard::disconnect`: unable to setup python path"); + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + wallet.disconnect()?; - let wallet = KeycardWallet::new(py) - .expect("`wallet::keycard::disconnect`: invalid keycard wallet provided"); - - wallet - .connect(py, &pin) - .expect("`wallet::keycard::disconnect`: failed to open session"); - - wallet - .disconnect(py) - .expect("`wallet::keycard::disconnect`: failed to unpair keycard"); - - clear_pairing(); - println!("\u{2705} Keycard unpaired and pairing cleared."); - }); + clear_pairing(); + println!("\u{2705} Keycard unpaired and pairing cleared."); Ok(SubcommandReturnValue::Empty) } @@ -108,22 +73,13 @@ impl KeycardSubcommand { fn handle_init(_wallet_core: &mut WalletCore) -> Result { let pin = read_pin()?; - Python::attach(|py| { - python_path::add_python_path(py) - .expect("`wallet::keycard::init`: unable to setup python path"); + let mut wallet = KeycardWallet::new()?; + let puk = wallet.initialize(&pin)?; - let wallet = KeycardWallet::new(py) - .expect("`wallet::keycard::init`: invalid keycard wallet provided"); - - let initialized = wallet - .initialize(py, &pin) - .expect("`wallet::keycard::init`: failed to initialize keycard"); - - if initialized { - clear_pairing(); - println!("\u{2705} Keycard initialized successfully."); - } - }); + clear_pairing(); + println!("Keycard PUK: {puk}"); + println!("Record this PUK and store it somewhere safe. It cannot be recovered."); + println!("\u{2705} Keycard initialized successfully."); Ok(SubcommandReturnValue::Empty) } @@ -132,25 +88,15 @@ impl KeycardSubcommand { let pin = read_pin()?; let mnemonic = read_mnemonic()?; - Python::attach(|py| { - python_path::add_python_path(py) - .expect("`wallet::keycard::load`: unable to setup python path"); + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + println!("\u{2705} Keycard is now connected to wallet."); - let wallet = KeycardWallet::new(py) - .expect("`wallet::keycard::load`: invalid keycard wallet provided"); - - wallet - .connect(py, &pin) - .expect("`wallet::keycard::load`: failed to connect to keycard"); - - println!("\u{2705} Keycard is now connected to wallet."); - if wallet.load_mnemonic(py, &mnemonic).is_ok() { - println!("\u{2705} Mnemonic phrase loaded successfully."); - } else { - println!("\u{274c} Failed to load mnemonic phrase."); - } - drop(wallet.close_session(py)); - }); + if wallet.load_mnemonic(&mnemonic).is_ok() { + println!("\u{2705} Mnemonic phrase loaded successfully."); + } else { + println!("\u{274c} Failed to load mnemonic phrase."); + } Ok(SubcommandReturnValue::Empty) } diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 923f55ae..2306511f 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -79,8 +79,6 @@ pub enum ExecutionFailureKind { TransactionBuildError(#[from] lee::error::LeeError), #[error("Failed to sign transaction: {0}")] SignError(anyhow::Error), - #[error(transparent)] - KeycardError(#[from] pyo3::PyErr), } #[expect(clippy::partial_pub_fields, reason = "TODO: make all fields private")] diff --git a/lez/wallet/src/signing.rs b/lez/wallet/src/signing.rs index 505dedd9..2ee67ae7 100644 --- a/lez/wallet/src/signing.rs +++ b/lez/wallet/src/signing.rs @@ -1,5 +1,4 @@ -use keycard_wallet::{KeycardWallet, python_path}; -use pyo3::Python; +use keycard_wallet::{KeycardWallet, KeycardWalletError}; /// Lazily opens and reuses a single Keycard session for all keycard signers in one transaction. pub struct KeycardSessionContext { @@ -15,19 +14,18 @@ impl KeycardSessionContext { } } - pub fn get_or_connect(&mut self, py: Python<'_>) -> pyo3::PyResult<&KeycardWallet> { + pub fn get_or_connect(&mut self) -> Result<&mut KeycardWallet, KeycardWalletError> { if self.wallet.is_none() { - python_path::add_python_path(py)?; - let wallet = KeycardWallet::new(py)?; - wallet.connect(py, &self.pin)?; + let mut wallet = KeycardWallet::new()?; + wallet.connect(&self.pin)?; self.wallet = Some(wallet); } - Ok(self.wallet.as_ref().expect("wallet was just inserted")) + Ok(self.wallet.as_mut().expect("wallet was just inserted")) } - pub fn close(self, py: Python<'_>) { - if let Some(w) = self.wallet { - let _res = w.close_session(py); + pub fn close(mut self) { + if let Some(wallet) = self.wallet.as_mut() { + drop(wallet.disconnect()); } } } From 42b53c6907ed629f9a0db4bf7604b59d47dcb3f1 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Mon, 6 Jul 2026 17:30:49 -0400 Subject: [PATCH 2/5] Updated to v2 secure communication --- .gitignore | 4 - Cargo.lock | 6 +- Cargo.toml | 3 +- docs/LEZ testnet v0.1 tutorials/keycard.md | 142 +++++------ lez/keycard_wallet/Cargo.toml | 4 +- .../keycard_applets/LEE_keycard.cap | Bin 104464 -> 0 bytes lez/keycard_wallet/keycard_applets/math.cap | Bin 4874 -> 0 bytes lez/keycard_wallet/src/bin/force_unpower.rs | 52 ++++ lez/keycard_wallet/src/lib.rs | 231 ++++++++---------- lez/keycard_wallet/tests/force_unpower.py | 41 ---- .../tests/keycard_power_recovery_tests.sh | 4 +- lez/keycard_wallet/tests/keycard_test_3.sh | 3 +- lez/keycard_wallet/tests/keycard_tests.sh | 2 +- lez/keycard_wallet/tests/keycard_tests_2.sh | 2 +- lez/keycard_wallet/wallet_with_keycard.sh | 10 - lez/wallet/src/cli/keycard.rs | 50 ++-- lez/wallet/src/lib.rs | 1 - lez/wallet/src/signing.rs | 31 --- 18 files changed, 255 insertions(+), 331 deletions(-) delete mode 100644 lez/keycard_wallet/keycard_applets/LEE_keycard.cap delete mode 100644 lez/keycard_wallet/keycard_applets/math.cap create mode 100644 lez/keycard_wallet/src/bin/force_unpower.rs delete mode 100755 lez/keycard_wallet/tests/force_unpower.py delete mode 100755 lez/keycard_wallet/wallet_with_keycard.sh delete mode 100644 lez/wallet/src/signing.rs diff --git a/.gitignore b/.gitignore index 4c1a18c8..f32b258c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,5 @@ result wallet-ffi/wallet_ffi.h bedrock_signing_key integration_tests/configs/debug/ -venv/ - -keycard_wallet/python/__pycache__/ -keycard_wallet/python/keycard-py/ .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 7227deda..814ba2ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4674,7 +4674,7 @@ dependencies = [ [[package]] name = "keycard-rs" version = "0.1.0" -source = "git+https://github.com/keycard-tech/keycard-rs?rev=98abc3ff1b26785a5631634d5bf24da54da541bf#98abc3ff1b26785a5631634d5bf24da54da541bf" +source = "git+https://github.com/keycard-tech/keycard-rs?rev=52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337#52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337" dependencies = [ "aes 0.9.1", "base64 0.21.7", @@ -4701,12 +4701,12 @@ name = "keycard_wallet" version = "0.1.0" dependencies = [ "bip39", + "hex", "keycard-rs", "lee", "log", + "pcsc", "rand 0.8.6", - "serde", - "serde_json", "thiserror 2.0.18", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 2d269ab0..6742c47d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,13 +163,14 @@ logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/lo logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "98abc3ff1b26785a5631634d5bf24da54da541bf" } +keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337" } # includes the handshake signature-normalization fix (marvin/normalize-signature, merged) rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", "bindgen-runtime", ] } rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] } +pcsc = "2" k256 = { version = "0.13.3", features = [ "ecdsa-core", "arithmetic", diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index 47573f12..a8d2a2df 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -6,46 +6,57 @@ This tutorial walks you through using Keycard with Wallet CLI. Keycard is option ### Required hardware - Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE. - Smartcard reader -- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos. - - `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team). - - `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team) ### Firmware installation -Installation: -1. Install math applet on your keycard; this process only needs to be done once. In the root of repo: - ``` - sudo apt-get install -y default-jdk - wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P lez/keycard_wallet/keycard_applets - cd lez/keycard_wallet/keycard_applets - java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap - ``` -2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop) - - Keycard Desktop is used to install the LEE key protocol to a blank keycard. - - Select (Re)Install Applet and upload the key binary (`lez/keycard_wallet/keycard_applets/LEE_keycard.cap`). - ![keycard-desktop.png](keycard-desktop.png) - - **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa. +The applet (LEE key protocol support, on top of standard Status Keycard commands) is no longer vendored in this repo as a pre-built `.cap` — build it directly from source. -## Wallet with Keycard -Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `lez/keycard_wallet/wallet_with_keycard.sh`. +**Currently this means building from a fork with a pending fix**, not upstream directly. We found and fixed a bug where the card signed LEE Schnorr signatures over the wrong message entirely (see [PR](https://github.com/jonesmarvin8/status-keycard/pull/new/marvin/fix-schnorr-hash-aliasing), not yet merged into [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)): ```bash -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. -python3 -m venv venv -source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py +git clone --recurse-submodules --branch marvin/fix-schnorr-hash-aliasing https://github.com/jonesmarvin8/status-keycard.git +cd status-keycard ``` -**Important**: Keycard wallet commands only work within the virtual environment. +**Once that PR is merged, switch back to cloning `keycard-tech/status-keycard`'s default branch directly** — this fork/branch pointer is temporary. + +The build requires **OpenJDK 11 specifically** (newer JDKs aren't compatible with its Gradle/plugin versions): + ```bash -# In the root of LEE repo: -source venv/bin/activate +sudo apt-get install -y openjdk-11-jdk +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 ``` +Gradle's default heap is too small for this build and will OOM during `buildSrc` compilation; bump it once: + +```bash +echo "org.gradle.jvmargs=-Xmx2g" >> gradle.properties +``` + +Build and install onto a connected, blank card (disconnect all other card readers first): + +```bash +./gradlew install +``` + +This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load the applet. + +### Personalizing the card + +**Installing the applet alone isn't enough to use the card.** A freshly installed applet has no identity certificate, and refuses every command (including `SELECT`) until one is provisioned via `IdentApplet`. There isn't yet a documented, supported personalization path for production cards — this is a known gap, not something to work around ad hoc. + +For development/testing, the project's own JUnit test suite provisions the card against a fixed test CA as a side effect of running any test — run the narrowest one: + +```bash +./gradlew test --tests "im.status.keycard.KeycardTest.selectTest" +``` + +If the card isn't initialized yet, this also initializes it with fixed test values (not secrets): PIN `000000`, alt-PIN `024680`, PUK `012345678901`. To re-personalize a card that's already initialized with different values, wipe it first with `wallet keycard factory-reset --confirm` (see the Commands table below), then re-run the test above, then `wallet keycard init` with whatever PIN you want. + +Because this personalizes against a throwaway test CA rather than a real production CA, the wallet needs to be told to trust it explicitly — see "CA public key override" below. Real, production-personalized cards need no such override. + +**Important:** keycard can only connect with one application at a time; if another tool is using the keycard then Wallet CLI cannot access the same keycard, and vice-versa. + ## PIN entry Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable: @@ -60,43 +71,37 @@ Unset it when done: unset KEYCARD_PIN ``` -## Pairing password +## CA public key override -The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair. +Secure Channel V2 verifies the card's identity certificate against a trusted CA public key. `keycard-rs` ships with the production default baked in, so real, production-personalized cards need no configuration at all. -The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password. - -To use a custom pairing password, set it before `init`: +Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA, so the wallet needs to be told to trust it explicitly: ```bash -# Note: Keep the leading space before this command. -# Leading space prevents this command from being stored in shell history -# (when HISTCONTROL=ignorespace is enabled). - export KEYCARD_PAIRING_PASSWORD=my-custom-password -wallet keycard init +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 ``` -After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key — the pairing password is not needed again until the pairing is cleared. +If this is unset (or set, but doesn't match the card's actual certificate), every keycard command will report the card as unavailable — that's the default-CA check correctly rejecting a card it doesn't recognize, not a bug. If it's set to something that isn't 66 hex characters, commands fail immediately with `invalid KEYCARD_CA_PUBLIC_KEY: ...` instead of a confusing "unavailable". -**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail. - -Unset the pairing password variable when done: +Unset it when you're done testing against a dev card: ```bash -unset KEYCARD_PAIRING_PASSWORD +unset KEYCARD_CA_PUBLIC_KEY ``` ## Keycard Commands +Keycard uses Secure Channel V2 (applet version >= 4.0) — the wallet authenticates the card via its identity certificate and opens a fresh ECDHE-derived channel every session. There's no pairing step and nothing cached between commands; you'll enter your PIN each time you connect. + ### Keycard | Command | Description | |----------------------------------|-----------------------------------------------------------------------| | `wallet keycard available` | Checks whether a Keycard reader and card are accessible | | `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK | -| `wallet keycard connect` | Establishes and saves a pairing with the Keycard | -| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing | +| `wallet keycard connect` | Opens a secure channel with the Keycard and verifies the PIN | | `wallet keycard load` | Loads a mnemonic phrase onto the Keycard | +| `wallet keycard factory-reset` | Wipes PIN/PUK/keys back to uninitialized, for re-`init` — **debug builds only** (see below) | | `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) | 1. Check keycard availability @@ -118,13 +123,13 @@ Record this PUK and store it somewhere safe. It cannot be recovered. ✅ Keycard initialized successfully. ``` -3. Connect (pair and save pairing for subsequent commands) +3. Connect (open a secure channel and verify the PIN) ```bash wallet keycard connect # Output: Keycard PIN: -✅ Keycard paired and ready. +✅ Keycard connected and PIN verified. ``` 4. Load a mnemonic phrase @@ -140,25 +145,22 @@ Keycard PIN: ✅ Mnemonic phrase loaded successfully. ``` -5. Disconnect (unpair and clear saved pairing) -```bash -wallet keycard disconnect +5. `factory-reset` and `get-private-keys` (**debug builds only**) -# Output: -Keycard PIN: -✅ Keycard unpaired and pairing cleared. -``` - -6. Get private keys for a BIP-32 path (**debug builds only**) - -`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent. - -First install the wallet with the `keycard-debug` feature: +Both require building the wallet with the `keycard-debug` feature: ```bash cargo install --path lez/wallet --force --features keycard-debug ``` -Then run the command: +`factory-reset` wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be re-`init`ialized — the counterpart to `init`. It does **not** remove the identity certificate, so the card doesn't need re-personalizing afterward. Irreversibly destroys any keys currently on the card, so it requires `--confirm`: +```bash +wallet keycard factory-reset --confirm + +# Output: +✅ Keycard factory-reset. Run `wallet keycard init` to reinitialize it. +``` + +`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. Requires `--reveal` to confirm intent: ```bash wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal @@ -515,20 +517,4 @@ bash lez/keycard_wallet/tests/keycard_tests.sh bash lez/keycard_wallet/tests/keycard_tests_2.sh bash lez/keycard_wallet/tests/keycard_test_3.sh bash lez/keycard_wallet/tests/keycard_power_recovery_tests.sh -``` - -## SigningGroup - -`SigningGroup` (`lez/wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved. - -Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing. - -Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce. - -``` -SigningGroup { - local: [(AccountId, PrivateKey)], // signed in pure Rust - keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session -} -``` ``` \ No newline at end of file diff --git a/lez/keycard_wallet/Cargo.toml b/lez/keycard_wallet/Cargo.toml index 7a062d8b..33fabfb4 100644 --- a/lez/keycard_wallet/Cargo.toml +++ b/lez/keycard_wallet/Cargo.toml @@ -11,9 +11,9 @@ workspace = true lee.workspace = true keycard-rs.workspace = true bip39.workspace = true +hex.workspace = true log.workspace = true +pcsc.workspace = true rand.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true thiserror.workspace = true zeroize.workspace = true diff --git a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap b/lez/keycard_wallet/keycard_applets/LEE_keycard.cap deleted file mode 100644 index b2e71d565454eb9debe51a0bbba7473cb7f1f9fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104464 zcmeFad0;V!IcET12VumC@B#=mg5Kxm41`@K3Nk9m-CZG)p zh^4kx?SlI{qAgWhTbH)4R%^AjwYGM#cCmJ~t$o{Cm;64@xp%S%^?iT;{CgtBZx$9TI z|14vlyl#E(t@Evqy}9;XGw;%X@$|KK{qm_3Rp&kWlVdmDb^Sf2cjvZyKl0L&^WS>& z<@$|lJMVOxhraW#%RYVSbMLkmj+z@^J7AsI9J`_@_ma<6v4(43Yd-L`lbgRfbTaSA zqThZ=OW(CAv9ICXfzLjAN8?x73iAff#rK})TNRy^{_>vZ8}9hK=jVU!JY0X{!jk=0 z-|$g+-mU+B`^MYn{m~u&>Wyo@_0Px)?|ypV_m)-Qp^Trl`(-qgXKe6Y+-+n4% z>&K@&)>ymv#mk2;d$#KJF>LPgaZr&>Dv#dlX-bk#NQzkTng zR*XbHJvfjZkGK8$j=#S5B!BZ~{DogU@$mMEuhks? z^8P8u-+4Cj%jb9esN%Hwv*)L*n|0;It8O-iPu@AbcICE`CkwB8BH#MQb(@Uazp(x5 z>*l|ppnb*Ag_h2PrxEN3j$V5}be;9<yB98+_u&@XVZTE?C76_(1bKkqAiW_;2AY1cT{`tMwKa&I{8%~0nxnlC*e)R5*hnJu`y8eeQYr5mT zeX2{1E!1^R^kdsOAA>3=t3J^=)X(n!&4Z?c;v7pB=Zj$Odrr0 z%Qcu$BClW^X;X&}`U`y9@K!IE1{@xc1{&fR5fqTv}TvRR{<1=x&ap}fz2y+*tdYQOFxU$jrK{*`4e1|0X4|CE; z{B;=D)p*u$5uc7A{YqR{a0ZnyY%${yX9nGe12oy^LWzBtnGg$~Qcb`mKiYIR8 zGG z6NO(Wd>UoY1L}UjAfz36^VYu?GDa8vf7tNW#|GMachLCE!oOO4AuHw=u?}@_VY~Ul zp<>>{I>miM5%DzJ?fxNq&+|*x;rRj6)%_J_&|RhfneA5Tbf_-LC4sR5l1T>y)5epyI~V0xakSNpfO#wBug{%nk2VwE2CS9Ws9ixXQ;Bp7N^y+cRNf?oUHPD$MPn)~!zbEQFb&Rz&_+nh3u%>F<+iej%saw9 zGw7aXkIX8xPL^d_#nuo$ORVU*X=SBA#+s>cK`**hmC3vP$m|zJl(}mWKushJI zRb&mL+KfDd8b(2CSqox9PlZ932S<#siRpQI#0(p_|7gS&hO(ptci5T&;1467uvGxy zPa}Fb$R0qYKaY4Z@qYme{B^_^wz5=aO4v#VBLMz8W_{U+KOUSN<|8SnGlZ%C^GIM& z%*P~JLk-qYr>@1N+~>JDq?LF=?p6<`_y}9kl9?6qG3)E%ltaJit~-TqlmO%7lrI zsA?q@(8>~~x~rT>uBq+^Se3NWDE$blE+2GXB1=>g32%t*CkS(DhMsLom;^SNTrZ2{ z5~v2J60I~)eszf%KET!hhczb>UKvt-D&*=vfLtV3;YK)`zzuis3y@YQO~Ip$(Z>(4 zwGu7irm%5;pc$%9W3)$SRRAY9)ogO-D+JvNLARowl}{+S6JDzVDB;#(Q<{VT(@*KD z-x}w^rptL@m6#;FSsKYTqJ_DxXtY$8Xpy`^tDtjgBu|e*gaL3hum6pTZub<-Jq6Fbg$2GXgvlF z$|hN&Yb6F4hghV03wku8g+VsjP~rmN#vXui$W;mCTp{=75-)C^%_Tkvf;Z%A*j$o= z5vEvy^Fv0%Ca7Q|w$Sa~I`kZb#LWV`I5RLcP? zLuO@(Vris=%xz^Dva7B|No0xH^$IdwmTz1$HDAse4zLCo zqXVo_$}(`9V_Y^3iUQb`3v9cxd4gTB7$5y5bCXTkk~AcPdVx~U_~C#jY*Tf-P%=RWCA0SiI9<*1s#d(BWzo=#K^K{gbZb1cBU(= z{E=|T2wuyMunXfbABCjhKnP6DMfTKOoSd35ufiJhDuQ9ly4E|Any?NTiBxG>N7(kT z*v^ZD6Oob3uvLuWS@ta(H)Wx6 z>>{~fnRwW0QE7Q$$T-U8kMl|YpfNR3uq!(;byq5o-g}PaNmz;CQFiIbRAP~RgP5WH zHWv*N7j>1Z87CJ7>!Z-<73>|jDc>G_lD#HkI9+AlK-? zUF^6lL9Wgi=7~}K6)3sI+B+Rp%~%83>qMj>rp8q`!rCt6Z-gB=HVs~q?Z7sicT za^UnCiq4ASY6|ioig22=#MDf1>LPV~QEpe$5UFT%F@}h+h5!|2>p^pu{D-G_7 zrza(38eJ|ZD_?I?$W?2W3(B*^6?YjWhO5K`8rGB}>{DQ=>(4<&com->nUe`QTfEQk%D+J4Klqm1%9wI?;%RLqEjie<73K9V=%M?HNWHI;T5)Pc(pb}})IKH#FCq=s%*y{p+6 zcc&BS*KpP>TRgT?JUq$2K5o0nmQ@Ck&&;zd(x6S!vdhBzo=V;~Ch`BJ@9lWyn%z9% zc8k-~HDyVJGVMC!2G%)UD&6KHXk<*obc{&9jV7 zFIXS0(irCxUt%qBr(=HM5P&6}6det#0z*!Mfjp=ok0A4dIwa)oxeL5b`!tBN^;Yk1$_ zr1`edyuHOiGtWvrlZJ(mZOH1Kt$rd6O&F0zkwc9>QIKf`m6VS`ca|$58`RDe%}Ep~ zLO4PL;R7tha>JQNS%OFc*Y|!RiAHyaB*5OvcUaCSxPL|q8D2AyfrU>ZB*~jkq(!7! zYc8Oih|GnYsPtTk6(z~b1LcFl3d^gCa1{@l(_InED71z<)#@y|04sjz#3)wE@V9LU zzB{c2cf0dSwA@m$AmS>tZtirGLqDAKR*tbRgefjy9kkbrhi4?xldgWDa*BX$5lWy* zE59=YNRI+>JB6^KF_Kr63+G?qP@*>p5f8`(i6Gj-lz*{A$ME2$qP@3LyRsv6EgA$ z4u|xsN(OZpjV~Y{%Is7LQ7e3^<5mkWsmwTqDcdm2VfGROY|O%QU$}{5BY#ZY);bhl;{r3M(BgB^Ttj zYB~FIf|rjrl;-7h8acV=@5{?Qzcg6Ea<$6y^EU0%#>87C-uQVxBW2svOxCL#`)1&(sSY?;V*K=4_P1Lny@o&)B}M6L~DH93*ulZ+Pn z{d!V`L*}q@bR%?UZ6eJ_b3yqCo)zGlhHE;mLR{p$k1dH3`7)7jcXf&L9Nt!0*`K6wG8I9q^n`JroaJLqch^RZ@LxI+TuG^OhY{kVv-C)BPnG= zFoFnhyYfS3NQbhs=fz8k?kM}O7O&$JCXFeTJj$Men|4MJw#20b6MFgS#Gc~1_%>P+mlZtfZfgO_? zlB`GGRmGXEM7D%he4v~(pI$P732lXc#u$O@Bo_+t9q>Us0|0P8TB{LqwRl2$iw}3lLsES56x0jU zh`Y(nt((j62`XLeJP*;cO>!dXVF7Qt+GA`de`F*(HaU^|_% zSJ-7~77W`SS`}v&Mjq%2TT>J=6g2*7w7gHHYR8-V-+zo<| zgOz{GYXlz1nVFApgJv!3#rur-Z?t{7b*eZPK2?0Gq%)P=qip3K zecK7wBDdrKd+vngigqD>`5bvz^n8pZOK>ZZMlbKu@u7lv-?4*u&m)MZUJZWu8$Ap& zr0cy?@lgp|QiuxLQbTmeRaEP+Hd^ zSPJy#ELt-hgxMGbrnG!NHER!>Jys-gfc;=Dt`lIM>@(O40&7P_GBF}ptB}6kMN`$~ zSY0&u5=kHIRj-K;z=*a-QNT^}3@2H+c|VNM1jFBZc@T~ay{c31RZlveR^n-;dNRV=HG=)v95gG! z7^ht<6CUZ$_G$Hrne@&`tx3X(nFz~cov{xuy=G%fKTpg~z6v|SUft)Zz@j^H$V-an zv2p@h@TyMhEF};xX_T)OIplL5Dddv$Q-7h-l0le3PdpO|j;|h$5PXEaMr8`<^^mR4 zB8Lo0X{GMAV=a4x{W5}Os-2{6>*>C~ja@K}bPg9jk4LUea6B<`3ht+FMyw$2GBSh4Q zb&!u_WnwKF(|FzAd?1VHl0>?~TD8x(Byvd4#O!UrU5%1DLmZ!8Aw_KX^(Z~;Glz2e z*&L`Wa>(Z34}pX%dTW&dC4YqdcHgAOH}(a04?}LmKK0V&6hwCp9Phc|%#nFvD;1Oe zr;+(#%RezFzu*JUrD3ZAlJn=0g?Q(zfJFdusu6%+i~#8PUV|lxB}W)-FZeMjCcw1q zAPKjHa}F=HWBFy!S9rHrmRupDWZR%?YGU3YmudmLYZ(jZF_McAL}DSP@6~bwqYL7m zxwg|dm{@=ewc=F)0a^Z6`Fh(iNp(&_A}74H|_>5-2?aeIz

^Bh^A;OP#OA@xmezb)t<5{+ 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 diff --git a/lez/keycard_wallet/keycard_applets/math.cap b/lez/keycard_wallet/keycard_applets/math.cap deleted file mode 100644 index b9c0e99f7cb534501781bbfeada8f9fd09a6acf3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4874 zcmcgve{d9c6@TB&-mm1Agxp=iVSZ{FEl7y&oYL?iZIk zOw!IkrMjZ`P8aoO&Voa!2q)jFKXjPDQ3bmG2I-Tjzsg|}=iv-`>yWDoy zVD>L|=k0#K@4NeX@B4o3KCdT_w}R1V1oO8?w!!BPEAY_1p|h!eWRP5Xg=Qk-tJR7S08Nm3_UY>WY3G^25$W9_`|O(E;-logE8aGjr}9t z;-eMEFO1x{_|t!MJ3cL5wqc#O>vzjtZ?Af3!oP2{@#&7$@BgLZXH%!99H);x^MpHG zXZ_{1me+sy$|G;DI$zzj?~3dDAG$Am+5O!jd&k}3!h5drduvXQ{(R8Y^ycXKr$70` zx_{-NCz`(}wr(pOe6RV?zxB%#AHVhbt7X+wchziqwXbL5WBs$eSDx*u^QPZ9vh&G3 zZEuuan2dgPO5b-Vy12!6skQ&o<=XI;9o4R4XVtyyJ{mso<{kg)J6^T++5?CGcJ#H; z&cU(A9*m8DWyzfH;t=Od)KXe zvez_y?(BoXiGP-QJqF`VXU*Y<8Gt(vz>AK|2QY%QcDyI6J{o*cBnze?+U?|xT5EBd>e?N2ceU9UR8AcJ12d-r9*OekCcP_hF zgQAG)HHdVFlc7K=o*W{n98`k?3BV2|V1+Ko2NM|JKJEtc@Zi)t9DC}qNXrg4 zMicR5ie&L%&+ZNUz!)nyzY8O}ant$^Z(O-0@AZ==JK_^HBF$|E*^2AqF|=|l<&DQ9 zBuR&R(dqX9=pTYSBo2v3VoEUw4^Ep)5uT_{iL=mPQPf+@VBve-!+h8P|xkAa4!z zw#5U89*@!fO&EdzI-SjCD^vpi^;%^oNhQ)^(+z{O!IuvPhQZ9#F;M%Hj&J!9ji>=> zJ|nn81A$~Xf%!w47QlUoNJ)bF0tOgvSw|DbFeny)g~lAi@W_ty7zRpq%rj`gKqB+g z+h*j`X=O$E;~w{TlL4z348fYE#Yr?T`YAxhB>bihW^@RtZwoMEEYS2gH z!KPpk3LQNzpKq2BhKa!SxPTWsdV7KSaL^Wt0rLazCl2qIrZM1S(lka>0&7o4q`NQe zm+o6bK`6+9BQQEjIyrP9nHAl@BO#N^+^~dTH6%XZN&!06gsVmlJ)2K%&#YP)&I<4m z3x{qbhg$N`lhS~P!pxEmiZUEMEX)iHP@GvZBAoQFDH{(IHw*DBmQI@_wEXr6T_lO5 zuL13J#qAMVDG9B@O1ub#ws2?-gKS=5*1W>3d4*Z?3Ov3^mKStc8wymM$(Te1P>T*R z!xCj0y?m(a=nGi5iT+O+QAT%ws{fgp-yTQY72w2z0$^hc0WOrg=JC)@pIu#`BT@0 zp)6v7>T#h4ls-83(GXJvhkhJV3ehdYL9UcVbdxyQJj#f`M9K^>lb9lv&_%v@IvEHx zhowy^ZAX>COvz(_rDps&Dl)@zy42I90V@&E*bRdL(Mwtw!3P2^Da)LfS zPQ2p}An2A(0{ayXI4KJC60-m!jy`_U0~m)3aE7ul|1&TD2w@1svaGD!&f9c0z1?6l z+DylQlcA*Yk}UxQ*PD4I3x_XDYb3&grRQXngtdk4}V^9^aUKz{>n5cnx|Hj%7$Iq_w7@(T!W&#T2U%BRkvBu3aHGw{+ro& z5^}CT20FEhRI`>yDz-{a5^_!#a!wb}VHu|jIj0MfTgmByl2_>H*@)muMjB*-7^cJ80Il3S|k5HoW=mDR0`8eQG9$mqMYpwD(NYFKr%BEw$L04ujHYFu@X zA>*bo)aEmWs^d_DtJ@42{LdP|RZWK)T-|EO;10~I`81as4>hj3;gE5MHQ*}S4>h*B xJ&>^-SoY`BIoB+xVb#A&GHe_hwOha{e*mPPC$C3GUmvVV0z7~o8ln#){1=8l7BBz+ diff --git a/lez/keycard_wallet/src/bin/force_unpower.rs b/lez/keycard_wallet/src/bin/force_unpower.rs new file mode 100644 index 00000000..9cf07f1b --- /dev/null +++ b/lez/keycard_wallet/src/bin/force_unpower.rs @@ -0,0 +1,52 @@ +#![expect( + clippy::print_stdout, + reason = "This is a CLI test helper, printing to stdout is expected and convenient" +)] + +//! Forces the card in the first available reader into the unpowered state via PC/SC +//! `SCARD_UNPOWER_CARD`. Run immediately before a wallet command to simulate the power-loss +//! condition reported on some USB reader/driver combinations. +//! +//! Either: +//! - pcscd re-powers the card on the next `SCardConnect`, so wallet commands will succeed without +//! triggering the retry path. +//! - the card stays unpowered, triggering a PC/SC transport error (`keycard_rs::Error::Io`) and +//! exercising the reconnect-and-retry wrapper in `KeycardWallet::connect()`. + +fn main() { + let context = match pcsc::Context::establish(pcsc::Scope::User) { + Ok(context) => context, + Err(e) => { + println!("force_unpower: failed to establish PC/SC context ({e}), skipping."); + return; + } + }; + + let readers = match context.list_readers_owned() { + Ok(readers) => readers, + Err(e) => { + println!("force_unpower: failed to list readers ({e}), skipping."); + return; + } + }; + + let Some(reader) = readers.first() else { + println!("force_unpower: no readers found, skipping."); + return; + }; + + let card = match context.connect(reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) { + Ok(card) => card, + Err(e) => { + println!("force_unpower: connect failed ({e}), skipping."); + return; + } + }; + + if let Err((_card, e)) = card.disconnect(pcsc::Disposition::UnpowerCard) { + println!("force_unpower: disconnect failed ({e}), skipping."); + return; + } + + println!("force_unpower: card powered down."); +} diff --git a/lez/keycard_wallet/src/lib.rs b/lez/keycard_wallet/src/lib.rs index 6d3f9777..f7d521cd 100644 --- a/lez/keycard_wallet/src/lib.rs +++ b/lez/keycard_wallet/src/lib.rs @@ -1,28 +1,28 @@ -use std::{path::PathBuf, str::FromStr as _}; +use std::str::FromStr as _; use keycard_rs::{ KeycardCommandSet, PcscChannel, constants::sign_p2, parsing::Bip32KeyPair, - secure_channel::Pairing, + secure_channel::SecureChannelVersion, tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE}, }; use lee::{AccountId, PublicKey, Signature}; use rand::Rng as _; -use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -const DEFAULT_PAIRING_PASSWORD: &str = "KeycardDefaultPairing"; - -/// LEE-applet extension tags. These aren't part of upstream `keycard-rs`'s `tlv` module (which -/// only knows the standard Status Keycard tags) since they're specific to the LEE-flavored -/// applet (`LEE_keycard.cap`). +/// LEE-applet extension tags. `keycard-rs` implements *sending* the LEE commands +/// (`load_lee_key`, `export_lee_key`) but never added parsing for their LEE-specific responses, +/// so these tag values — owned by the applet, not either client library — have to be hardcoded +/// here. Matches `KeycardApplet.TLV_LEE_NSK`/`TLV_LEE_VSK` in `status-keycard` +/// (`KeycardApplet.java:97-98`), the applet `LEE_keycard.cap` was almost certainly built from. const TLV_LEE_NSK: u8 = 0x83; const TLV_LEE_VSK: u8 = 0x84; /// Raw Schnorr signature (64 bytes: `r || s`, no ASN.1/DER wrapping) nested inside the standard /// `TLV_SIGNATURE_TEMPLATE` alongside the usual `TLV_PUB_KEY`. Confirmed against real hardware — /// the LEE applet's `SIGN` response for `sign_p2::BIP340_SCHNORR` is -/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }`. +/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }` — and matches +/// `SECP256k1.TLV_RAW_SIGNATURE` in `status-keycard` (`SECP256k1.java:64`). const TLV_LEE_RAW_SIGNATURE: u8 = 0x88; /// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte @@ -35,51 +35,60 @@ pub enum KeycardWalletError { Keycard(#[from] keycard_rs::Error), #[error("keycard is already initialized")] AlreadyInitialized, + #[error( + "this wallet only supports Secure Channel V2 keycards (applet version >= 4.0); detected {0:?}" + )] + UnsupportedSecureChannel(Option), #[error("invalid mnemonic phrase: {0}")] InvalidMnemonic(String), #[error("invalid key material from keycard: {0}")] InvalidKeyMaterial(String), #[error("keycard returned a signature that does not verify against its own public key")] SignatureVerificationFailed, -} - -// TODO: encrypt at rest alongside broader wallet storage encryption work. -#[derive(Serialize, Deserialize)] -pub struct KeycardPairingData { - pub index: u8, - pub key: Vec, -} - -impl KeycardPairingData { - const fn is_valid(&self) -> bool { - self.key.len() == 32 && self.index <= 4 - } + #[error("invalid KEYCARD_CA_PUBLIC_KEY: {0}")] + InvalidCaPublicKey(String), } /// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC. +/// Only Secure Channel V2 cards are supported — see `require_secure_channel_v2`. pub struct KeycardWallet { command_set: KeycardCommandSet, } impl KeycardWallet { /// Connects to the first available PC/SC reader. Does not select the applet yet — callers - /// that need application info (`initialize`, `pair`, `connect`, ...) do that themselves. + /// that need application info (`initialize`, `connect`, ...) do that themselves. + /// + /// Verifies the card's identity certificate against `keycard-rs`'s default production CA, + /// unless overridden via `KEYCARD_CA_PUBLIC_KEY` — see `ca_public_key_override`. pub fn new() -> Result { let channel = PcscChannel::connect()?; Ok(Self { - command_set: KeycardCommandSet::new(channel), + command_set: Self::build_command_set(channel)?, }) } - /// Returns whether a smart card reader and a selectable Keycard are both present. + fn build_command_set(channel: PcscChannel) -> Result { + Ok(match ca_public_key_override()? { + Some(ca) => KeycardCommandSet::new_with_ca(channel, ca), + None => KeycardCommandSet::new(channel), + }) + } + + /// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both + /// present. #[must_use] - pub fn is_unpaired_keycard_available() -> bool { + pub fn is_keycard_available() -> bool { let Ok(channel) = PcscChannel::connect() else { return false; }; - KeycardCommandSet::new(channel) - .select() - .is_ok_and(|resp| resp.is_ok()) + let Ok(mut command_set) = Self::build_command_set(channel) else { + return false; + }; + if !command_set.select().is_ok_and(|resp| resp.is_ok()) { + return false; + } + command_set.secure_channel_version() == Some(SecureChannelVersion::V2) } fn select(&mut self) -> Result<(), KeycardWalletError> { @@ -87,15 +96,17 @@ impl KeycardWallet { Ok(()) } - fn open_and_verify(&mut self, pin: &str) -> Result<(), KeycardWalletError> { - self.command_set.auto_open_secure_channel()?; - self.command_set.verify_pin(pin)?.check_auth_ok()?; - Ok(()) + /// Rejects any card that isn't running Secure Channel V2 (older applets, or a card that + /// hasn't advertised a secure channel at all). Call right after `select()`. + fn require_secure_channel_v2(&self) -> Result<(), KeycardWalletError> { + match self.command_set.secure_channel_version() { + Some(SecureChannelVersion::V2) => Ok(()), + other => Err(KeycardWalletError::UnsupportedSecureChannel(other)), + } } /// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a - /// transport-level error (e.g. the card lost power mid-session). Mirrors the reconnect-once - /// behavior the previous `keycard-py`-based wallet had for `TransportError`. + /// transport-level error (e.g. the card lost power mid-session). fn with_reconnect_on_transport_error( &mut self, op: impl Fn(&mut Self) -> Result, @@ -116,6 +127,7 @@ impl KeycardWallet { /// for surfacing the PUK to the operator — it cannot be recovered afterward. pub fn initialize(&mut self, pin: &str) -> Result { self.select()?; + self.require_secure_channel_v2()?; let already_initialized = self .command_set .app_info() @@ -125,73 +137,37 @@ impl KeycardWallet { return Err(KeycardWalletError::AlreadyInitialized); } + // V2's INIT has no shared-secret field (confirmed against real hardware and the + // applet's own reference command set: a payload containing one is rejected outright) — + // pass an empty secret and let the card default the PIN/PUK retry counts. let puk = generate_puk(); self.command_set - .init(pin, &puk, &pairing_password())? + .init_with_secret(pin, None, &puk, &[], 0, 0)? .check_ok()?; Ok(puk) } - pub fn pair(&mut self, pin: &str) -> Result<(u8, [u8; 32]), KeycardWalletError> { + /// Wipes the card's PIN, PUK, and loaded keys, returning it to an uninitialized state — + /// the counterpart to `initialize()`. Does **not** remove the identity certificate + /// provisioned via `IdentApplet`, so the card can be re-`initialize()`d afterward without + /// re-personalizing it. Irreversibly destroys any keys currently loaded on the card. + pub fn factory_reset(&mut self) -> Result<(), KeycardWalletError> { self.select()?; - self.command_set.auto_pair(&pairing_password())?; - let pairing = self - .command_set - .pairing() - .expect("auto_pair sets pairing data on success") - .clone(); - - if let Err(err) = self.open_and_verify(pin) { - drop(self.command_set.auto_unpair()); - return Err(err); - } - - Ok((pairing.pairing_index(), *pairing.pairing_key())) - } - - pub fn setup_communication_with_pairing( - &mut self, - pin: &str, - index: u8, - key: &[u8; 32], - ) -> Result<(), KeycardWalletError> { - self.select()?; - self.command_set.set_pairing(Pairing::new(*key, index)); - self.open_and_verify(pin) - } - - /// Connect using a stored pairing if available, falling back to a fresh pair. - /// Saves any newly established pairing to disk. - pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> { - if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) { - let key: [u8; 32] = pairing - .key - .clone() - .try_into() - .expect("KeycardPairingData::is_valid checked the key is 32 bytes"); - let reconnected = self.with_reconnect_on_transport_error(|wallet| { - wallet.setup_communication_with_pairing(pin, pairing.index, &key) - }); - if reconnected.is_ok() { - return Ok(()); - } - } - - let (index, key) = self.with_reconnect_on_transport_error(|wallet| wallet.pair(pin))?; - save_pairing(&KeycardPairingData { - index, - key: key.to_vec(), - }); + self.require_secure_channel_v2()?; + self.command_set.factory_reset()?.check_ok()?; Ok(()) } - /// Unpairs the current session. Returns `false` if there was nothing to unpair. - pub fn disconnect(&mut self) -> Result { - if self.command_set.pairing().is_none() { - return Ok(false); - } - self.command_set.auto_unpair()?; - Ok(true) + /// Opens the secure channel and verifies the PIN. Secure Channel V2 re-authenticates from + /// the card's certificate every session — there's no pairing step and nothing to persist. + pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> { + self.with_reconnect_on_transport_error(|wallet| { + wallet.select()?; + wallet.require_secure_channel_v2()?; + wallet.command_set.auto_open_secure_channel()?; + wallet.command_set.verify_pin(pin)?.check_auth_ok()?; + Ok(()) + }) } pub fn get_public_key_for_path(&mut self, path: &str) -> Result { @@ -217,7 +193,8 @@ impl KeycardWallet { } }; - PublicKey::try_new(x_only).map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string())) + PublicKey::try_new(x_only) + .map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string())) } pub fn get_public_key_for_path_with_connect( @@ -234,9 +211,12 @@ impl KeycardWallet { path: &str, message: &[u8; 32], ) -> Result<(Signature, PublicKey), KeycardWalletError> { - let resp = - self.command_set - .sign_with_path_and_algo(message, path, sign_p2::BIP340_SCHNORR, false)?; + let resp = self.command_set.sign_with_path_and_algo( + message, + path, + sign_p2::BIP340_SCHNORR, + false, + )?; resp.check_ok()?; let sig = Signature { @@ -276,7 +256,10 @@ impl KeycardWallet { Ok(format!("Public/{}", AccountId::from(&public_key))) } - pub fn get_private_keys_for_path(&mut self, path: &str) -> Result { + pub fn get_private_keys_for_path( + &mut self, + path: &str, + ) -> Result { let resp = self.command_set.export_lee_key(path)?; resp.check_ok()?; @@ -297,9 +280,7 @@ impl KeycardWallet { ) -> Result { let mut wallet = Self::new()?; wallet.connect(pin)?; - let result = wallet.get_private_keys_for_path(path); - drop(wallet.disconnect()); - result + wallet.get_private_keys_for_path(path) } } @@ -310,8 +291,22 @@ fn generate_puk() -> String { .collect() } -fn pairing_password() -> String { - std::env::var("KEYCARD_PAIRING_PASSWORD").unwrap_or_else(|_| DEFAULT_PAIRING_PASSWORD.to_owned()) +/// Optional override for the CA public key used to verify a card's identity certificate, read +/// from `KEYCARD_CA_PUBLIC_KEY` as 66 hex characters (a 33-byte compressed secp256k1 key). +/// Falls back to `keycard-rs`'s production default when unset. +/// +/// This exists purely for testing against cards that weren't personalized through the real +/// production process — e.g. `status-keycard`'s own `JUnit` suite signs test cards with a fixed, +/// throwaway CA that will never match the production default. Real users' cards should need no +/// override at all. +fn ca_public_key_override() -> Result, KeycardWalletError> { + let Ok(hex_str) = std::env::var("KEYCARD_CA_PUBLIC_KEY") else { + return Ok(None); + }; + let mut bytes = [0_u8; 33]; + hex::decode_to_slice(hex_str.trim(), &mut bytes) + .map_err(|e| KeycardWalletError::InvalidCaPublicKey(e.to_string()))?; + Ok(Some(bytes)) } /// Parses a BIP340 Schnorr signature from a LEE `SIGN` response. @@ -362,35 +357,3 @@ fn zeroizing_fixed_bytes( arr.copy_from_slice(&raw); Ok(arr) } - -fn pairing_file_path() -> Option { - let home = std::env::var("LEE_WALLET_HOME_DIR") - .map(PathBuf::from) - .or_else(|_| { - std::env::home_dir() - .map(|h| h.join(".lee").join("wallet")) - .ok_or(()) - }) - .ok()?; - Some(home.join("keycard_pairing.json")) -} - -fn load_pairing() -> Option { - let path = pairing_file_path()?; - let file = std::fs::File::open(path).ok()?; - serde_json::from_reader(file).ok() -} - -fn save_pairing(data: &KeycardPairingData) { - if let Some(path) = pairing_file_path() - && let Ok(json) = serde_json::to_vec_pretty(data) - { - drop(std::fs::write(path, json)); - } -} - -pub fn clear_pairing() { - if let Some(path) = pairing_file_path() { - drop(std::fs::remove_file(path)); - } -} diff --git a/lez/keycard_wallet/tests/force_unpower.py b/lez/keycard_wallet/tests/force_unpower.py deleted file mode 100755 index c6789015..00000000 --- a/lez/keycard_wallet/tests/force_unpower.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -""" -Forces the card in the first available reader into the unpowered state via -PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate -the power-loss condition reported on some USB reader/driver combinations. - -Either: -- pcscd re-powers the card on the next SCardConnect, so wallet -commands will succeed without triggering the retry path. -- the card stays unpowered, triggering a PC/SC transport error -(keycard_rs::Error::Io) and exercising the reconnect-and-retry wrapper in -KeycardWallet::pair() / KeycardWallet::setup_communication_with_pairing(). -""" -import sys -from smartcard.scard import ( - SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect, - SCARD_SCOPE_USER, SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1, - SCARD_UNPOWER_CARD, -) - -hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER) -hresult, reader_list = SCardListReaders(hcontext, []) - -if not reader_list: - print("force_unpower: no readers found, skipping.") - sys.exit(0) - -hresult, hcard, _ = SCardConnect( - hcontext, - reader_list[0], - SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, -) - -if hresult != 0: - print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.") - sys.exit(0) - -SCardDisconnect(hcard, SCARD_UNPOWER_CARD) -print("force_unpower: card powered down.") diff --git a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh index 3d8301f7..8b9c5ff4 100755 --- a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh +++ b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -4,14 +4,12 @@ # Forces a card power cycle before each keycard-backed wallet command to verify # commands survive mid-session power loss. -source venv/bin/activate - export KEYCARD_PIN=111111 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" unpower() { - python "$SCRIPT_DIR/force_unpower.py" + cargo run -q --manifest-path "$SCRIPT_DIR/../Cargo.toml" --bin force_unpower } echo "Test: wallet keycard available" diff --git a/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh index f4aa57fe..3d98c43a 100755 --- a/lez/keycard_wallet/tests/keycard_test_3.sh +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -2,8 +2,7 @@ # keycard_test_3.sh — tests for `wallet keycard get-private-keys`. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. -# 2. Keycard reader inserted with card loaded (wallet keycard load has been run). +# 1. Keycard reader inserted with card loaded (wallet keycard load has been run). cargo install --path lez/wallet --force --features keycard-debug diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index 3faa48c9..910f9f73 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/keycard_wallet/tests/keycard_tests.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Run wallet_with_keycard.sh first +# Run `cargo install --path lez/wallet --force` first export KEYCARD_PIN=111111 diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh index 9804fecb..9b629fef 100755 --- a/lez/keycard_wallet/tests/keycard_tests_2.sh +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -2,7 +2,7 @@ # keycard_tests_2.sh — comprehensive token + AMM keycard integration tests. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. +# 1. Run `cargo install --path lez/wallet --force` once to install the wallet CLI. # 2. Reset the local chain so all accounts are uninitialized. # 3. Keycard reader inserted with card loaded. # diff --git a/lez/keycard_wallet/wallet_with_keycard.sh b/lez/keycard_wallet/wallet_with_keycard.sh deleted file mode 100755 index f77fd790..00000000 --- a/lez/keycard_wallet/wallet_with_keycard.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -cargo install --path lez/wallet --force - -# `pyscard` is only needed by tests/force_unpower.py, a test-only helper that simulates a card -# power loss via PC/SC — the wallet CLI itself is pure Rust and talks to the card directly via -# `keycard-rs`. -python3 -m venv venv -source venv/bin/activate -pip install pyscard \ No newline at end of file diff --git a/lez/wallet/src/cli/keycard.rs b/lez/wallet/src/cli/keycard.rs index 65ad0b77..b9626370 100644 --- a/lez/wallet/src/cli/keycard.rs +++ b/lez/wallet/src/cli/keycard.rs @@ -5,7 +5,7 @@ use anyhow::Result; use clap::Subcommand; -use keycard_wallet::{KeycardWallet, clear_pairing}; +use keycard_wallet::KeycardWallet; use crate::{ WalletCore, @@ -17,9 +17,16 @@ use crate::{ pub enum KeycardSubcommand { Available, Connect, - Disconnect, Init, Load, + /// Wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be + /// re-initialized with `wallet keycard init`. Irreversibly destroys any keys currently on + /// the card. Requires --confirm. + FactoryReset { + /// Confirm that the card's current keys should be irreversibly destroyed. + #[arg(long)] + confirm: bool, + }, /// Retrieve the private keys (NSK, VSK) for a given BIP-32 key path. /// /// Prints raw key material to stdout — intended for debugging only. @@ -38,7 +45,7 @@ pub enum KeycardSubcommand { impl KeycardSubcommand { fn handle_available(_wallet_core: &mut WalletCore) -> SubcommandReturnValue { - if KeycardWallet::is_unpaired_keycard_available() { + if KeycardWallet::is_keycard_available() { println!("\u{2705} Keycard is available."); } else { println!("\u{274c} Keycard is not available."); @@ -52,20 +59,7 @@ impl KeycardSubcommand { let mut wallet = KeycardWallet::new()?; wallet.connect(&pin)?; - println!("\u{2705} Keycard paired and ready."); - - Ok(SubcommandReturnValue::Empty) - } - - fn handle_disconnect(_wallet_core: &mut WalletCore) -> Result { - let pin = read_pin()?; - - let mut wallet = KeycardWallet::new()?; - wallet.connect(&pin)?; - wallet.disconnect()?; - - clear_pairing(); - println!("\u{2705} Keycard unpaired and pairing cleared."); + println!("\u{2705} Keycard connected and PIN verified."); Ok(SubcommandReturnValue::Empty) } @@ -76,7 +70,6 @@ impl KeycardSubcommand { let mut wallet = KeycardWallet::new()?; let puk = wallet.initialize(&pin)?; - clear_pairing(); println!("Keycard PUK: {puk}"); println!("Record this PUK and store it somewhere safe. It cannot be recovered."); println!("\u{2705} Keycard initialized successfully."); @@ -101,6 +94,25 @@ impl KeycardSubcommand { Ok(SubcommandReturnValue::Empty) } + fn handle_factory_reset( + confirm: bool, + _wallet_core: &mut WalletCore, + ) -> Result { + if !confirm { + eprintln!( + "WARNING: pass --confirm to factory-reset the keycard. \ + This irreversibly destroys any keys currently loaded on it." + ); + return Ok(SubcommandReturnValue::Empty); + } + + let mut wallet = KeycardWallet::new()?; + wallet.factory_reset()?; + println!("\u{2705} Keycard factory-reset. Run `wallet keycard init` to reinitialize it."); + + Ok(SubcommandReturnValue::Empty) + } + #[cfg(feature = "keycard-debug")] fn handle_get_private_keys( key_path: &str, @@ -135,9 +147,9 @@ impl WalletSubcommand for KeycardSubcommand { match self { Self::Available => Ok(Self::handle_available(wallet_core)), Self::Connect => Self::handle_connect(wallet_core), - Self::Disconnect => Self::handle_disconnect(wallet_core), Self::Init => Self::handle_init(wallet_core), Self::Load => Self::handle_load(wallet_core), + Self::FactoryReset { confirm } => Self::handle_factory_reset(confirm, wallet_core), #[cfg(feature = "keycard-debug")] Self::GetPrivateKeys { key_path, reveal } => { Self::handle_get_private_keys(&key_path, reveal, wallet_core) diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 2306511f..d67232ac 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -44,7 +44,6 @@ pub mod config; pub mod helperfunctions; pub mod poller; pub mod program_facades; -pub mod signing; pub mod storage; pub const HOME_DIR_ENV_VAR: &str = "LEE_WALLET_HOME_DIR"; diff --git a/lez/wallet/src/signing.rs b/lez/wallet/src/signing.rs deleted file mode 100644 index 2ee67ae7..00000000 --- a/lez/wallet/src/signing.rs +++ /dev/null @@ -1,31 +0,0 @@ -use keycard_wallet::{KeycardWallet, KeycardWalletError}; - -/// Lazily opens and reuses a single Keycard session for all keycard signers in one transaction. -pub struct KeycardSessionContext { - pin: String, - wallet: Option, -} - -impl KeycardSessionContext { - pub fn new(pin: impl Into) -> Self { - Self { - pin: pin.into(), - wallet: None, - } - } - - pub fn get_or_connect(&mut self) -> Result<&mut KeycardWallet, KeycardWalletError> { - if self.wallet.is_none() { - let mut wallet = KeycardWallet::new()?; - wallet.connect(&self.pin)?; - self.wallet = Some(wallet); - } - Ok(self.wallet.as_mut().expect("wallet was just inserted")) - } - - pub fn close(mut self) { - if let Some(wallet) = self.wallet.as_mut() { - drop(wallet.disconnect()); - } - } -} From 87ab5bf95c1077f513094450a6e18a506fc2301c Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Mon, 6 Jul 2026 17:51:23 -0400 Subject: [PATCH 3/5] ci: install libpcsclite-dev for pcsc crate builds --- .github/actions/install-system-deps/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install-system-deps/action.yml b/.github/actions/install-system-deps/action.yml index 28c4b41c..ebd9afbd 100644 --- a/.github/actions/install-system-deps/action.yml +++ b/.github/actions/install-system-deps/action.yml @@ -6,5 +6,5 @@ runs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential clang libclang-dev libssl-dev pkg-config + sudo apt-get install -y build-essential clang libclang-dev libssl-dev pkg-config libpcsclite-dev shell: bash From 2dc114c27b70a45142e42f2dfacdfd7e8ab46442 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Tue, 7 Jul 2026 17:04:05 -0400 Subject: [PATCH 4/5] docs(keycard): clarify personalization is mandatory and document default CA Reinstalling the applet via `./gradlew install` wipes any existing personalization regardless of firmware source, which wasn't previously called out and is a common source of "card not available" confusion. Also state plainly that personalization is required before any command works, and document keycard-rs's actual baked-in default CA public key instead of only describing the override mechanism. Export KEYCARD_CA_PUBLIC_KEY in all keycard test scripts so they work against the dev/test-CA personalization flow they rely on. --- docs/LEZ testnet v0.1 tutorials/keycard.md | 43 +++++++------------ .../tests/keycard_power_recovery_tests.sh | 1 + lez/keycard_wallet/tests/keycard_test_3.sh | 1 + lez/keycard_wallet/tests/keycard_tests.sh | 1 + lez/keycard_wallet/tests/keycard_tests_2.sh | 1 + 5 files changed, 19 insertions(+), 28 deletions(-) diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index a8d2a2df..8b4711e6 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -9,17 +9,13 @@ This tutorial walks you through using Keycard with Wallet CLI. Keycard is option ### Firmware installation -The applet (LEE key protocol support, on top of standard Status Keycard commands) is no longer vendored in this repo as a pre-built `.cap` — build it directly from source. - -**Currently this means building from a fork with a pending fix**, not upstream directly. We found and fixed a bug where the card signed LEE Schnorr signatures over the wrong message entirely (see [PR](https://github.com/jonesmarvin8/status-keycard/pull/new/marvin/fix-schnorr-hash-aliasing), not yet merged into [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)): +LEE key protocol support (on top of standard Status Keycard commands) is built from source, from [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)'s default branch: ```bash -git clone --recurse-submodules --branch marvin/fix-schnorr-hash-aliasing https://github.com/jonesmarvin8/status-keycard.git +git clone --recurse-submodules https://github.com/keycard-tech/status-keycard.git cd status-keycard ``` -**Once that PR is merged, switch back to cloning `keycard-tech/status-keycard`'s default branch directly** — this fork/branch pointer is temporary. - The build requires **OpenJDK 11 specifically** (newer JDKs aren't compatible with its Gradle/plugin versions): ```bash @@ -39,21 +35,13 @@ Build and install onto a connected, blank card (disconnect all other card reader ./gradlew install ``` -This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load the applet. +This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load it onto the card. -### Personalizing the card +**Warning: `./gradlew install` uninstalls and reinstalls the applet, which erases any existing personalization.** If you run this against a card that's already personalized (identity certificate, PIN, PUK, and any loaded keys), all of that is wiped, regardless of whether the firmware source changed at all — reinstalling the exact same build twice has the same effect. -**Installing the applet alone isn't enough to use the card.** A freshly installed applet has no identity certificate, and refuses every command (including `SELECT`) until one is provisioned via `IdentApplet`. There isn't yet a documented, supported personalization path for production cards — this is a known gap, not something to work around ad hoc. +### Personalizing your card -For development/testing, the project's own JUnit test suite provisions the card against a fixed test CA as a side effect of running any test — run the narrowest one: - -```bash -./gradlew test --tests "im.status.keycard.KeycardTest.selectTest" -``` - -If the card isn't initialized yet, this also initializes it with fixed test values (not secrets): PIN `000000`, alt-PIN `024680`, PUK `012345678901`. To re-personalize a card that's already initialized with different values, wipe it first with `wallet keycard factory-reset --confirm` (see the Commands table below), then re-run the test above, then `wallet keycard init` with whatever PIN you want. - -Because this personalizes against a throwaway test CA rather than a real production CA, the wallet needs to be told to trust it explicitly — see "CA public key override" below. Real, production-personalized cards need no such override. +**Personalization is mandatory, not optional — every card requires it before any command will work, immediately after installing the firmware.** A freshly installed (or freshly reinstalled) card has no identity certificate, and refuses every command. **Important:** keycard can only connect with one application at a time; if another tool is using the keycard then Wallet CLI cannot access the same keycard, and vice-versa. @@ -71,23 +59,22 @@ Unset it when done: unset KEYCARD_PIN ``` -## CA public key override +## Default CA public key -Secure Channel V2 verifies the card's identity certificate against a trusted CA public key. `keycard-rs` ships with the production default baked in, so real, production-personalized cards need no configuration at all. +`keycard-rs` verifies every card's identity certificate against a trusted CA public key before anything else happens — no match, no commands, regardless of whether the firmware or PIN is correct. The baked-in default is: -Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA, so the wallet needs to be told to trust it explicitly: +``` +029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c +``` + +Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA instead, so the wallet needs to be told to trust it explicitly: ```bash export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 +# unset KEYCARD_CA_PUBLIC_KEY when done testing against a dev card ``` -If this is unset (or set, but doesn't match the card's actual certificate), every keycard command will report the card as unavailable — that's the default-CA check correctly rejecting a card it doesn't recognize, not a bug. If it's set to something that isn't 66 hex characters, commands fail immediately with `invalid KEYCARD_CA_PUBLIC_KEY: ...` instead of a confusing "unavailable". - -Unset it when you're done testing against a dev card: - -```bash -unset KEYCARD_CA_PUBLIC_KEY -``` +If the card's certificate doesn't match whichever CA is in effect, every command reports the card as simply "not available." ## Keycard Commands diff --git a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh index 8b9c5ff4..7440f735 100755 --- a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh +++ b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -5,6 +5,7 @@ # commands survive mid-session power loss. export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh index 3d98c43a..59e0ec6b 100755 --- a/lez/keycard_wallet/tests/keycard_test_3.sh +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -7,6 +7,7 @@ cargo install --path lez/wallet --force --features keycard-debug export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 echo "=== Test: wallet keycard get-private-keys path 10 ===" wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index 910f9f73..3d7018b8 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/keycard_wallet/tests/keycard_tests.sh @@ -2,6 +2,7 @@ # Run `cargo install --path lez/wallet --force` first export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # Tests wallet keycard available # - Checks whether smart reader and keycard are both available. diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh index 9b629fef..b574e6a3 100755 --- a/lez/keycard_wallet/tests/keycard_tests_2.sh +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -24,6 +24,7 @@ # (LP holding for amm new is created fresh each run — no persistent label) export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # ============================================================================= # Keycard setup From 0fd7e8e0f9163af44debb685d48e41b91b716d67 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 8 Jul 2026 09:49:48 -0400 Subject: [PATCH 5/5] fix(keycard): address PR #595 review comments - Propagate the load_mnemonic error instead of swallowing it behind is_ok(), matching every other command handler in this file. - Add deny.toml allowing the keycard-rs git source, fixing the source-not-allowed failure in `cargo deny check`. Licenses and advisories checks still have pre-existing, separate failures not addressed here. - Drop the pinned rev for the keycard-rs git dependency so it tracks the upstream default branch instead. --- Cargo.lock | 4 +- Cargo.toml | 2 +- deny.toml | 239 ++++++++++++++++++++++++++++++++++ lez/wallet/src/cli/keycard.rs | 7 +- 4 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 deny.toml diff --git a/Cargo.lock b/Cargo.lock index 814ba2ad..167b728b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4674,7 +4674,7 @@ dependencies = [ [[package]] name = "keycard-rs" version = "0.1.0" -source = "git+https://github.com/keycard-tech/keycard-rs?rev=52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337#52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337" +source = "git+https://github.com/keycard-tech/keycard-rs#9535a657ba04b1e6916de51777e22b4837c1a84d" dependencies = [ "aes 0.9.1", "base64 0.21.7", @@ -9977,7 +9977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 6742c47d..b8193352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,7 +163,7 @@ logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/lo logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "52cb832a6334ab84ea0a0e1f42aeabcf8a3b4337" } # includes the handshake signature-normalization fix (marvin/normalize-signature, merged) +keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs" } rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..15e55767 --- /dev/null +++ b/deny.toml @@ -0,0 +1,239 @@ +# This template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# Root options + +# The graph table configures how the dependency graph is constructed and thus +# which crates the checks are performed against +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #"x86_64-unknown-linux-musl", + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] +# When creating the dependency graph used as the source of truth when checks are +# executed, this field can be used to prune crates from the graph, removing them +# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate +# is pruned from the graph, all of its dependencies will also be pruned unless +# they are connected to another crate in the graph that hasn't been pruned, +# so it should be used with care. The identifiers are [Package ID Specifications] +# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) +#exclude = [] +# If true, metadata will be collected with `--all-features`. Note that this can't +# be toggled off if true, if you want to conditionally enable `--all-features` it +# is recommended to pass `--all-features` on the cmd line instead +all-features = false +# If true, metadata will be collected with `--no-default-features`. The same +# caveat with `all-features` applies +no-default-features = false +# If set, these feature will be enabled when collecting metadata. If `--features` +# is specified on the cmd line they will take precedence over this option. +#features = [] + +# The output table provides options for how/if diagnostics are outputted +[output] +# When outputting inclusion graphs in diagnostics that include features, this +# option can be used to specify the depth at which feature edges will be added. +# This option is included since the graphs can be quite large and the addition +# of features from the crate(s) to all of the graph roots can be far too verbose. +# This option can be overridden via `--feature-depth` on the cmd line +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory databases are cloned/fetched into +#db-path = "$CARGO_HOME/advisory-dbs" +# The url(s) of the advisory databases to use +#db-urls = ["https://github.com/rustsec/advisory-db"] +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + #"RUSTSEC-0000-0000", + #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, + #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish + #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, +] +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + #"MIT", + #"Apache-2.0", + #"Apache-2.0 WITH LLVM-exception", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +#[[licenses.clarify]] +# The package spec the clarification applies to +#crate = "ring" +# The SPDX expression for the license requirements of the crate +#expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +#license-files = [ +# Each entry is a crate relative path, and the (opaque) hash of its contents +#{ path = "LICENSE", hash = 0xbd0eed23 } +#] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = false +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "allow" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# The default lint level for `default` features for crates that are members of +# the workspace that is being checked. This can be overridden by allowing/denying +# `default` on a crate-by-crate basis if desired. +workspace-default-features = "allow" +# The default lint level for `default` features for external crates that are not +# members of the workspace. This can be overridden by allowing/denying `default` +# on a crate-by-crate basis if desired. +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, +] +# If true, workspace members are automatically allowed even when using deny-by-default +# This is useful for organizations that want to deny all external dependencies by default +# but allow their own workspace crates without having to explicitly list them +allow-workspace = false +# List of crates to deny +deny = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, +] + +# List of features to allow/deny +# Each entry the name of a crate and a version range. If version is +# not specified, all versions will be matched. +#[[bans.features]] +#crate = "reqwest" +# Features to not allow +#deny = ["json"] +# Features to allow +#allow = [ +# "rustls", +# "__rustls", +# "__tls", +# "hyper-rustls", +# "rustls", +# "rustls-pemfile", +# "rustls-tls-webpki-roots", +# "tokio-rustls", +# "webpki-roots", +#] +# If true, the allowed features must exactly match the enabled feature set. If +# this is set there is no point setting `deny` +#exact = true + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite. +skip-tree = [ + #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies + #{ crate = "ansi_term@0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "warn" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "warn" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = ["https://github.com/keycard-tech/keycard-rs"] + +[sources.allow-org] +# github.com organizations to allow git sources for +github = [] +# gitlab.com organizations to allow git sources for +gitlab = [] +# bitbucket.org organizations to allow git sources for +bitbucket = [] diff --git a/lez/wallet/src/cli/keycard.rs b/lez/wallet/src/cli/keycard.rs index b9626370..da02d13b 100644 --- a/lez/wallet/src/cli/keycard.rs +++ b/lez/wallet/src/cli/keycard.rs @@ -85,11 +85,8 @@ impl KeycardSubcommand { wallet.connect(&pin)?; println!("\u{2705} Keycard is now connected to wallet."); - if wallet.load_mnemonic(&mnemonic).is_ok() { - println!("\u{2705} Mnemonic phrase loaded successfully."); - } else { - println!("\u{274c} Failed to load mnemonic phrase."); - } + wallet.load_mnemonic(&mnemonic)?; + println!("\u{2705} Mnemonic phrase loaded successfully."); Ok(SubcommandReturnValue::Empty) }