feat: add basic commands for communicating with keycard

This commit is contained in:
jonesmarvin8 2026-04-17 19:08:45 -04:00
parent 190c811f10
commit 04344f1fce
46 changed files with 1074 additions and 96 deletions

View File

@ -13,6 +13,7 @@ ignore = [
{ id = "RUSTSEC-2025-0055", reason = "`tracing-subscriber` v0.2.25 pulled in by ark-relations v0.4.0 - will be addressed before mainnet" },
{ id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained but continuing to use it." },
{ id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" },
{ id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" },
]
yanked = "deny"
unused-ignored-advisory = "deny"

150
Cargo.lock generated
View File

@ -1303,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799"
dependencies = [
"clap",
"heck",
"heck 0.5.0",
"indexmap 2.13.0",
"log",
"proc-macro2",
@ -1450,7 +1450,7 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -1715,15 +1715,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]]
name = "cpp_demangle"
version = "0.4.5"
@ -2192,7 +2183,7 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"proc-macro2-diagnostics",
]
@ -3016,6 +3007,12 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@ -3569,6 +3566,15 @@ dependencies = [
"web-time",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -3888,7 +3894,7 @@ version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro-crate",
"proc-macro2",
"quote",
@ -5123,6 +5129,15 @@ dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mempool"
version = "0.1.0"
@ -5249,11 +5264,11 @@ dependencies = [
[[package]]
name = "multihash"
version = "0.19.3"
version = "0.19.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d"
checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c"
dependencies = [
"core2",
"no_std_io2",
"unsigned-varint",
]
@ -5314,6 +5329,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "no_std_io2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6"
dependencies = [
"memchr",
]
[[package]]
name = "no_std_strings"
version = "0.1.3"
@ -5388,7 +5412,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6134,7 +6158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.11.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -6147,7 +6171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.11.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -6185,6 +6209,69 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "pyo3"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"parking_lot",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.117",
]
[[package]]
name = "quinn"
version = "0.11.9"
@ -7132,7 +7219,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs 0.26.11",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -7143,9 +7230,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"ring",
"rustls-pki-types",
@ -7872,7 +7959,7 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -7997,6 +8084,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.26.0"
@ -8845,6 +8938,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "unit-prefix"
version = "0.5.2"
@ -9012,6 +9111,7 @@ dependencies = [
"nssa",
"nssa_core",
"optfield",
"pyo3",
"rand 0.8.5",
"sequencer_service_rpc",
"serde",
@ -9551,7 +9651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"heck 0.5.0",
"wit-parser",
]
@ -9562,7 +9662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"heck 0.5.0",
"indexmap 2.13.0",
"prettyplease",
"syn 2.0.117",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -382,8 +382,8 @@ impl ProgramOutput {
}
/// Representation of a number as `lo + hi * 2^128`.
#[derive(PartialEq, Eq)]
struct WrappedBalanceSum {
#[derive(Debug, PartialEq, Eq)]
pub struct WrappedBalanceSum {
lo: u128,
hi: u128,
}
@ -393,7 +393,7 @@ impl WrappedBalanceSum {
///
/// Returns [`None`] if balance sum overflows `lo + hi * 2^128` representation, which is not
/// expected in practical scenarios.
fn from_balances(balances: impl Iterator<Item = u128>) -> Option<Self> {
pub fn from_balances(balances: impl Iterator<Item = u128>) -> Option<Self> {
let mut wrapped = Self { lo: 0, hi: 0 };
for balance in balances {
@ -408,6 +408,75 @@ impl WrappedBalanceSum {
}
}
impl std::fmt::Display for WrappedBalanceSum {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.hi == 0 {
write!(f, "{}", self.lo)
} else {
write!(f, "{} * 2^128 + {}", self.hi, self.lo)
}
}
}
impl From<u128> for WrappedBalanceSum {
fn from(value: u128) -> Self {
Self { lo: value, hi: 0 }
}
}
#[derive(thiserror::Error, Debug)]
pub enum ExecutionValidationError {
#[error("Pre-state account IDs are not unique")]
PreStateAccountIdsNotUnique,
#[error(
"Pre-state and post-state lengths do not match: pre-state length {pre_state_length}, post-state length {post_state_length}"
)]
MismatchedPreStatePostStateLength {
pre_state_length: usize,
post_state_length: usize,
},
#[error("Unallowed modification of nonce for account {account_id}")]
ModifiedNonce { account_id: AccountId },
#[error("Unallowed modification of program owner for account {account_id}")]
ModifiedProgramOwner { account_id: AccountId },
#[error(
"Trying to decrease balance of account {account_id} owned by {owner_program_id:?} in a program {executing_program_id:?} which is not the owner"
)]
UnauthorizedBalanceDecrease {
account_id: AccountId,
owner_program_id: ProgramId,
executing_program_id: ProgramId,
},
#[error(
"Unauthorized modification of data for account {account_id} which is not default and not owned by executing program {executing_program_id:?}"
)]
UnauthorizedDataModification {
account_id: AccountId,
executing_program_id: ProgramId,
},
#[error(
"Post-state for account {account_id} has default program owner but pre-state was not default"
)]
NonDefaultAccountWithDefaultOwner { account_id: AccountId },
#[error("Total balance across accounts overflowed 2^256 - 1")]
BalanceSumOverflow,
#[error(
"Total balance across accounts is not preserved: total balance in pre-states {total_balance_pre_states}, total balance in post-states {total_balance_post_states}"
)]
MismatchedTotalBalance {
total_balance_pre_states: WrappedBalanceSum,
total_balance_post_states: WrappedBalanceSum,
},
}
#[must_use]
pub fn compute_authorized_pdas(
caller_program_id: Option<ProgramId>,
@ -448,31 +517,39 @@ pub fn read_nssa_inputs<T: DeserializeOwned>() -> (ProgramInput<T>, InstructionD
/// - `pre_states`: The list of input accounts, each annotated with authorization metadata.
/// - `post_states`: The list of resulting accounts after executing the program logic.
/// - `executing_program_id`: The identifier of the program that was executed.
#[must_use]
pub fn validate_execution(
pre_states: &[AccountWithMetadata],
post_states: &[AccountPostState],
executing_program_id: ProgramId,
) -> bool {
) -> Result<(), ExecutionValidationError> {
// 1. Check account ids are all different
if !validate_uniqueness_of_account_ids(pre_states) {
return false;
return Err(ExecutionValidationError::PreStateAccountIdsNotUnique);
}
// 2. Lengths must match
if pre_states.len() != post_states.len() {
return false;
return Err(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length: pre_states.len(),
post_state_length: post_states.len(),
},
);
}
for (pre, post) in pre_states.iter().zip(post_states) {
// 3. Nonce must remain unchanged
if pre.account.nonce != post.account.nonce {
return false;
return Err(ExecutionValidationError::ModifiedNonce {
account_id: pre.account_id,
});
}
// 4. Program ownership changes are not allowed
if pre.account.program_owner != post.account.program_owner {
return false;
return Err(ExecutionValidationError::ModifiedProgramOwner {
account_id: pre.account_id,
});
}
let account_program_owner = pre.account.program_owner;
@ -481,7 +558,11 @@ pub fn validate_execution(
if post.account.balance < pre.account.balance
&& account_program_owner != executing_program_id
{
return false;
return Err(ExecutionValidationError::UnauthorizedBalanceDecrease {
account_id: pre.account_id,
owner_program_id: account_program_owner,
executing_program_id,
});
}
// 6. Data changes only allowed if owned by executing program or if account pre state has
@ -490,13 +571,20 @@ pub fn validate_execution(
&& pre.account != Account::default()
&& account_program_owner != executing_program_id
{
return false;
return Err(ExecutionValidationError::UnauthorizedDataModification {
account_id: pre.account_id,
executing_program_id,
});
}
// 7. If a post state has default program owner, the pre state must have been a default
// account
if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() {
return false;
return Err(
ExecutionValidationError::NonDefaultAccountWithDefaultOwner {
account_id: pre.account_id,
},
);
}
}
@ -505,20 +593,23 @@ pub fn validate_execution(
let Some(total_balance_pre_states) =
WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance))
else {
return false;
return Err(ExecutionValidationError::BalanceSumOverflow);
};
let Some(total_balance_post_states) =
WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance))
else {
return false;
return Err(ExecutionValidationError::BalanceSumOverflow);
};
if total_balance_pre_states != total_balance_post_states {
return false;
return Err(ExecutionValidationError::MismatchedTotalBalance {
total_balance_pre_states,
total_balance_post_states,
});
}
true
Ok(())
}
fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> bool {

View File

@ -1,12 +1,16 @@
use std::io;
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use thiserror::Error;
#[macro_export]
macro_rules! ensure {
($cond:expr, $err:expr) => {
if !$cond {
return Err($err);
return Err($err.into());
}
};
}
@ -17,7 +21,7 @@ pub enum NssaError {
InvalidInput(String),
#[error("Program violated execution rules")]
InvalidProgramBehavior,
InvalidProgramBehavior(#[from] InvalidProgramBehaviorError),
#[error("Serialization error: {0}")]
InstructionSerializationError(String),
@ -32,15 +36,15 @@ pub enum NssaError {
InvalidPublicKey(#[source] k256::schnorr::Error),
#[error("Invalid hex for public key")]
InvalidHexPublicKey(hex::FromHexError),
InvalidHexPublicKey(#[source] hex::FromHexError),
#[error("Risc0 error: {0}")]
#[error("Failed to write program input: {0}")]
ProgramWriteInputFailed(String),
#[error("Risc0 error: {0}")]
#[error("Failed to execute program: {0}")]
ProgramExecutionFailed(String),
#[error("Risc0 error: {0}")]
#[error("Failed to prove program: {0}")]
ProgramProveFailed(String),
#[error("Invalid transaction: {0}")]
@ -77,6 +81,61 @@ pub enum NssaError {
OutOfValidityWindow,
}
#[derive(Error, Debug)]
pub enum InvalidProgramBehaviorError {
#[error(
"Inconsistent pre-state for account {account_id} : expected {expected:?}, actual {actual:?}"
)]
InconsistentAccountPreState {
account_id: AccountId,
// Boxed to reduce the size of the error type
expected: Box<Account>,
actual: Box<Account>,
},
#[error(
"Inconsistent authorization for account {account_id} : expected {expected_authorization}, actual {actual_authorization}"
)]
InconsistentAccountAuthorization {
account_id: AccountId,
expected_authorization: bool,
actual_authorization: bool,
},
#[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedProgramId {
expected: ProgramId,
actual: ProgramId,
},
#[error("Caller program ID mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedCallerProgramId {
expected: Option<ProgramId>,
actual: Option<ProgramId>,
},
#[error(transparent)]
ExecutionValidationFailed(#[from] nssa_core::program::ExecutionValidationError),
#[error("Trying to claim account {account_id} which is not default")]
ClaimedNonDefaultAccount { account_id: AccountId },
#[error("Trying to claim account {account_id} which is not authorized")]
ClaimedUnauthorizedAccount { account_id: AccountId },
#[error("PDA claim mismatch: expected {expected:?}, actual {actual:?}")]
MismatchedPdaClaim {
expected: AccountId,
actual: AccountId,
},
#[error("Default account {account_id} was modified without being claimed")]
DefaultAccountModifiedWithoutClaim { account_id: AccountId },
#[error("Called program {program_id:?} which is not listed in dependencies")]
UndeclaredProgramDependency { program_id: ProgramId },
}
#[cfg(test)]
mod tests {

View File

@ -10,7 +10,7 @@ use nssa_core::{
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
error::NssaError,
error::{InvalidProgramBehaviorError, NssaError},
program::Program,
program_methods::{PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID},
state::MAX_NUMBER_CHAINED_CALLS,
@ -113,9 +113,11 @@ pub fn execute_and_prove(
env_builder.add_assumption(inner_receipt);
for new_call in program_output.chained_calls.into_iter().rev() {
let next_program = dependencies
.get(&new_call.program_id)
.ok_or(NssaError::InvalidProgramBehavior)?;
let next_program = dependencies.get(&new_call.program_id).ok_or(
InvalidProgramBehaviorError::UndeclaredProgramDependency {
program_id: new_call.program_id,
},
)?;
chained_calls.push_front((new_call, next_program, Some(chained_call.program_id)));
}

View File

@ -366,12 +366,15 @@ pub mod tests {
Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey},
program::{BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow},
program::{
BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId,
TimestampValidityWindow, WrappedBalanceSum,
},
};
use crate::{
PublicKey, PublicTransaction, V03State,
error::NssaError,
error::{InvalidProgramBehaviorError, NssaError},
execute_and_prove,
privacy_preserving_transaction::{
PrivacyPreservingTransaction,
@ -933,10 +936,11 @@ pub mod tests {
#[test]
fn program_should_fail_if_modifies_nonces() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let account_id = AccountId::new([1; 32]);
let initial_data = [(account_id, 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let account_ids = vec![AccountId::new([1; 32])];
let account_ids = vec![account_id];
let program_id = Program::nonce_changer_program().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
@ -945,7 +949,14 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedNonce { account_id: err_account_id }
)
)) if err_account_id == account_id
));
}
#[test]
@ -962,7 +973,17 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 1 && post_state_length == 2
));
}
#[test]
@ -979,7 +1000,17 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 2 && post_state_length == 1
));
}
#[test]
@ -1003,7 +1034,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
@ -1027,7 +1063,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
@ -1051,7 +1092,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
@ -1075,16 +1121,21 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_transfers_balance_from_non_owned_account() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let sender_account_id = AccountId::new([1; 32]);
let receiver_account_id = AccountId::new([2; 32]);
let initial_data = [(sender_account_id, 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let balance_to_move: u128 = 1;
let program_id = Program::simple_balance_transfer().id();
assert_ne!(
@ -1103,7 +1154,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id }
))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id
));
}
#[test]
@ -1128,7 +1184,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id }
))) if err_account_id == account_id && executing_program_id == program_id
));
}
#[test]
@ -1146,7 +1207,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into()
));
}
#[test]
@ -1175,7 +1241,12 @@ pub mod tests {
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into()
));
}
fn test_public_account_keys_1() -> TestPublicKeys {
@ -3088,7 +3159,12 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id }
)) if err_account_id == account_id
));
}
/// This test ensures that even if a malicious program tries to perform overflow of balances
@ -3134,7 +3210,22 @@ pub mod tests {
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]);
let tx = PublicTransaction::new(message, witness_set);
let res = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(res, Err(NssaError::InvalidProgramBehavior)));
let expected_total_balance_pre_states = WrappedBalanceSum::from_balances(
[sender_init_balance, recipient_init_balance].into_iter(),
)
.unwrap();
let expected_total_balance_post_states = WrappedBalanceSum::from_balances(
[sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(),
)
.unwrap();
assert!(matches!(
res,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
)
)) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states
));
let sender_post = state.get_account_by_id(sender_id);
let recipient_post = state.get_account_by_id(recipient_id);
@ -3379,7 +3470,14 @@ pub mod tests {
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Should fail - cannot modify data without claiming the account
assert!(matches!(result, Err(NssaError::InvalidProgramBehavior)));
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim {
account_id: err_account_id
}
)) if err_account_id == account_id
));
}
#[test]

