Merge 02b08b469f6534e9072d854f66e7f02f951291ab into 390f4b48d982511883eec8628a9742976f8de8d3

This commit is contained in:
jonesmarvin8 2026-07-16 16:03:45 +03:00 committed by GitHub
commit 161790a7c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 872 additions and 934 deletions

View File

@ -62,6 +62,7 @@ allow-git = [
"https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git",
"https://github.com/logos-blockchain/sponges",
"https://github.com/arkworks-rs/spongefish.git",
"https://github.com/keycard-tech/keycard-rs",
]
unknown-git = "deny"
unknown-registry = "deny"

View File

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

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

562
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -178,11 +178,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" }
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",
@ -199,7 +202,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [
axum = "0.8.4"
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"] }

View File

@ -6,46 +6,45 @@ 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.
## 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`.
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
# 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 https://github.com/keycard-tech/status-keycard.git
cd status-keycard
```
**Important**: Keycard wallet commands only work within the virtual environment.
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 it onto 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.
### Personalizing your card
**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.
## 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 +59,36 @@ Unset it when done:
unset KEYCARD_PIN
```
## Pairing password
## Default CA public key
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.
`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:
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`:
```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
```
029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c
```
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.
**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:
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
unset KEYCARD_PAIRING_PASSWORD
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
# unset KEYCARD_CA_PUBLIC_KEY when done testing against a dev card
```
If the card's certificate doesn't match whichever CA is in effect, every command reports the card as simply "not available."
## 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 +110,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 +132,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 +504,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

@ -9,8 +9,11 @@ workspace = true
[dependencies]
lee.workspace = true
pyo3.workspace = true
keycard-rs.workspace = true
bip39.workspace = true
hex.workspace = true
log.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
pcsc.workspace = true
rand.workspace = true
thiserror.workspace = true
zeroize.workspace = true

View File

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

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,159 +1,230 @@
use std::path::PathBuf;
use std::str::FromStr as _;
use keycard_rs::{
KeycardCommandSet, PcscChannel,
constants::sign_p2,
parsing::Bip32KeyPair,
secure_channel::SecureChannelVersion,
tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE},
};
use lee::{AccountId, PublicKey, Signature};
use pyo3::{prelude::*, types::PyAny};
use serde::{Deserialize, Serialize};
use rand::Rng as _;
use zeroize::Zeroizing;
pub mod python_path;
/// 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> }` — 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
/// arrays.
type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>);
// TODO: encrypt at rest alongside broader wallet storage encryption work.
#[derive(Serialize, Deserialize)]
pub struct KeycardPairingData {
pub index: u8,
pub key: Vec<u8>,
#[derive(Debug, thiserror::Error)]
pub enum KeycardWalletError {
#[error(transparent)]
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,
#[error("invalid KEYCARD_CA_PUBLIC_KEY: {0}")]
InvalidCaPublicKey(String),
}
impl KeycardPairingData {
const fn is_valid(&self) -> bool {
self.key.len() == 32 && self.index <= 4
}
}
/// Rust wrapper around the Python `KeycardWallet` class.
/// 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 {
instance: Py<PyAny>,
command_set: KeycardCommandSet,
}
impl KeycardWallet {
/// Create a new Python `KeycardWallet` instance.
pub fn new(py: Python) -> PyResult<Self> {
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`, `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 {
instance: instance.into(),
command_set: Self::build_command_set(channel)?,
})
}
pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult<bool> {
self.instance
.bind(py)
.call_method0("is_unpaired_keycard_available")?
.extract()
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),
})
}
pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult<bool> {
self.instance
.bind(py)
.call_method1("initialize", (pin,))?
.extract()
}
pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec<u8>)> {
self.instance
.bind(py)
.call_method1("pair", (pin,))?
.extract()
}
pub fn setup_communication_with_pairing(
&self,
py: Python<'_>,
pin: &str,
index: u8,
key: &[u8],
) -> PyResult<bool> {
self.instance
.bind(py)
.call_method1(
"setup_communication_with_pairing",
(pin, index, key.to_vec()),
)?
.extract()
}
pub fn close_session(&self, py: Python<'_>) -> PyResult<bool> {
self.instance
.bind(py)
.call_method0("close_session")?
.extract()
}
/// 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(());
/// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both
/// present.
#[must_use]
pub fn is_keycard_available() -> bool {
let Ok(channel) = PcscChannel::connect() else {
return false;
};
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;
}
let (index, key) = self.pair(py, pin)?;
save_pairing(&KeycardPairingData { index, key });
command_set.secure_channel_version() == Some(SecureChannelVersion::V2)
}
fn select(&mut self) -> Result<(), KeycardWalletError> {
self.command_set.select()?.check_ok()?;
Ok(())
}
pub fn disconnect(&self, py: Python) -> PyResult<bool> {
self.instance.bind(py).call_method0("disconnect")?.extract()
/// 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)),
}
}
pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult<PublicKey> {
let public_key: Vec<u8> = self
.instance
.bind(py)
.call_method1("get_public_key_for_path", (path,))?
.extract()?;
let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec<u8>| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"expected 32-byte public key from keycard, got {} bytes",
vec.len()
))
})?;
PublicKey::try_new(public_key)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
/// 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).
fn with_reconnect_on_transport_error<T>(
&mut self,
op: impl Fn(&mut Self) -> Result<T, KeycardWalletError>,
) -> Result<T, KeycardWalletError> {
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,
}
}
pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult<PublicKey> {
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
/// 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<String, KeycardWalletError> {
self.select()?;
self.require_secure_channel_v2()?;
let already_initialized = self
.command_set
.app_info()
.expect("select() populates app_info on success")
.is_initialized();
if already_initialized {
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_with_secret(pin, None, &puk, &[], 0, 0)?
.check_ok()?;
Ok(puk)
}
/// 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.require_secure_channel_v2()?;
self.command_set.factory_reset()?.check_ok()?;
Ok(())
}
/// 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> {
let resp = self.command_set.export_key(path, false, true)?;
resp.check_ok()?;
let keypair = Bip32KeyPair::from_tlv(resp.data())?;
// 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(x_only)
.map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string()))
}
pub fn get_public_key_for_path_with_connect(
pin: &str,
path: &str,
) -> Result<PublicKey, KeycardWalletError> {
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<u8> = 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::<pyo3::exceptions::PyValueError, _>(
"keycard returned a signature that does not verify against its own public key",
));
return Err(KeycardWalletError::SignatureVerificationFailed);
}
Ok((sig, pub_key))
}
@ -162,39 +233,40 @@ 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<String> {
) -> Result<String, KeycardWalletError> {
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<PrivateKeyPair> {
let (raw_nsk, raw_vsk): (Vec<u8>, Vec<u8>) = 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<PrivateKeyPair, KeycardWalletError> {
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 +277,66 @@ impl KeycardWallet {
pub fn get_private_keys_for_path_with_connect(
pin: &str,
path: &str,
) -> PyResult<PrivateKeyPair> {
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<PrivateKeyPair, KeycardWalletError> {
let mut wallet = Self::new()?;
wallet.connect(pin)?;
wallet.get_private_keys_for_path(path)
}
}
/// 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<u8>) -> PyResult<[u8; 64]> {
if py_signature.len() < 64 {
if py_signature.len() < 32 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(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<u8>| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(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()
}
/// 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.
///
/// 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<u8>| {
KeycardWalletError::InvalidKeyMaterial(format!(
"expected a 64-byte raw schnorr signature, got {} bytes",
v.len()
))
})
}
#[expect(
@ -254,9 +346,9 @@ fn normalize_keycard_signature(py_signature: Vec<u8>) -> PyResult<[u8; 64]> {
fn zeroizing_fixed_bytes<const N: usize>(
label: &str,
raw: Zeroizing<Vec<u8>>,
) -> PyResult<Zeroizing<[u8; N]>> {
) -> Result<Zeroizing<[u8; N]>, KeycardWalletError> {
if raw.len() != N {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
return Err(KeycardWalletError::InvalidKeyMaterial(format!(
"expected {N}-byte {label} from keycard, got {} bytes",
raw.len()
)));
@ -265,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,69 +0,0 @@
use std::{env, path::PathBuf};
use pyo3::{prelude::*, types::PyList};
fn collect_python_paths() -> Vec<PathBuf> {
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::<PyList>()?;
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(())
}

View File

@ -1,40 +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 TransportError
and exercising the retry wrapper in pair() / 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,13 @@
# 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
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
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,14 +2,12 @@
# 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).
source venv/bin/activate
# 1. Keycard reader inserted with card loaded (wallet keycard load has been run).
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

View File

@ -1,9 +1,8 @@
#!/bin/bash
# Run wallet_with_keycard.sh first
source venv/bin/activate # Load the appropriate virtual environment
# 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.

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.
#
@ -23,8 +23,8 @@
# 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
export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743
# =============================================================================
# Keycard setup

View File

@ -1,12 +0,0 @@
#!/bin/bash
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.
python3 -m venv venv
source venv/bin/activate
pip install pyscard mnemonic ecdsa pyaes
pip install -e lez/keycard_wallet/python/keycard-py

View File

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

View File

@ -1,7 +1,7 @@
use core::fmt;
use anyhow::Result;
use keycard_wallet::{KeycardWallet, python_path};
use keycard_wallet::KeycardWallet;
use lee::{AccountId, PrivateKey, PublicKey, Signature};
use lee_core::{
Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof,
@ -237,14 +237,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(),
);
@ -498,17 +491,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)

View File

@ -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;
use crate::{
WalletCore,
@ -18,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.
@ -39,22 +45,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_keycard_available() {
println!("\u{2705} Keycard is available.");
} else {
println!("\u{274c} Keycard is not available.");
}
SubcommandReturnValue::Empty
}
@ -62,45 +57,9 @@ impl KeycardSubcommand {
fn handle_connect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
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));
});
Ok(SubcommandReturnValue::Empty)
}
fn handle_disconnect(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
let pin = read_pin()?;
Python::attach(|py| {
python_path::add_python_path(py)
.expect("`wallet::keycard::disconnect`: unable to setup python path");
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.");
});
let mut wallet = KeycardWallet::new()?;
wallet.connect(&pin)?;
println!("\u{2705} Keycard connected and PIN verified.");
Ok(SubcommandReturnValue::Empty)
}
@ -108,22 +67,12 @@ impl KeycardSubcommand {
fn handle_init(_wallet_core: &mut WalletCore) -> Result<SubcommandReturnValue> {
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.");
}
});
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 +81,31 @@ 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.load_mnemonic(&mnemonic)?;
println!("\u{2705} Mnemonic phrase loaded successfully.");
wallet
.connect(py, &pin)
.expect("`wallet::keycard::load`: failed to connect to keycard");
Ok(SubcommandReturnValue::Empty)
}
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));
});
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)
}
@ -189,9 +144,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

@ -45,7 +45,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";
@ -80,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")]

View File

@ -1,33 +0,0 @@
use keycard_wallet::{KeycardWallet, python_path};
use pyo3::Python;
/// 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, py: Python<'_>) -> pyo3::PyResult<&KeycardWallet> {
if self.wallet.is_none() {
python_path::add_python_path(py)?;
let wallet = KeycardWallet::new(py)?;
wallet.connect(py, &self.pin)?;
self.wallet = Some(wallet);
}
Ok(self.wallet.as_ref().expect("wallet was just inserted"))
}
pub fn close(self, py: Python<'_>) {
if let Some(w) = self.wallet {
let _res = w.close_session(py);
}
}
}