mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-09 15:29:34 +00:00
Merge 0fd7e8e0f9163af44debb685d48e41b91b716d67 into a0ba6008c38665882e160fb83a9f7c52bc1789aa
This commit is contained in:
commit
8ab77827d3
@ -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
4
.gitignore
vendored
@ -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
562
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -163,11 +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" }
|
||||
|
||||
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",
|
||||
@ -183,7 +186,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [
|
||||
] }
|
||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||
pyo3 = { version = "0.29", features = ["auto-initialize"] }
|
||||
zeroize = "1"
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
|
||||
239
deny.toml
Normal file
239
deny.toml
Normal file
@ -0,0 +1,239 @@
|
||||
# This template contains all of the possible sections and their default values
|
||||
|
||||
# Note that all fields that take a lint level have these possible values:
|
||||
# * deny - An error will be produced and the check will fail
|
||||
# * warn - A warning will be produced, but the check will not fail
|
||||
# * allow - No warning or error will be produced, though in some cases a note
|
||||
# will be
|
||||
|
||||
# The values provided in this template are the default values that will be used
|
||||
# when any section or field is not specified in your own configuration
|
||||
|
||||
# Root options
|
||||
|
||||
# The graph table configures how the dependency graph is constructed and thus
|
||||
# which crates the checks are performed against
|
||||
[graph]
|
||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
||||
# only the specified targets will be checked when running `cargo deny check`.
|
||||
# This means, if a particular package is only ever used as a target specific
|
||||
# dependency, such as, for example, the `nix` crate only being used via the
|
||||
# `target_family = "unix"` configuration, that only having windows targets in
|
||||
# this list would mean the nix crate, as well as any of its exclusive
|
||||
# dependencies not shared by any other crates, would be ignored, as the target
|
||||
# list here is effectively saying which targets you are building for.
|
||||
targets = [
|
||||
# The triple can be any string, but only the target triples built in to
|
||||
# rustc (as of 1.40) can be checked against actual config expressions
|
||||
#"x86_64-unknown-linux-musl",
|
||||
# You can also specify which target_features you promise are enabled for a
|
||||
# particular target. target_features are currently not validated against
|
||||
# the actual valid features supported by the target architecture.
|
||||
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
|
||||
]
|
||||
# When creating the dependency graph used as the source of truth when checks are
|
||||
# executed, this field can be used to prune crates from the graph, removing them
|
||||
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
|
||||
# is pruned from the graph, all of its dependencies will also be pruned unless
|
||||
# they are connected to another crate in the graph that hasn't been pruned,
|
||||
# so it should be used with care. The identifiers are [Package ID Specifications]
|
||||
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
|
||||
#exclude = []
|
||||
# If true, metadata will be collected with `--all-features`. Note that this can't
|
||||
# be toggled off if true, if you want to conditionally enable `--all-features` it
|
||||
# is recommended to pass `--all-features` on the cmd line instead
|
||||
all-features = false
|
||||
# If true, metadata will be collected with `--no-default-features`. The same
|
||||
# caveat with `all-features` applies
|
||||
no-default-features = false
|
||||
# If set, these feature will be enabled when collecting metadata. If `--features`
|
||||
# is specified on the cmd line they will take precedence over this option.
|
||||
#features = []
|
||||
|
||||
# The output table provides options for how/if diagnostics are outputted
|
||||
[output]
|
||||
# When outputting inclusion graphs in diagnostics that include features, this
|
||||
# option can be used to specify the depth at which feature edges will be added.
|
||||
# This option is included since the graphs can be quite large and the addition
|
||||
# of features from the crate(s) to all of the graph roots can be far too verbose.
|
||||
# This option can be overridden via `--feature-depth` on the cmd line
|
||||
feature-depth = 1
|
||||
|
||||
# This section is considered when running `cargo deny check advisories`
|
||||
# More documentation for the advisories section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
# The path where the advisory databases are cloned/fetched into
|
||||
#db-path = "$CARGO_HOME/advisory-dbs"
|
||||
# The url(s) of the advisory databases to use
|
||||
#db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
#"RUSTSEC-0000-0000",
|
||||
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
|
||||
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
|
||||
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
|
||||
# See Git Authentication for more information about setting up git authentication.
|
||||
#git-fetch-with-cli = true
|
||||
|
||||
# This section is considered when running `cargo deny check licenses`
|
||||
# More documentation for the licenses section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# List of explicitly allowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||
allow = [
|
||||
#"MIT",
|
||||
#"Apache-2.0",
|
||||
#"Apache-2.0 WITH LLVM-exception",
|
||||
]
|
||||
# The confidence threshold for detecting a license from license text.
|
||||
# The higher the value, the more closely the license text must be to the
|
||||
# canonical license text of a valid SPDX license file.
|
||||
# [possible values: any between 0.0 and 1.0].
|
||||
confidence-threshold = 0.8
|
||||
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
|
||||
# aren't accepted for every possible crate as with the normal allow list
|
||||
exceptions = [
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
#{ allow = ["Zlib"], crate = "adler32" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
# adding a clarification entry for it allows you to manually specify the
|
||||
# licensing information
|
||||
#[[licenses.clarify]]
|
||||
# The package spec the clarification applies to
|
||||
#crate = "ring"
|
||||
# The SPDX expression for the license requirements of the crate
|
||||
#expression = "MIT AND ISC AND OpenSSL"
|
||||
# One or more files in the crate's source used as the "source of truth" for
|
||||
# the license expression. If the contents match, the clarification will be used
|
||||
# when running the license check, otherwise the clarification will be ignored
|
||||
# and the crate will be checked normally, which may produce warnings or errors
|
||||
# depending on the rest of your configuration
|
||||
#license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
#{ path = "LICENSE", hash = 0xbd0eed23 }
|
||||
#]
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
# published to private registries.
|
||||
# To see how to mark a crate as unpublished (to the official registry),
|
||||
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
|
||||
ignore = false
|
||||
# One or more private registries that you might publish crates to, if a crate
|
||||
# is only published to private registries, and ignore is true, the crate will
|
||||
# not have its license(s) checked
|
||||
registries = [
|
||||
#"https://sekretz.com/registry
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check bans`.
|
||||
# More documentation about the 'bans' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
# with multiple versions
|
||||
# * lowest-version - The path to the lowest versioned duplicate is highlighted
|
||||
# * simplest-path - The path to the version with the fewest edges is highlighted
|
||||
# * all - Both lowest-version and simplest-path are used
|
||||
highlight = "all"
|
||||
# The default lint level for `default` features for crates that are members of
|
||||
# the workspace that is being checked. This can be overridden by allowing/denying
|
||||
# `default` on a crate-by-crate basis if desired.
|
||||
workspace-default-features = "allow"
|
||||
# The default lint level for `default` features for external crates that are not
|
||||
# members of the workspace. This can be overridden by allowing/denying `default`
|
||||
# on a crate-by-crate basis if desired.
|
||||
external-default-features = "allow"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
|
||||
]
|
||||
# If true, workspace members are automatically allowed even when using deny-by-default
|
||||
# This is useful for organizations that want to deny all external dependencies by default
|
||||
# but allow their own workspace crates without having to explicitly list them
|
||||
allow-workspace = false
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
|
||||
# Wrapper crates can optionally be specified to allow the crate when it
|
||||
# is a direct dependency of the otherwise banned crate
|
||||
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
|
||||
]
|
||||
|
||||
# List of features to allow/deny
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
#[[bans.features]]
|
||||
#crate = "reqwest"
|
||||
# Features to not allow
|
||||
#deny = ["json"]
|
||||
# Features to allow
|
||||
#allow = [
|
||||
# "rustls",
|
||||
# "__rustls",
|
||||
# "__tls",
|
||||
# "hyper-rustls",
|
||||
# "rustls",
|
||||
# "rustls-pemfile",
|
||||
# "rustls-tls-webpki-roots",
|
||||
# "tokio-rustls",
|
||||
# "webpki-roots",
|
||||
#]
|
||||
# If true, the allowed features must exactly match the enabled feature set. If
|
||||
# this is set there is no point setting `deny`
|
||||
#exact = true
|
||||
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
||||
# dependencies starting at the specified crate, up to a certain depth, which is
|
||||
# by default infinite.
|
||||
skip-tree = [
|
||||
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
|
||||
#{ crate = "ansi_term@0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check sources`.
|
||||
# More documentation about the 'sources' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
|
||||
[sources]
|
||||
# Lint level for what to happen when a crate from a crate registry that is not
|
||||
# in the allow list is encountered
|
||||
unknown-registry = "warn"
|
||||
# Lint level for what to happen when a crate from a git repository that is not
|
||||
# in the allow list is encountered
|
||||
unknown-git = "warn"
|
||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
||||
# if not specified. If it is specified but empty, no registries are allowed.
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# List of URLs for allowed Git repositories
|
||||
allow-git = ["https://github.com/keycard-tech/keycard-rs"]
|
||||
|
||||
[sources.allow-org]
|
||||
# github.com organizations to allow git sources for
|
||||
github = []
|
||||
# gitlab.com organizations to allow git sources for
|
||||
gitlab = []
|
||||
# bitbucket.org organizations to allow git sources for
|
||||
bitbucket = []
|
||||
@ -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`).
|
||||

|
||||
- **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
|
||||
}
|
||||
```
|
||||
```
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
52
lez/keycard_wallet/src/bin/force_unpower.rs
Normal file
52
lez/keycard_wallet/src/bin/force_unpower.rs
Normal 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.");
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
@ -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.")
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ use core::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder;
|
||||
use keycard_wallet::{KeycardWallet, python_path};
|
||||
use keycard_wallet::KeycardWallet;
|
||||
use lee::{AccountId, PrivateKey, PublicKey, Signature};
|
||||
use lee_core::{
|
||||
CommitmentSetDigest, DUMMY_COMMITMENT_HASH, Identifier, InputAccountIdentity, MembershipProof,
|
||||
@ -213,14 +213,7 @@ impl AccountManager {
|
||||
if pin.is_none() {
|
||||
pin = Some(
|
||||
crate::helperfunctions::read_pin()
|
||||
.map_err(|e| {
|
||||
ExecutionFailureKind::KeycardError(pyo3::PyErr::new::<
|
||||
pyo3::exceptions::PyRuntimeError,
|
||||
_,
|
||||
>(
|
||||
e.to_string()
|
||||
))
|
||||
})?
|
||||
.map_err(ExecutionFailureKind::SignError)?
|
||||
.as_str()
|
||||
.to_owned(),
|
||||
);
|
||||
@ -438,17 +431,11 @@ impl AccountManager {
|
||||
.collect();
|
||||
|
||||
if let Some(pin) = self.pin.clone() {
|
||||
pyo3::Python::attach(|py| -> pyo3::PyResult<()> {
|
||||
python_path::add_python_path(py)?;
|
||||
let wallet = KeycardWallet::new(py)?;
|
||||
wallet.connect(py, &pin)?;
|
||||
for path in keycard_paths {
|
||||
sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?);
|
||||
}
|
||||
let _res = wallet.close_session(py);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let mut wallet = KeycardWallet::new()?;
|
||||
wallet.connect(&pin)?;
|
||||
for path in keycard_paths {
|
||||
sigs.push(wallet.sign_message_for_path(path, &message_hash)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sigs)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
@ -79,8 +78,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")]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user