View File

@ -14,7 +14,7 @@ use nssa_core::{
use crate::{
V03State, ensure,
error::NssaError,
error::{InvalidProgramBehaviorError, NssaError},
privacy_preserving_transaction::{
PrivacyPreservingTransaction, circuit::Proof, message::Message,
},
@ -145,39 +145,52 @@ impl ValidatedStateDiff {
.unwrap_or_else(|| state.get_account_by_id(account_id));
ensure!(
pre.account == expected_pre,
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::InconsistentAccountPreState {
account_id,
expected: Box::new(expected_pre),
actual: Box::new(pre.account.clone())
}
);
// Check that authorization flags are consistent with the provided ones or
// authorized by program through the PDA mechanism
let expected_is_authorized = is_authorized(&account_id);
ensure!(
pre.is_authorized == is_authorized(&account_id),
NssaError::InvalidProgramBehavior
pre.is_authorized == expected_is_authorized,
InvalidProgramBehaviorError::InconsistentAccountAuthorization {
account_id,
expected_authorization: expected_is_authorized,
actual_authorization: pre.is_authorized
}
);
}
// Verify that the program output's self_program_id matches the expected program ID.
ensure!(
program_output.self_program_id == chained_call.program_id,
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::MismatchedProgramId {
expected: chained_call.program_id,
actual: program_output.self_program_id
}
);
// Verify that the program output's caller_program_id matches the actual caller.
ensure!(
program_output.caller_program_id == caller_program_id,
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::MismatchedCallerProgramId {
expected: caller_program_id,
actual: program_output.caller_program_id,
}
);
// Verify execution corresponds to a well-behaved program.
// See the # Programs section for the definition of the `validate_execution` method.
ensure!(
validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
),
NssaError::InvalidProgramBehavior
);
validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
)
.map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?;
// Verify validity window
ensure!(
@ -192,27 +205,33 @@ impl ValidatedStateDiff {
let Some(claim) = post.required_claim() else {
continue;
};
let account_id = program_output.pre_states[i].account_id;
// The invoked program can only claim accounts with default program id.
ensure!(
post.account().program_owner == DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id }
);
let account_id = program_output.pre_states[i].account_id;
match claim {
Claim::Authorized => {
// The program can only claim accounts that were authorized by the signer.
ensure!(
is_authorized(&account_id),
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id }
);
}
Claim::Pda(seed) => {
// The program can only claim accounts that correspond to the PDAs it is
// authorized to claim.
let pda = AccountId::from((&chained_call.program_id, &seed));
ensure!(account_id == pda, NssaError::InvalidProgramBehavior);
ensure!(
account_id == pda,
InvalidProgramBehaviorError::MismatchedPdaClaim {
expected: pda,
actual: account_id
}
);
}
}
@ -238,7 +257,7 @@ impl ValidatedStateDiff {
}
// Check that all modified uninitialized accounts where claimed
for post in state_diff.iter().filter_map(|(account_id, post)| {
for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| {
let pre = state.get_account_by_id(*account_id);
if pre.program_owner != DEFAULT_PROGRAM_ID {
return None;
@ -246,11 +265,11 @@ impl ValidatedStateDiff {
if pre == *post {
return None;
}
Some(post)
Some((*account_id, post))
}) {
ensure!(
post.program_owner != DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id }
);
}

View File

@ -125,12 +125,17 @@ impl ExecutionState {
// Check that the program is well behaved.
// See the # Programs section for the definition of the `validate_execution` method.
let execution_valid = validate_execution(
let validated_execution = validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
);
assert!(execution_valid, "Bad behaved program");
if let Err(err) = validated_execution {
panic!(
"Invalid program behavior in program {:?}: {err}",
chained_call.program_id
);
}
for next_call in program_output.chained_calls.iter().rev() {
chained_calls.push_front((next_call.clone(), Some(chained_call.program_id)));

View File

@ -18,6 +18,7 @@ amm_core.workspace = true
testnet_initial_state.workspace = true
ata_core.workspace = true
bip39.workspace = true
pyo3 = { version = "0.21", features = ["auto-initialize"] }
anyhow.workspace = true
thiserror.workspace = true

199
wallet/src/cli/keycard.rs Normal file
View File

@ -0,0 +1,199 @@
use anyhow::Result;
use clap::Subcommand;
use pyo3::prelude::*;
use crate::{
WalletCore,
cli::{SubcommandReturnValue, WalletSubcommand, keycard_wallet::KeycardWallet, python_path},
};
/// Represents generic chain CLI subcommand.
#[derive(Subcommand, Debug, Clone)]
pub enum KeycardSubcommand {
Available,
Connect {
#[arg(
short,
long,
)]
pin: Option<String>,
},
Load {
#[arg(
short,
long,
)]
mnemonic: Option<String>,
},
Remove,
}
/// Represents generic register CLI subcommand.
/*
#[derive(Subcommand, Debug, Clone)]
pub enum NewSubcommand {
/// Register new public account.
Public {
#[arg(long)]
/// Chain index of a parent node.
cci: Option<ChainIndex>,
#[arg(short, long)]
/// Label to assign to the new account.
label: Option<String>,
},
/// Register new private account.
Private {
#[arg(long)]
/// Chain index of a parent node.
cci: Option<ChainIndex>,
#[arg(short, long)]
/// Label to assign to the new account.
label: Option<String>,
},
}
*/
/*
impl WalletSubcommand for NewSubcommand {
async fn handle_subcommand(
self,
wallet_core: &mut WalletCore,
) -> Result<SubcommandReturnValue> {
match self {
Self::Public { cci, label } => {
if let Some(label) = &label
&& wallet_core
.storage
.labels
.values()
.any(|l| l.to_string() == *label)
{
anyhow::bail!("Label '{label}' is already in use by another account");
}
let (account_id, chain_index) = wallet_core.create_new_account_public(cci);
let private_key = wallet_core
.storage
.user_data
.get_pub_account_signing_key(account_id)
.unwrap();
let public_key = PublicKey::new_from_private_key(private_key);
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
}
println!(
"Generated new account with account_id Public/{account_id} at path {chain_index}"
);
println!("With pk {}", hex::encode(public_key.value()));
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
Self::Private { cci, label } => {
if let Some(label) = &label
&& wallet_core
.storage
.labels
.values()
.any(|l| l.to_string() == *label)
{
anyhow::bail!("Label '{label}' is already in use by another account");
}
let (account_id, chain_index) = wallet_core.create_new_account_private(cci);
if let Some(label) = label {
wallet_core
.storage
.labels
.insert(account_id.to_string(), Label::new(label));
}
let (key, _) = wallet_core
.storage
.user_data
.get_private_account(account_id)
.unwrap();
println!(
"Generated new account with account_id Private/{account_id} at path {chain_index}",
);
println!("With npk {}", hex::encode(key.nullifier_public_key.0));
println!(
"With vpk {}",
hex::encode(key.viewing_public_key.to_bytes())
);
wallet_core.store_persistent_data().await?;
Ok(SubcommandReturnValue::RegisterAccount { account_id })
}
}
}
}
*/
impl WalletSubcommand for KeycardSubcommand {
#[expect(clippy::cognitive_complexity, reason = "TODO: fix later")]
async fn handle_subcommand(
self,
_wallet_core: &mut WalletCore,
) -> Result<SubcommandReturnValue> {
match self {
Self::Available => {
Python::with_gil(|py| {
python_path::add_python_path(py).expect("keycard_wallet.py not found");
let wallet = KeycardWallet::new(py).expect("Expect keycard wallet");
let _available = wallet.is_unpaired_keycard_available(py);
});
Ok(SubcommandReturnValue::Empty)
},
Self::Connect { pin } => {
// TODO This should be persistent.
Python::with_gil(|py| {
python_path::add_python_path(py).expect("keycard_wallet.py not found");
let wallet = KeycardWallet::new(py).expect("Expect keycard wallet");
let _ = wallet.setup_communication(py, pin.expect("TODO"));
});
Ok(SubcommandReturnValue::Empty)
},
Self::Load { mnemonic } => {
// TODO This should be persistent.
Python::with_gil(|py| {
python_path::add_python_path(py).expect("keycard_wallet.py not found");
let wallet = KeycardWallet::new(py).expect("Expect keycard wallet");
let _ = wallet.load_account_keys(py, &mnemonic.expect("TODO"));
});
Ok(SubcommandReturnValue::Empty)
},
Self::Remove => {
// TODO This should be persistent.
Python::with_gil(|py| {
python_path::add_python_path(py).expect("keycard_wallet.py not found");
let wallet = KeycardWallet::new(py).expect("Expect keycard wallet");
let _ = wallet.remove_account_keys(py);
});
Ok(SubcommandReturnValue::Empty)
},
}
}
}

