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 b2e71d56..00000000 Binary files a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap and /dev/null differ diff --git a/lez/keycard_wallet/keycard_applets/math.cap b/lez/keycard_wallet/keycard_applets/math.cap deleted file mode 100644 index b9c0e99f..00000000 Binary files a/lez/keycard_wallet/keycard_applets/math.cap and /dev/null differ 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()); - } - } -}