Updated to v2 secure communication

This commit is contained in:
Marvin Jones 2026-07-06 17:30:49 -04:00
parent 763464d27b
commit 42b53c6907
18 changed files with 255 additions and 331 deletions

4
.gitignore vendored
View File

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

6
Cargo.lock generated
View File

@ -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",
]

View File

@ -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",

View File

@ -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
}
```
```

View File

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

View File

@ -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.");
}

View File

@ -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<SecureChannelVersion>),
#[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<u8>,
}
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<Self, KeycardWalletError> {
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<KeycardCommandSet, KeycardWalletError> {
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<T>(
&mut self,
op: impl Fn(&mut Self) -> Result<T, KeycardWalletError>,
@ -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<String, KeycardWalletError> {
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<bool, KeycardWalletError> {
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<PublicKey, KeycardWalletError> {
@ -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<PrivateKeyPair, KeycardWalletError> {
pub fn get_private_keys_for_path(
&mut self,
path: &str,
) -> Result<PrivateKeyPair, KeycardWalletError> {
let resp = self.command_set.export_lee_key(path)?;
resp.check_ok()?;
@ -297,9 +280,7 @@ impl KeycardWallet {
) -> Result<PrivateKeyPair, KeycardWalletError> {
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<Option<[u8; 33]>, 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<const N: usize>(
arr.copy_from_slice(&raw);
Ok(arr)
}
fn pairing_file_path() -> Option<PathBuf> {
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<KeycardPairingData> {
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));
}
}

View File

@ -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.")

View File

@ -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"

View File

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

View File

@ -1,5 +1,5 @@
#!/bin/bash
# Run wallet_with_keycard.sh first
# Run `cargo install --path lez/wallet --force` first
export KEYCARD_PIN=111111

View File

@ -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.
#

View File

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

View File

@ -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<SubcommandReturnValue> {
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<SubcommandReturnValue> {
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)

View File

@ -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";

View File

@ -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<KeycardWallet>,
}
impl KeycardSessionContext {
pub fn new(pin: impl Into<String>) -> 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());
}
}
}