View File

@ -0,0 +1,123 @@
use pyo3::prelude::*;
use pyo3::types::PyAny;
/// Rust wrapper around the Python KeycardWallet class.
/// Holds a persistent Python object in memory.
pub struct KeycardWallet {
instance: Py<PyAny>,
}
impl KeycardWallet {
/// Create a new Python KeycardWallet instance
pub fn new(py: Python) -> PyResult<Self> {
let module = py.import_bound("keycard_wallet")?;
let class = module.getattr("KeycardWallet")?;
let instance = class.call0()?;
Ok(Self {
instance: instance.into_py(py),
})
}
/// Calls Python: is_unpaired_keycard_available()
pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult<bool> {
self.instance
.bind(py) // replaces as_ref(py)
.call_method0("is_unpaired_keycard_available")?
.extract()
}
pub fn setup_communication(&self, py: Python, pin: String) -> PyResult<bool> {
let py_pin = pyo3::types::PyString::new_bound(py, &pin);
self.instance
.bind(py)
.call_method1("setup_communication", (py_pin,))?
.extract()
}
pub fn disconnect(&self, py: Python) -> PyResult<bool> {
self.instance
.bind(py)
.call_method0("disconnect")?
.extract()
}
pub fn get_public_signing_key(&self, py: Python) -> PyResult<[u8; 32]> {
self.instance
.bind(py)
.call_method0("get_public_signing_key")?
.extract()
}
pub fn derive_path(&self, py: Python, path: Vec<u32>) -> PyResult<()> {
let path = Self::convert_path_to_string(path);
self.instance
.bind(py)
.call_method1("change_path", (path,))?;
Ok(())
}
fn convert_path_to_string(path: Vec<u32>) -> String {
format!(
"m/{}",
path.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("'/")
)
}
pub fn sign_message_current_key(&self, py: Python, message: &[u8; 32]) -> PyResult<[u8; 64]> {
let py_message = pyo3::types::PyBytes::new_bound(py, message);
let py_signature: Vec<u8> = self.instance
.bind(py)
.call_method1("sign_message_current_key", (py_message,))?
.getattr("signature")? // or "bytes", "data", "value", etc.
.extract()?;
let signature: [u8; 64] = py_signature
.try_into()
.map_err(|_| PyErr::new::<pyo3::exceptions::PyValueError, _>(
"Expected signature of exactly 64 bytes"
))?;
Ok(signature)
}
pub fn sign_message_with_path(&self, py: Python, path: Vec<u32>, message: &[u8; 32]) -> PyResult<[u8; 64]> {
let py_message = pyo3::types::PyBytes::new_bound(py, message);
let path = Self::convert_path_to_string(path);
let py_signature: Vec<u8> = self.instance
.bind(py)
.call_method1("sign_message_with_path", (path, py_message))?
.getattr("signature")? // or "bytes", "data", "value", etc.
.extract()?;
let signature: [u8; 64] = py_signature
.try_into()
.map_err(|_| PyErr::new::<pyo3::exceptions::PyValueError, _>(
"Expected signature of exactly 64 bytes"
))?;
Ok(signature)
}
pub fn remove_account_keys(&self, py: Python) -> PyResult<()> {
self.instance
.bind(py)
.call_method0("remove_account_keys")?;
Ok(())
}
pub fn load_account_keys(&self, py: Python, mnemonic: &str) -> PyResult<()> {
self.instance
.bind(py)
.call_method1("load_account_keys", (mnemonic,))?;
Ok(())
}
}

View File

@ -14,6 +14,7 @@ use crate::{
account::AccountSubcommand,
chain::ChainSubcommand,
config::ConfigSubcommand,
keycard::KeycardSubcommand,
programs::{
amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand,
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
@ -25,7 +26,10 @@ use crate::{
pub mod account;
pub mod chain;
pub mod config;
pub mod keycard;
pub mod programs;
pub mod python_path;
pub mod keycard_wallet;
pub(crate) trait WalletSubcommand {
async fn handle_subcommand(self, wallet_core: &mut WalletCore)
@ -73,6 +77,8 @@ pub enum Command {
},
/// Deploy a program.
DeployProgram { binary_filepath: PathBuf },
#[command(subcommand)]
Keycard(KeycardSubcommand),
}
/// To execute commands, env var `NSSA_WALLET_HOME_DIR` must be set into directory with config.
@ -121,6 +127,9 @@ pub async fn execute_subcommand(
Command::Pinata(pinata_subcommand) => {
pinata_subcommand.handle_subcommand(wallet_core).await?
}
Command::Keycard(keycard_subcommand) => {
keycard_subcommand.handle_subcommand(wallet_core).await?
}
Command::CheckHealth => {
let remote_program_ids = wallet_core
.sequencer_client

View File

@ -0,0 +1,43 @@
import keycard_wallet as keycard_wallet
import time # For testing
my_wallet = keycard_wallet.KeycardWallet()
print("Setup communication with card...", my_wallet.setup_communication())
#my_wallet.load_account_keys()
#pub_key = my_wallet.get_public_signing_key()
# TODO: now I want to specify LEE
#print(f"Public key: {list(pub_key)}")
#my_wallet.debug_key_export()
#priv_key = my_wallet.get_private_signing_key()
#print(f"Private key: {list(priv_key)}")
#my_wallet.remove_account_keys()
print("Disconnection", my_wallet.disconnect()) # To not do a stupid
"""
my_wallet.setup_communication()
# TODO: issues here
#priv_key = my_wallet.get_private_key()
#print(f"Private key: {list(priv_key)}")
#signature = my_wallet.sign_message_current_key()
#print(f"Signature: {signature.signature.hex()}")
#signature = my_wallet.sign_message_with_path("m/44'/60'/0'/0/1")
#print(f"Signature: {signature.signature.hex()}")
my_wallet.disconnect() # To not do a stupid
"""

View File

@ -0,0 +1,189 @@
from smartcard.System import readers
from keycard.exceptions import APDUError, TransportError
from ecdsa import VerifyingKey, SECP256k1
from keycard.keycard import KeyCard
from mnemonic import Mnemonic
from keycard import constants
import keycard
PIN = '123456'
DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing"
class KeycardWallet:
def __init__(self):
self.card = KeyCard()
self.pairing_index = None
self.pairing_key = None
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
# Wrapped
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
# Wrapped
def setup_communication(self, pin = PIN, password = DEFAULT_PAIRING_PASSWORD) -> bool:
try:
self.card.select()
if not self.card.is_initialized:
# TODO: need to be able to initialize a card.
return False
if self.pairing_index is None:
pairing_index, pairing_key = self.card.pair(password) #Testing
self.pairing_index = pairing_index
self.pairing_key = pairing_key
self.card.open_secure_channel(pairing_index, pairing_key)
self.card.verify_pin(PIN)
return True
except Exception as e:
print(f"Error: {e}")
return False
"""
# Needs to be more robust to handle card removal and reinsertion
def is_selected_card_available(self) -> bool:
if self.transport.connection is None:
return False
try:
#TODO: fix this up Try a lightweight operation
# Card is present
self.card.send_apdu(cla=0x00, ins=0xA4, p1=0x04, p2=0x00, data=b'')
# return True
except Exception:
return False
# TODO: attempt to prevent a new card from being inserted
return self.card.is_selected
"""
# Wrapped
def disconnect(self) -> bool:
try:
self.card.unpair(self.pairing_index)
self.pairing_index = None
self.pairing_key = None
return True
except Exception as e:
print(f"Error during disconnect: {e}")
return False
# TODO: add path?
# Wrapped
def get_public_signing_key(self):
uncompressed_pub_key = self.card.export_current_key(public_only=True).public_key
# Convert to VerifyingKey object
vk = VerifyingKey.from_string(uncompressed_pub_key, curve=SECP256k1)
return vk.to_string("compressed")[1:]
"""
# TODO: don't think this possible; blocked by firmware
def get_private_signing_key(self):
try:
exported = self.card.export_current_key(public_only=False)
print(f"Exported key: {exported}")
print(f"Public key: {exported.public_key.hex() if exported.public_key else 'None'}")
print(f"Private key: {exported.private_key.hex() if exported.private_key else 'None'}")
print(f"Chain code: {exported.chain_code.hex() if exported.chain_code else 'None'}")
if exported.private_key is None:
raise ValueError("No private key returned - key may not be loaded on card")
return exported.private_key
except Exception as e:
print(f"Error exporting key: {e}")
raise
"""
# TODO: delete this function
def debug_key_export(self):
"""Debug why key export fails with SW=6985"""
# 1. Check if a key exists
try:
status = self.card.status
print(f"Status: {status}")
except Exception as e:
print(f"Cannot get status: {e}")
# 2. Try public key export first
try:
exported = self.card.export_current_key(public_only=True)
print(f"Public key export: {exported.public_key.hex() if exported.public_key else 'None'}")
except Exception as e:
print(f"Public key export failed: {e}")
# 3. Check if key needs to be generated
try:
key_uid = self. card.generate_key()
print(f"Generated key UID: {key_uid.hex()}")
except Exception as e:
print(f"Key generation failed: {e}")
# 4. Try private export again
try:
exported = self.card.export_current_key(public_only=False)
if exported.private_key:
print(f"Private key: {exported.private_key.hex()}")
else:
print("Private key is None - key may not allow export")
except Exception as e:
print(f"Private key export failed: {e}")
#TODO: check well formed?
# Wrapped
def change_path(self, path):
self.card.derive_key(path)
# Message must be 32 bytes
# TODO: rename to current_path
# Wrapped
def sign_message_current_key(self, message = b"TestMessageMustBe32Bytes!\x00\x00\x00\x00\x00\x00\x00"):
# Message must be sent bytes
return self.card.sign(message, constants.SigningAlgorithm.SCHNORR_BIP340)
# Does not update the path
# Wrapped
def sign_message_with_path(self, path, message = b"TestMessageMustBe32Bytes!\x00\x00\x00\x00\x00\x00\x00"):
# must be sent bytes
return self.card.sign_with_path(message, path, False, constants.SigningAlgorithm.SCHNORR_BIP340)
# Wrapped
def remove_account_keys(self):
self.card.remove_key()
# TODO: update to accept a different language?
def load_account_keys(self, mnemonic) :
mnemo = Mnemonic("english")
seed = mnemo.to_seed(mnemonic, passphrase="")
# Load the seed onto the card
result = self.card.load_key(
key_type= constants.LoadKeyType.BIP39_SEED,
lee_seed=seed
)

View File

@ -0,0 +1,39 @@
use std::env;
use std::path::PathBuf;
use pyo3::prelude::*;
use pyo3::types::PyList;
/// Adds the project's `python/` directory and venv site-packages to Python's sys.path
pub fn add_python_path(py: Python) -> PyResult<()> {
let current_dir = env::current_dir()
.expect("Failed to get current working directory");
let paths_to_add: Vec<PathBuf> = vec![
current_dir.join("python"),
current_dir.join("python").join("keycard-py-lee-schnorr"), // ← add this
//current_dir.join("python").join("keycard-py-lee-schnorr").join(".venv").join("Lib").join("site-packages"),
];
// Sanity check — warns early if a path doesn't exist
for path in &paths_to_add {
if !path.exists() {
eprintln!("Warning: Python path does not exist: {:?}", path);
}
}
let sys = py.import_bound("sys")?;
let sys_path: &PyList = sys.getattr("path")?.extract()?;
for path in &paths_to_add {
let path_str = path
.to_str()
.expect("Invalid path");
// Avoid duplicating the path
if !sys_path.iter().any(|p| p.extract::<&str>().unwrap_or("") == path_str) {
sys_path.insert(0, path_str)?;
}
}
Ok(())
}