mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-05-13 19:49:29 +00:00
Revert "fixes"
This reverts commit 41f34f4ff4145b7abb60fd9bec168ae4b60f23b4.
This commit is contained in:
parent
41f34f4ff4
commit
5bcf1a253b
158
Cargo.lock
generated
158
Cargo.lock
generated
@ -1303,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"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 0.5.0",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@ -2183,7 +2183,7 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
]
|
||||
@ -3007,12 +3007,6 @@ 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"
|
||||
@ -3566,15 +3560,6 @@ 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"
|
||||
@ -3894,7 +3879,7 @@ version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -4011,43 +3996,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keycard_wallet"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"amm_core",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"ata_core",
|
||||
"base58",
|
||||
"bip39",
|
||||
"clap",
|
||||
"common",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hex",
|
||||
"humantime",
|
||||
"humantime-serde",
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"key_protocol",
|
||||
"log",
|
||||
"nssa",
|
||||
"nssa_core",
|
||||
"optfield",
|
||||
"pyo3",
|
||||
"rand 0.8.5",
|
||||
"sequencer_service_rpc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"testnet_initial_state",
|
||||
"thiserror 2.0.18",
|
||||
"token_core",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex"
|
||||
version = "3.6.0"
|
||||
@ -5166,15 +5114,6 @@ 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"
|
||||
@ -5449,7 +5388,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6195,7 +6134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.11.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@ -6208,7 +6147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.11.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@ -6246,69 +6185,6 @@ 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"
|
||||
@ -7996,7 +7872,7 @@ version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@ -8121,12 +7997,6 @@ 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"
|
||||
@ -8975,12 +8845,6 @@ 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"
|
||||
@ -9144,12 +9008,10 @@ dependencies = [
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"key_protocol",
|
||||
"keycard_wallet",
|
||||
"log",
|
||||
"nssa",
|
||||
"nssa_core",
|
||||
"optfield",
|
||||
"pyo3",
|
||||
"rand 0.8.5",
|
||||
"sequencer_service_rpc",
|
||||
"serde",
|
||||
@ -9689,7 +9551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
@ -9700,7 +9562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"indexmap 2.13.0",
|
||||
"prettyplease",
|
||||
"syn 2.0.117",
|
||||
|
||||
@ -38,7 +38,6 @@ members = [
|
||||
"examples/program_deployment/methods/guest",
|
||||
"bedrock_client",
|
||||
"testnet_initial_state",
|
||||
"keycard_wallet",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -68,7 +67,6 @@ ata_program = { path = "programs/associated_token_account" }
|
||||
test_program_methods = { path = "test_program_methods" }
|
||||
bedrock_client = { path = "bedrock_client" }
|
||||
testnet_initial_state = { path = "testnet_initial_state" }
|
||||
keycard_wallet = { path = "keycard_wallet" }
|
||||
|
||||
tokio = { version = "1.50", features = [
|
||||
"net",
|
||||
|
||||
@ -52,8 +52,6 @@ async fn main() {
|
||||
accounts,
|
||||
Program::serialize_instruction(greeting).unwrap(),
|
||||
&program.into(),
|
||||
&None,
|
||||
&None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -60,8 +60,6 @@ async fn main() {
|
||||
accounts,
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
&program_with_dependencies,
|
||||
&None,
|
||||
&None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -106,8 +106,6 @@ async fn main() {
|
||||
accounts,
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
&program.into(),
|
||||
&None,
|
||||
&None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -149,8 +147,6 @@ async fn main() {
|
||||
accounts,
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
&program.into(),
|
||||
&None,
|
||||
&None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -134,8 +134,6 @@ async fn amm_public() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 7,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -165,8 +163,6 @@ async fn amm_public() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 7,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -555,8 +551,6 @@ async fn amm_new_pool_using_labels() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 5,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
@ -581,8 +575,6 @@ async fn amm_new_pool_using_labels() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 5,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
|
||||
|
||||
@ -269,8 +269,6 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@ -503,8 +501,6 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@ -619,8 +615,6 @@ async fn burn_via_ata_private_owner() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: fund_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -31,8 +31,6 @@ async fn private_transfer_to_owned_account() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -74,8 +72,6 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
|
||||
to_npk: Some(to_npk_string),
|
||||
to_vpk: Some(hex::encode(to_vpk.0)),
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -126,8 +122,6 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -193,8 +187,6 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
|
||||
to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)),
|
||||
to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)),
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -245,8 +237,6 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -291,8 +281,6 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
|
||||
to_npk: Some(to_npk_string),
|
||||
to_vpk: Some(hex::encode(to_vpk.0)),
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -365,8 +353,6 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
|
||||
to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)),
|
||||
to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)),
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -413,8 +399,6 @@ async fn initialize_private_account() -> Result<()> {
|
||||
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
|
||||
account_id: Some(format_private_account_id(account_id)),
|
||||
account_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -472,8 +456,6 @@ async fn private_transfer_using_from_label() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -517,8 +499,6 @@ async fn initialize_private_account_using_label() -> Result<()> {
|
||||
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
|
||||
account_id: None,
|
||||
account_label: Some(label),
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
|
||||
@ -24,8 +24,6 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -84,8 +82,6 @@ pub async fn successful_transfer_to_new_account() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -124,8 +120,6 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 1_000_000,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let failed_send = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await;
|
||||
@ -166,8 +160,6 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -202,8 +194,6 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -248,8 +238,6 @@ async fn initialize_public_account() -> Result<()> {
|
||||
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
|
||||
account_id: Some(format_public_account_id(account_id)),
|
||||
account_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -291,8 +279,6 @@ async fn successful_transfer_using_from_label() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -340,8 +326,6 @@ async fn successful_transfer_using_to_label() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -113,8 +113,6 @@ async fn indexer_state_consistency() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -151,8 +149,6 @@ async fn indexer_state_consistency() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -239,8 +235,6 @@ async fn indexer_state_consistency_with_labels() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -76,8 +76,6 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
|
||||
to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)),
|
||||
to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)),
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
@ -154,8 +152,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 100,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -168,8 +164,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 101,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -210,8 +204,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 102,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -224,8 +216,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 103,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -291,8 +281,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 10,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -304,8 +292,6 @@ async fn restore_keys_from_seed() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: 11,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
|
||||
@ -54,8 +54,6 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()>
|
||||
Command::Pinata(PinataProgramAgnosticSubcommand::Claim {
|
||||
to: Some(winner_account_id_formatted),
|
||||
to_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@ -111,8 +109,6 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<()
|
||||
Command::Pinata(PinataProgramAgnosticSubcommand::Claim {
|
||||
to: Some(winner_account_id_formatted),
|
||||
to_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@ -145,8 +141,6 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> {
|
||||
let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim {
|
||||
to: Some(format_public_account_id(ctx.existing_public_accounts()[0])),
|
||||
to_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let pinata_balance_pre = ctx
|
||||
@ -188,8 +182,6 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> {
|
||||
ctx.existing_private_accounts()[0],
|
||||
)),
|
||||
to_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let pinata_balance_pre = ctx
|
||||
@ -255,8 +247,6 @@ async fn claim_pinata_to_new_private_account() -> Result<()> {
|
||||
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
|
||||
account_id: Some(winner_account_id_formatted.clone()),
|
||||
account_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
|
||||
|
||||
@ -273,8 +263,6 @@ async fn claim_pinata_to_new_private_account() -> Result<()> {
|
||||
let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim {
|
||||
to: Some(winner_account_id_formatted),
|
||||
to_label: None,
|
||||
pin: None,
|
||||
key_path: None,
|
||||
});
|
||||
|
||||
let pinata_balance_pre = ctx
|
||||
|
||||
@ -135,8 +135,6 @@ async fn create_and_transfer_public_token() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -182,8 +180,6 @@ async fn create_and_transfer_public_token() -> Result<()> {
|
||||
holder: Some(format_public_account_id(recipient_account_id)),
|
||||
holder_label: None,
|
||||
amount: burn_amount,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -232,8 +228,6 @@ async fn create_and_transfer_public_token() -> Result<()> {
|
||||
holder_npk: None,
|
||||
holder_vpk: None,
|
||||
amount: mint_amount,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -379,8 +373,6 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -408,8 +400,6 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
|
||||
holder: Some(format_private_account_id(recipient_account_id)),
|
||||
holder_label: None,
|
||||
amount: burn_amount,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -577,8 +567,6 @@ async fn create_token_with_private_definition() -> Result<()> {
|
||||
holder_npk: None,
|
||||
holder_vpk: None,
|
||||
amount: mint_amount_public,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -627,8 +615,6 @@ async fn create_token_with_private_definition() -> Result<()> {
|
||||
holder_npk: None,
|
||||
holder_vpk: None,
|
||||
amount: mint_amount_private,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -771,8 +757,6 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -904,8 +888,6 @@ async fn shielded_token_transfer() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -1032,8 +1014,6 @@ async fn deshielded_token_transfer() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -1169,8 +1149,6 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
|
||||
holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)),
|
||||
holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.0)),
|
||||
amount: mint_amount,
|
||||
holder_pin: None,
|
||||
holder_key_path: None,
|
||||
};
|
||||
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
@ -1374,8 +1352,6 @@ async fn transfer_token_using_from_label() -> Result<()> {
|
||||
to_npk: None,
|
||||
to_vpk: None,
|
||||
amount: transfer_amount,
|
||||
from_pin: None,
|
||||
from_key_path: None,
|
||||
};
|
||||
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
|
||||
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
# Run wallet_with_keycard.sh first
|
||||
|
||||
source venv/bin/activate # Load the appropriate virtual environment
|
||||
|
||||
# Tests wallet keycard available
|
||||
# - Checks whether smart reader and keycard are both available.
|
||||
echo "Test: wallet keycard available"
|
||||
wallet keycard available
|
||||
|
||||
echo 'Test: wallet keycard load --pin 111111 --mnemonic "final empty hair duty next drastic normal miss wreck wreck strategy omit"'
|
||||
# Install a new mnemonic phrase to keycard
|
||||
wallet keycard load --pin 111111 --mnemonic "fashion degree mountain wool question damp current pond grow dolphin chronic then"
|
||||
# Commented out to avoid resetting card constantly
|
||||
|
||||
echo "Test: wallet auth-transfer --pin 111111 --key-path \"m/44'/60/0\'/0/0\""
|
||||
wallet auth-transfer init --pin 111111 --key-path "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --pin 111111 --key-path \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --pin 111111 --key-path "m/44'/60'/0'/0/0"
|
||||
|
||||
|
||||
echo "Test: wallet pinata claim --pin 111111 --key-path \"m/44'/60'/0'/0/0\""
|
||||
wallet pinata claim --pin 111111 --key-path "m/44'/60'/0'/0/0"
|
||||
|
||||
|
||||
echo "Test: wallet account get --pin 111111 --key-path \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --pin 111111 --key-path "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Initialize new account (auth-transfer init) and send"
|
||||
wallet auth-transfer init --pin 111111 --key-path "m/44'/60'/0'/0/1"
|
||||
wallet auth-transfer send --amount 40 --pin 111111 --from-key-path "m/44'/60'/0'/0/0" --to-key-path "m/44'/60'/0'/0/1"
|
||||
|
||||
echo "Test: wallet account get --pin 111111 --key-path \"m/44'/60'/0'/0/0\""
|
||||
wallet account get --pin 111111 --key-path "m/44'/60'/0'/0/0"
|
||||
|
||||
echo "Test: wallet account get --pin 111111 --key-path \"m/44'/60'/0'/0/1\""
|
||||
wallet account get --pin 111111 --key-path "m/44'/60'/0'/0/1"
|
||||
|
||||
|
||||
# initialize account keys (outside of keycard)
|
||||
# Eventually use for tokens and shielded
|
||||
wallet account new private
|
||||
wallet account new public
|
||||
wallet account new public
|
||||
wallet account new public
|
||||
wallet account new public
|
||||
|
||||
# Initialize Token A
|
||||
wallet token new --definition-account-id "Public/4rXJzAEVn9Av1bK1RR4orTJP8dJDzRuoTBRsXVn1pwcK" --supply-account-id "Public/3PfkXqePVRnet5H1PbnfgeWykBrqX3KPPeMBESJt4QEd" --total-supply 1000 --name LEZT
|
||||
|
||||
# Initialize Token B
|
||||
wallet token new --definition-account-id "Public/DjJx9ccoRyv1xxmHmpFy8mATeKq3Es1DnobjT4EZ4ab2" --supply-account-id "Public/EKgmwG9n7jMYkKaTYdZa7ELyYZq5f43oBKuCiu3t3Tm8" --total-supply 1000 --name LEET
|
||||
|
||||
# Send Token A to a new wallet account
|
||||
|
||||
# Send from non keycard account to an account owned by keycard.
|
||||
wallet token send --from "Public/3PfkXqePVRnet5H1PbnfgeWykBrqX3KPPeMBESJt4QEd" --to "Public/6iYPF671bMDEkADFvHgcJDrYHJMqZv6cYbxVMsUU7LFE" --amount 400
|
||||
# This fails due to lack of initialization for Token Account
|
||||
|
||||
|
||||
wallet auth-transfer send --amount 40 --pin 111111 --from-key-path "m/44'/60'/0'/0/0" --to-npk "55204e2934045b044f06d8222b454d46b54788f33c7dec4f6733d441703bb0e6" --to-vpk "02a8626b0c0ad9383c5678dad48c3969b4174fb377cdb03a6259648032c774cec8"
|
||||
@ -1,8 +1,7 @@
|
||||
use crate::{PublicTransaction, error::NssaError, public_transaction::Message};
|
||||
|
||||
impl Message {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
pub(crate) fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(&self).expect("Autoderived borsh serialization failure")
|
||||
}
|
||||
}
|
||||
@ -14,6 +13,6 @@ impl PublicTransaction {
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, NssaError> {
|
||||
Ok(borsh::from_slice(bytes).expect("Autoderived borsh serialization failure"))
|
||||
Ok(borsh::from_slice(bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ pub struct WitnessSet {
|
||||
|
||||
impl WitnessSet {
|
||||
#[must_use]
|
||||
// TODO: this generates signatures.
|
||||
// However. we may need to get signatures from Keycard.
|
||||
pub fn for_message(message: &Message, proof: Proof, private_keys: &[&PrivateKey]) -> Self {
|
||||
let message_hash = message.hash_message();
|
||||
let signatures_and_public_keys = private_keys
|
||||
@ -30,26 +32,6 @@ impl WitnessSet {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_list(
|
||||
proof: Proof,
|
||||
signatures: &[Signature],
|
||||
public_keys: &[PublicKey],
|
||||
) -> Self {
|
||||
assert_eq!(signatures.len(), public_keys.len());
|
||||
|
||||
let signatures_and_public_keys = signatures
|
||||
.iter()
|
||||
.zip(public_keys.iter())
|
||||
.map(|(sig, key)| (sig.clone(), key.clone()))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
signatures_and_public_keys,
|
||||
proof,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn signatures_are_valid_for(&self, message: &Message) -> bool {
|
||||
let message_hash = message.hash_message();
|
||||
|
||||
@ -90,7 +90,7 @@ mod tests {
|
||||
|
||||
assert_eq!(witness_set.signatures_and_public_keys.len(), 2);
|
||||
|
||||
let message_bytes = message.to_bytes();
|
||||
let message_bytes = message.hash_message();
|
||||
for ((signature, public_key), expected_public_key) in witness_set
|
||||
.signatures_and_public_keys
|
||||
.into_iter()
|
||||
|
||||
194
python/keycard-py/.gitignore
vendored
194
python/keycard-py/.gitignore
vendored
@ -1,194 +0,0 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv*
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the enitre vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
@ -1,61 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Support Pairing Mode for Keycard version 3.2
|
||||
|
||||
### Fixed
|
||||
|
||||
- Older versions not supported. Python 3.10+ now supported
|
||||
|
||||
## [0.3.0] - 2025-08-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Open Secure Channel also mutually authenticates unless specified otherwise.
|
||||
- SignatureResult object returned by sign methods
|
||||
- Identity returns public key.
|
||||
|
||||
## [0.2.0] - 2025-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- LOAD KEY command
|
||||
- SET PINLESS PATH command
|
||||
- GENERATE MNEMONIC command
|
||||
- DERIVE KEY command
|
||||
|
||||
## [0.1.0] - 2025-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- INIT command
|
||||
- IDENT command
|
||||
- OPEN SECURE CHANNEL command
|
||||
- MUTUALLY AUTHENTICATE command
|
||||
- PAIR command
|
||||
- UNPAIR command
|
||||
- GET STATUS command
|
||||
- VERIFY PIN command
|
||||
- CHANGE PIN command
|
||||
- UNBLOCK PIN command
|
||||
- REMOVE KEY command
|
||||
- GENERATE KEY command
|
||||
- SIGN command
|
||||
- EXPORT KEY command
|
||||
- GET_DATA command
|
||||
- STORE DATA command
|
||||
- FACTORY RESET command
|
||||
|
||||
|
||||
[unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.3.0...HEAD
|
||||
[0.3.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://github.com/olivierlacan/keep-a-changelog/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/mmlado/keycard-py/releases/tag/v0.1.0
|
||||
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 mmlado
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -1,27 +0,0 @@
|
||||
[](LICENSE) [](https://www.python.org/downloads/) [](https://codecov.io/gh/mmlado/keycard-py) [](https://pypi.org/project/keycard/) [](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml) [](https://mmlado.github.io/keycard-py/)   
|
||||
|
||||
A minimal, clean, fully native Python SDK for communicating with [Keycard](https://keycard.tech) smart cards.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10 or higher
|
||||
- The library is tested on Python 3.10, 3.11, 3.12, and 3.13
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mmlado/keycard-py.git
|
||||
cd keycard-py
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e .
|
||||
pytest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are welcome as this SDK grows.
|
||||
@ -1,36 +0,0 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../../'))
|
||||
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = 'KeyCard.py'
|
||||
copyright = '2025, mmlado'
|
||||
author = 'mmlado'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx_autodoc_typehints',
|
||||
'sphinx.ext.napoleon', # Google/NumPy style docstrings
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = 'alabaster'
|
||||
html_static_path = ['_static']
|
||||
@ -1,21 +0,0 @@
|
||||
.. KeyCard.py documentation master file, created by
|
||||
sphinx-quickstart on Thu Jun 26 13:32:43 2025.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
KeyCard.py documentation
|
||||
========================
|
||||
|
||||
Add your content using ``reStructuredText`` syntax. See the
|
||||
`reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_
|
||||
documentation for details.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
modules
|
||||
|
||||
.. automodule:: keycard.keycard
|
||||
:members:
|
||||
@ -1,77 +0,0 @@
|
||||
keycard.commands package
|
||||
========================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
keycard.commands.ident module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.ident
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.init module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.init
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.mutually_authenticate module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.mutually_authenticate
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.open_secure_channel module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.open_secure_channel
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.pair module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.pair
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.select module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.select
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.unpair module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.unpair
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
keycard.commands.verify_pin module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.commands.verify_pin
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: keycard.commands
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@ -1,29 +0,0 @@
|
||||
keycard.crypto package
|
||||
======================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
keycard.crypto.aes module
|
||||
-------------------------
|
||||
|
||||
.. automodule:: keycard.crypto.aes
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.crypto.padding module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: keycard.crypto.padding
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: keycard.crypto
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@ -1,45 +0,0 @@
|
||||
keycard.parsing package
|
||||
=======================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
keycard.parsing.application\_info module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: keycard.parsing.application_info
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.parsing.capabilities module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: keycard.parsing.capabilities
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.parsing.identity module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: keycard.parsing.identity
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.parsing.tlv module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: keycard.parsing.tlv
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: keycard.parsing
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@ -1,63 +0,0 @@
|
||||
keycard package
|
||||
===============
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
keycard.commands
|
||||
keycard.crypto
|
||||
keycard.parsing
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
keycard.apdu module
|
||||
-------------------
|
||||
|
||||
.. automodule:: keycard.apdu
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.constants module
|
||||
------------------------
|
||||
|
||||
.. automodule:: keycard.constants
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.exceptions module
|
||||
-------------------------
|
||||
|
||||
.. automodule:: keycard.exceptions
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.keycard module
|
||||
----------------------
|
||||
|
||||
.. automodule:: keycard.keycard
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
keycard.transport module
|
||||
------------------------
|
||||
|
||||
.. automodule:: keycard.transport
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: keycard
|
||||
:members:
|
||||
:show-inheritance:
|
||||
:undoc-members:
|
||||
@ -1,7 +0,0 @@
|
||||
keycard
|
||||
=======
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
keycard
|
||||
@ -1,196 +0,0 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from ecdsa import SigningKey, VerifyingKey, SECP256k1, util
|
||||
from hashlib import sha256
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
from keycard import constants
|
||||
from keycard.exceptions import APDUError
|
||||
from keycard.keycard import KeyCard
|
||||
|
||||
PIN = '123456'
|
||||
PUK = '123456123456'
|
||||
PAIRING_PASSWORD = 'KeycardTest'
|
||||
|
||||
def bip32_master_key(seed: bytes):
|
||||
I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
|
||||
master_priv_key = I[:32]
|
||||
master_chain_code = I[32:]
|
||||
return master_priv_key, master_chain_code
|
||||
|
||||
|
||||
def get_uncompressed_pubkey(priv_key_bytes: bytes):
|
||||
sk = SigningKey.from_string(priv_key_bytes, curve=SECP256k1)
|
||||
vk = sk.verifying_key
|
||||
return b'\x04' + vk.to_string()
|
||||
|
||||
|
||||
with KeyCard() as card:
|
||||
card.select()
|
||||
print('Retrieving data...')
|
||||
retrieved_data = card.get_data(slot=constants.StorageSlot.PUBLIC)
|
||||
print(f'Retrieved data: {retrieved_data}')
|
||||
try:
|
||||
print('Factory resetting card...')
|
||||
card.factory_reset()
|
||||
except APDUError as e:
|
||||
print(f'Factory reset failed: {e}')
|
||||
else:
|
||||
print(card.select())
|
||||
|
||||
card.init(PIN, PUK, PAIRING_PASSWORD)
|
||||
print('Card initialized.')
|
||||
print(card.select())
|
||||
|
||||
print('Identifying...')
|
||||
ident_public_key = card.ident()
|
||||
print(f'Identity public key: {ident_public_key.hex()}')
|
||||
|
||||
print('Pairing...')
|
||||
pairing_index, pairing_key = card.pair(PAIRING_PASSWORD)
|
||||
print(f'Paired. Index: {pairing_index}')
|
||||
print(f'{pairing_key.hex()=}')
|
||||
|
||||
card.open_secure_channel(pairing_index, pairing_key)
|
||||
print('Secure channel established.')
|
||||
|
||||
print(card.status)
|
||||
|
||||
print("Generating mnemonic")
|
||||
indexes = card.generate_mnemonic()
|
||||
print("Generated list: ", ", ".join(str(m) for m in indexes))
|
||||
mnemo = Mnemonic("english")
|
||||
words = [mnemo.wordlist[i] for i in indexes]
|
||||
print("Mnemonic: ", " ".join(words))
|
||||
|
||||
print('Unblocking PIN...')
|
||||
card.verify_pin('654321')
|
||||
card.verify_pin('654321')
|
||||
try:
|
||||
card.verify_pin('654321')
|
||||
except RuntimeError as e:
|
||||
print(f'PIN verification failed: {e}')
|
||||
card.unblock_pin(PUK, PIN)
|
||||
print('PIN unblocked.')
|
||||
|
||||
card.verify_pin(PIN)
|
||||
print('PIN verified.')
|
||||
|
||||
print('Generating key...')
|
||||
key = b'0x04' + card.generate_key()
|
||||
print(f'Generated key: {key.hex()}')
|
||||
|
||||
print('Exporting key...')
|
||||
exported_key = card.export_current_key(True)
|
||||
print(f'Exported key: {exported_key.public_key.hex()}')
|
||||
if exported_key.private_key:
|
||||
print(f'Private key: {exported_key.private_key.hex()}')
|
||||
if exported_key.chain_code:
|
||||
print(f'Chain code: {exported_key.chain_code.hex()}')
|
||||
|
||||
digest = sha256(b'This is a test message.').digest()
|
||||
print(f'Digest: {digest.hex()}')
|
||||
signature = card.sign(digest)
|
||||
print(f'Signature: {signature}')
|
||||
|
||||
vk = VerifyingKey.from_string(exported_key.public_key, curve=SECP256k1)
|
||||
try:
|
||||
vk.verify_digest(
|
||||
signature.signature_der, digest, sigdecode=util.sigdecode_der)
|
||||
print('Signature verified successfully.')
|
||||
except Exception as e:
|
||||
print(f"Signature verification failed: {e}")
|
||||
|
||||
print("Set pinless path...")
|
||||
card.set_pinless_path("m/44'/60'/0'/0/0")
|
||||
|
||||
print("Sign with pinless path...")
|
||||
print(f'Digest: {digest.hex()}')
|
||||
signature = card.sign_pinless(digest)
|
||||
print(f'Signature: {signature}')
|
||||
|
||||
exported_key = card.export_key(
|
||||
derivation_option=constants.DerivationOption.DERIVE,
|
||||
public_only=True,
|
||||
keypath="m/44'/60'/0'/0/0"
|
||||
)
|
||||
|
||||
vk = VerifyingKey.from_string(exported_key.public_key, curve=SECP256k1)
|
||||
try:
|
||||
vk.verify_digest(
|
||||
signature.signature_der, digest, sigdecode=util.sigdecode_der)
|
||||
print('Signature verified successfully.')
|
||||
except Exception as e:
|
||||
print(f"Signature verification failed: {e}")
|
||||
|
||||
|
||||
print("Load key...")
|
||||
sk = SigningKey.generate(curve=SECP256k1)
|
||||
vk = sk.verifying_key
|
||||
public_key = b'\x04' + vk.to_string()
|
||||
|
||||
result = card.load_key(
|
||||
key_type=constants.LoadKeyType.ECC,
|
||||
public_key=public_key,
|
||||
private_key=sk.to_string()
|
||||
)
|
||||
|
||||
uid = sha256(public_key).digest()
|
||||
if (result == uid):
|
||||
print("Received public key hash is the same")
|
||||
else:
|
||||
print("Received public key hash is not the same")
|
||||
|
||||
print("Loading key from mnemonic...")
|
||||
mnemonic = (
|
||||
"gravity machine north sort system female "
|
||||
"filter attitude volume fold club stay"
|
||||
)
|
||||
passphrase = ""
|
||||
mnemo = Mnemonic("english")
|
||||
seed = mnemo.to_seed(mnemonic, passphrase)
|
||||
|
||||
master_priv_key, master_chain_code = bip32_master_key(seed)
|
||||
pubkey = get_uncompressed_pubkey(master_priv_key)
|
||||
uid = hashlib.sha256(pubkey).digest()
|
||||
|
||||
result = card.load_key(
|
||||
key_type=constants.LoadKeyType.BIP39_SEED,
|
||||
bip39_seed=seed
|
||||
)
|
||||
|
||||
if (result == uid):
|
||||
print("Received public key hash is the same")
|
||||
else:
|
||||
print("Received public key hash is not the same")
|
||||
|
||||
print("Deriving key...")
|
||||
card.derive_key("m/44'/60'/0'/0/0")
|
||||
|
||||
card.change_pin(PIN)
|
||||
print('PIN changed.')
|
||||
|
||||
card.change_puk(PUK)
|
||||
print('PUK changed.')
|
||||
|
||||
card.change_pairing_secret(PAIRING_PASSWORD)
|
||||
print('Pairing secret changed.')
|
||||
|
||||
print('Storing data...')
|
||||
data = b'This is some test data.'
|
||||
card.store_data(data, slot=constants.StorageSlot.PUBLIC)
|
||||
print('Data stored.')
|
||||
|
||||
print('Retrieving data...')
|
||||
retrieved_data = card.get_data(slot=constants.StorageSlot.PUBLIC)
|
||||
print(f'Retrieved data: {retrieved_data}')
|
||||
|
||||
print('Removing key...')
|
||||
card.remove_key()
|
||||
print('Key removed.')
|
||||
|
||||
print('Unpairing...')
|
||||
card.unpair(pairing_index)
|
||||
print(f'Unpaired index {pairing_index}.')
|
||||
@ -1,4 +0,0 @@
|
||||
"""KeyCard Python SDK - APDU communication and cryptographic utilities."""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__doc__ = "Python SDK for interacting with Status Keycard."
|
||||
@ -1,48 +0,0 @@
|
||||
'''
|
||||
This module provides classes and functions for handling APDU (Application
|
||||
Protocol Data Unit) responses and encoding data in LV (Length-Value) format.
|
||||
'''
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class APDUResponse:
|
||||
'''
|
||||
Represents a response to an APDU (Application Protocol Data Unit) command.
|
||||
|
||||
Attributes:
|
||||
data (bytes): The response data returned from the APDU command.
|
||||
status_word (int): The status word indicating the result of the APDU
|
||||
command.
|
||||
'''
|
||||
data: bytes
|
||||
status_word: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'APDUResponse(data={bytes(self.data).hex()}, '
|
||||
f'status_word={hex(self.status_word)})'
|
||||
)
|
||||
|
||||
|
||||
def encode_lv(value: bytes) -> bytes:
|
||||
'''
|
||||
Encodes the given bytes using LV (Length-Value) encoding.
|
||||
|
||||
The function prepends the length of the input bytes as a single byte,
|
||||
followed by the value itself. The maximum supported length is 255 bytes.
|
||||
|
||||
Args:
|
||||
value (bytes): The data to encode.
|
||||
|
||||
Returns:
|
||||
bytes: The LV-encoded bytes.
|
||||
|
||||
Raises:
|
||||
ValueError: If the input exceeds 255 bytes in length.
|
||||
'''
|
||||
if len(value) > 255:
|
||||
raise ValueError('LV encoding supports up to 255 bytes')
|
||||
|
||||
return bytes([len(value)]) + value
|
||||
@ -1,38 +0,0 @@
|
||||
from typing import Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CardInterface(Protocol):
|
||||
'''
|
||||
Abstract base class representing a Keycard interface for command functions.
|
||||
'''
|
||||
card_public_key: Optional[bytes]
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def is_secure_channel_open(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def is_pin_verified(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def is_selected(self) -> bool: ...
|
||||
|
||||
def send_apdu(
|
||||
self,
|
||||
ins: int,
|
||||
p1: int = 0x00,
|
||||
p2: int = 0x00,
|
||||
data: bytes = b'',
|
||||
cla: Optional[int] = None
|
||||
) -> bytes: ...
|
||||
|
||||
def send_secure_apdu(
|
||||
self,
|
||||
ins: int,
|
||||
p1: int = 0x00,
|
||||
p2: int = 0x00,
|
||||
data: bytes = b''
|
||||
) -> bytes: ...
|
||||
@ -1,49 +0,0 @@
|
||||
from .change_secret import change_secret
|
||||
from .derive_key import derive_key
|
||||
from .export_key import export_key
|
||||
from .export_lee_key import export_lee_key
|
||||
from .factory_reset import factory_reset
|
||||
from .generate_key import generate_key
|
||||
from .generate_mnemonic import generate_mnemonic
|
||||
from .get_data import get_data
|
||||
from .ident import ident
|
||||
from .init import init
|
||||
from .get_status import get_status
|
||||
from .load_key import load_key
|
||||
from .mutually_authenticate import mutually_authenticate
|
||||
from .open_secure_channel import open_secure_channel
|
||||
from .pair import pair
|
||||
from .remove_key import remove_key
|
||||
from .select import select
|
||||
from .set_pinless_path import set_pinless_path
|
||||
from .sign import sign
|
||||
from .store_data import store_data
|
||||
from .unblock_pin import unblock_pin
|
||||
from .unpair import unpair
|
||||
from .verify_pin import verify_pin
|
||||
|
||||
__all__ = [
|
||||
'change_secret',
|
||||
'derive_key',
|
||||
'export_key',
|
||||
'export_lee_key',
|
||||
'factory_reset',
|
||||
'generate_key',
|
||||
'generate_mnemonic',
|
||||
'get_data',
|
||||
'ident',
|
||||
'init',
|
||||
'get_status',
|
||||
'load_key',
|
||||
'mutually_authenticate',
|
||||
'open_secure_channel',
|
||||
'pair',
|
||||
'remove_key',
|
||||
'select',
|
||||
'set_pinless_path',
|
||||
'sign',
|
||||
'store_data',
|
||||
'unblock_pin',
|
||||
'unpair',
|
||||
'verify_pin',
|
||||
]
|
||||
@ -1,46 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_pin_verified
|
||||
from ..crypto.generate_pairing_token import generate_pairing_token
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def change_secret(
|
||||
card: CardInterface,
|
||||
new_value: bytes | str,
|
||||
pin_type: constants.PinType
|
||||
) -> None:
|
||||
"""
|
||||
Changes the specified secret (PIN, PUK, PAIRING) or secret on the card.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened
|
||||
- User PIN must be verified
|
||||
|
||||
Args:
|
||||
card: The card session object.
|
||||
new_value (bytes | str): The new PIN/PUK/secret.
|
||||
pin_type (PinType): Type of PIN (USER, PUK, or PAIRING)
|
||||
|
||||
Raises:
|
||||
ValueError: If input format is invalid.
|
||||
APDUError: If the card returns an error status word.
|
||||
"""
|
||||
if pin_type == constants.PinType.PAIRING:
|
||||
if isinstance(new_value, str):
|
||||
new_value = generate_pairing_token(new_value)
|
||||
elif len(new_value) != 32:
|
||||
raise ValueError("Pairing secret must be 32 bytes.")
|
||||
elif isinstance(new_value, str):
|
||||
new_value = new_value.encode("utf-8")
|
||||
|
||||
if pin_type == constants.PinType.USER and len(new_value) != 6:
|
||||
raise ValueError("User PIN must be exactly 6 digits.")
|
||||
elif pin_type == constants.PinType.PUK and len(new_value) != 12:
|
||||
raise ValueError("PUK must be exactly 12 digits.")
|
||||
|
||||
card.send_secure_apdu(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=pin_type.value,
|
||||
data=new_value
|
||||
)
|
||||
@ -1,25 +0,0 @@
|
||||
from ..card_interface import CardInterface
|
||||
from ..constants import INS_DERIVE_KEY
|
||||
from ..parsing.keypath import KeyPath
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def derive_key(card: CardInterface, path: str = '') -> None:
|
||||
"""
|
||||
Set the derivation path for subsequent SIGN and EXPORT KEY commands.
|
||||
|
||||
Args:
|
||||
card (CardInterface): The card interface.
|
||||
path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0") or
|
||||
"../0/1" (parent) or "./0" (current).
|
||||
|
||||
Raises:
|
||||
APDUError: if the derivation fails or the format is invalid.
|
||||
"""
|
||||
keypath = KeyPath(path)
|
||||
card.send_secure_apdu(
|
||||
ins=INS_DERIVE_KEY,
|
||||
p1=keypath.source,
|
||||
data=keypath.data
|
||||
)
|
||||
@ -1,80 +0,0 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..constants import DerivationOption, KeyExportOption, DerivationSource
|
||||
from ..parsing import tlv
|
||||
from ..parsing.exported_key import ExportedKey
|
||||
from ..parsing.keypath import KeyPath
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def export_key(
|
||||
card: CardInterface,
|
||||
derivation_option: constants.DerivationOption,
|
||||
public_only: bool,
|
||||
keypath: Optional[Union[str, bytes, bytearray]] = None,
|
||||
make_current: bool = False,
|
||||
source: DerivationSource = DerivationSource.MASTER
|
||||
) -> ExportedKey:
|
||||
"""
|
||||
Export a key (public or private) from the card using an optional keypath.
|
||||
|
||||
If derivation_option == CURRENT, keypath can be omitted or empty.
|
||||
|
||||
Args:
|
||||
card: The card object
|
||||
derivation_option: e.g. DERIVE, CURRENT, DERIVE_AND_MAKE_CURRENT
|
||||
public_only: If True, export only public key
|
||||
keypath: BIP32-style string or packed bytes, or None if CURRENT
|
||||
make_current: Whether to update the card's current path
|
||||
source: MASTER (0x00), PARENT (0x40), CURRENT (0x80)
|
||||
|
||||
Returns:
|
||||
dict with optional 'public_key', 'private_key', 'chain_code'
|
||||
"""
|
||||
if keypath is None:
|
||||
if derivation_option != constants.DerivationOption.CURRENT:
|
||||
raise ValueError(
|
||||
"Keypath required unless using CURRENT derivation")
|
||||
data = b""
|
||||
elif isinstance(keypath, str):
|
||||
data = KeyPath(keypath).data
|
||||
elif isinstance(keypath, (bytes, bytearray)):
|
||||
if len(keypath) % 4 != 0:
|
||||
raise ValueError("Byte keypath must be a multiple of 4")
|
||||
data = bytes(keypath)
|
||||
else:
|
||||
raise TypeError("Keypath must be a string or bytes")
|
||||
|
||||
if make_current:
|
||||
p1 = DerivationOption.DERIVE_AND_MAKE_CURRENT
|
||||
else:
|
||||
p1 = derivation_option
|
||||
p1 |= source
|
||||
|
||||
if public_only:
|
||||
p2 = KeyExportOption.PUBLIC_ONLY
|
||||
else:
|
||||
p2 = KeyExportOption.PRIVATE_AND_PUBLIC
|
||||
|
||||
response = card.send_secure_apdu(
|
||||
ins=constants.INS_EXPORT_KEY,
|
||||
p1=p1,
|
||||
p2=p2,
|
||||
data=data
|
||||
)
|
||||
|
||||
outer = tlv.parse_tlv(response)
|
||||
tpl = outer.get(0xA1)
|
||||
if not tpl:
|
||||
raise ValueError("Missing keypair template (tag 0xA1)")
|
||||
|
||||
inner = tlv.parse_tlv(tpl[0])
|
||||
|
||||
return ExportedKey(
|
||||
public_key=inner.get(0x80, [None])[0],
|
||||
private_key=inner.get(0x81, [None])[0],
|
||||
chain_code=inner.get(0x82, [None])[0],
|
||||
)
|
||||
@ -1,82 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..constants import DerivationOption, DerivationSource
|
||||
from ..parsing import tlv
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportedLeeKey:
|
||||
"""Represents a LEE key template containing LEE_NSK and LEE_VSK."""
|
||||
lee_nsk: Optional[bytes] = None
|
||||
lee_vsk: Optional[bytes] = None
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def export_lee_key(
|
||||
card: CardInterface,
|
||||
derivation_option: constants.DerivationOption,
|
||||
keypath: Optional[Union[str, bytes, bytearray]] = None,
|
||||
make_current: bool = False,
|
||||
source: DerivationSource = DerivationSource.MASTER
|
||||
) -> ExportedLeeKey:
|
||||
"""
|
||||
Export a LEE key template from the card.
|
||||
|
||||
The output is a key template (tag 0xA1) containing LEE_NSK (tag 0x83)
|
||||
and LEE_VSK (tag 0x84).
|
||||
|
||||
If derivation_option == CURRENT, keypath can be omitted or empty.
|
||||
|
||||
Args:
|
||||
card: The card object
|
||||
derivation_option: e.g. DERIVE, CURRENT, DERIVE_AND_MAKE_CURRENT
|
||||
keypath: BIP32-style string or packed bytes, or None if CURRENT
|
||||
make_current: Whether to update the card's current path
|
||||
source: MASTER (0x00), PARENT (0x40), CURRENT (0x80)
|
||||
|
||||
Returns:
|
||||
ExportedLeeKey with lee_nsk and lee_vsk fields
|
||||
"""
|
||||
if keypath is None:
|
||||
if derivation_option != constants.DerivationOption.CURRENT:
|
||||
raise ValueError(
|
||||
"Keypath required unless using CURRENT derivation")
|
||||
data = b""
|
||||
elif isinstance(keypath, str):
|
||||
from ..parsing.keypath import KeyPath
|
||||
data = KeyPath(keypath).data
|
||||
elif isinstance(keypath, (bytes, bytearray)):
|
||||
if len(keypath) % 4 != 0:
|
||||
raise ValueError("Byte keypath must be a multiple of 4")
|
||||
data = bytes(keypath)
|
||||
else:
|
||||
raise TypeError("Keypath must be a string or bytes")
|
||||
|
||||
if make_current:
|
||||
p1 = DerivationOption.DERIVE_AND_MAKE_CURRENT
|
||||
else:
|
||||
p1 = derivation_option
|
||||
p1 |= source
|
||||
|
||||
response = card.send_secure_apdu(
|
||||
ins=constants.INS_EXPORT_LEE_KEY,
|
||||
p1=p1,
|
||||
p2=0x00,
|
||||
data=data
|
||||
)
|
||||
|
||||
outer = tlv.parse_tlv(response)
|
||||
tpl = outer.get(0xA1)
|
||||
if not tpl:
|
||||
raise ValueError("Missing keypair template (tag 0xA1)")
|
||||
|
||||
inner = tlv.parse_tlv(tpl[0])
|
||||
|
||||
return ExportedLeeKey(
|
||||
lee_nsk=inner.get(0x83, [None])[0],
|
||||
lee_vsk=inner.get(0x84, [None])[0],
|
||||
)
|
||||
@ -1,15 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_selected
|
||||
|
||||
|
||||
@require_selected
|
||||
def factory_reset(card: CardInterface) -> None:
|
||||
'''
|
||||
Sends the FACTORY_RESET command to the card.
|
||||
'''
|
||||
card.send_apdu(
|
||||
ins=constants.INS_FACTORY_RESET,
|
||||
p1=0xAA,
|
||||
p2=0x55
|
||||
)
|
||||
@ -1,27 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_secure_channel
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def generate_key(card: CardInterface) -> bytes:
|
||||
'''
|
||||
Generates a new key on the card and returns the key UID.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened
|
||||
- PIN must be verified
|
||||
|
||||
Args:
|
||||
transport: Transport instance for APDU communication
|
||||
session: SecureChannel instance for wrapping/unwrapping
|
||||
|
||||
Returns:
|
||||
bytes: Key UID (SHA-256 of the public key)
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not 0x9000
|
||||
'''
|
||||
return card.send_secure_apdu(
|
||||
ins=constants.INS_GENERATE_KEY
|
||||
)
|
||||
@ -1,41 +0,0 @@
|
||||
from ..card_interface import CardInterface
|
||||
from ..constants import INS_GENERATE_MNEMONIC
|
||||
from ..preconditions import require_secure_channel
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def generate_mnemonic(
|
||||
card: CardInterface,
|
||||
checksum_size: int = 6
|
||||
) -> list[int]:
|
||||
"""
|
||||
Generate a BIP39 mnemonic using the card's RNG.
|
||||
|
||||
Args:
|
||||
card (CardInterface): The card interface.
|
||||
checksum_size (int): Number of checksum bits
|
||||
(between 4 and 8 inclusive).
|
||||
|
||||
Returns:
|
||||
List[int]: List of integers (0-2047) corresponding to wordlist
|
||||
indexes.
|
||||
|
||||
Raises:
|
||||
ValueError: If checksum size is outside the allowed range.
|
||||
APDUError: If the card rejects the request.
|
||||
"""
|
||||
if not (4 <= checksum_size <= 8):
|
||||
raise ValueError("Checksum size must be between 4 and 8")
|
||||
|
||||
response = card.send_secure_apdu(
|
||||
ins=INS_GENERATE_MNEMONIC,
|
||||
p1=checksum_size
|
||||
)
|
||||
|
||||
if len(response) % 2 != 0:
|
||||
raise ValueError("Response must contain an even number of bytes")
|
||||
|
||||
return [
|
||||
(response[i] << 8) | response[i + 1]
|
||||
for i in range(0, len(response), 2)
|
||||
]
|
||||
@ -1,35 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_selected
|
||||
|
||||
|
||||
@require_selected
|
||||
def get_data(
|
||||
card: CardInterface,
|
||||
slot: constants.StorageSlot = constants.StorageSlot.PUBLIC
|
||||
) -> bytes:
|
||||
"""
|
||||
Gets the data on the card previously stored with the store data command
|
||||
in the specified slot.
|
||||
|
||||
If the secure channel is open, it uses the secure APDU command.
|
||||
Otherwise, it uses the proprietary APDU command.
|
||||
|
||||
Args:
|
||||
card: The card session object.
|
||||
slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH)
|
||||
|
||||
Raises:
|
||||
ValueError: If slot is invalid or data is too long.
|
||||
"""
|
||||
if card.is_secure_channel_open:
|
||||
return card.send_secure_apdu(
|
||||
ins=constants.INS_GET_DATA,
|
||||
p1=slot.value
|
||||
)
|
||||
|
||||
return card.send_apdu(
|
||||
cla=constants.CLA_PROPRIETARY,
|
||||
ins=constants.INS_GET_DATA,
|
||||
p1=slot.value
|
||||
)
|
||||
@ -1,63 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..parsing import tlv
|
||||
from ..preconditions import require_secure_channel
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def get_status(
|
||||
card: CardInterface,
|
||||
key_path: bool = False
|
||||
) -> dict[str, int | bool] | list[int]:
|
||||
'''
|
||||
Query the application status or key path from the Keycard.
|
||||
|
||||
Requires an open Secure Channel.
|
||||
|
||||
Args:
|
||||
transport: Transport instance used to send APDU bytes.
|
||||
session: An established SecureChannel instance.
|
||||
key_path (bool): If True, returns the current key path.
|
||||
If False (default), returns application status.
|
||||
|
||||
Returns:
|
||||
If key_path is False:
|
||||
dict with keys:
|
||||
- pin_retry_count (int)
|
||||
- puk_retry_count (int)
|
||||
- initialized (bool)
|
||||
|
||||
If key_path is True:
|
||||
List of 32-bit integers representing the current key path.
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
ValueError: If the application status template (tag 0xA3) is missing.
|
||||
'''
|
||||
response: bytes = card.send_secure_apdu(
|
||||
ins=constants.INS_GET_STATUS,
|
||||
p1=0x01 if key_path else 0x00,
|
||||
)
|
||||
|
||||
if key_path:
|
||||
return [
|
||||
int.from_bytes(response[i:i+4], 'big')
|
||||
for i in range(0, len(response), 4)
|
||||
]
|
||||
|
||||
outer = tlv.parse_tlv(response)
|
||||
|
||||
if 0xA3 not in outer:
|
||||
raise ValueError('Missing tag 0xA3 (Application Status Template)')
|
||||
|
||||
inner = tlv.parse_tlv(outer[0xA3][0])
|
||||
|
||||
pin_retry = inner[0x02][0] or b'\xff'
|
||||
puk_retry = inner[0x02][1] or b'\xff'
|
||||
initialized = inner[0x01][0] != b'\x00'
|
||||
|
||||
return {
|
||||
'pin_retry_count': pin_retry[0] if pin_retry else 0xff,
|
||||
'puk_retry_count': puk_retry[0] if puk_retry else 0xff,
|
||||
'initialized': initialized
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..parsing.identity import parse
|
||||
from ..preconditions import require_selected
|
||||
|
||||
|
||||
@require_selected
|
||||
def ident(card: CardInterface, challenge: Optional[bytes]) -> bytes:
|
||||
'''
|
||||
Sends a challenge to the card to receive a signed identity response.
|
||||
|
||||
Args:
|
||||
transport: An instance of the Transport class to communicate with
|
||||
the card.
|
||||
challenge (bytes): A challenge (nonce or data) to send to the card.
|
||||
If None, a random 32-byte challenge is generated.
|
||||
|
||||
Returns:
|
||||
bytes: The public key extracted from the card's identity response.
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not successful (0x9000).
|
||||
'''
|
||||
challenge = challenge or os.urandom(32)
|
||||
|
||||
response: bytes = card.send_apdu(
|
||||
ins=constants.INS_IDENT,
|
||||
data=challenge
|
||||
)
|
||||
|
||||
return parse(challenge, response)
|
||||
@ -1,79 +0,0 @@
|
||||
from os import urandom
|
||||
from ecdsa import SigningKey, VerifyingKey, ECDH, SECP256k1
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..crypto.aes import aes_cbc_encrypt
|
||||
from ..crypto.generate_pairing_token import generate_pairing_token
|
||||
from ..exceptions import NotSelectedError
|
||||
from ..preconditions import require_selected
|
||||
|
||||
|
||||
@require_selected
|
||||
def init(
|
||||
card: CardInterface,
|
||||
pin: str | bytes,
|
||||
puk: str | bytes,
|
||||
pairing_secret: str | bytes
|
||||
) -> None:
|
||||
'''
|
||||
Initializes a Keycard device with PIN, PUK, and pairing secret.
|
||||
|
||||
Establishes an ephemeral ECDH key exchange and sends encrypted
|
||||
credentials to the card.
|
||||
|
||||
Args:
|
||||
transport: The transport used to send APDU commands to the card.
|
||||
card_public_key (bytes): The card's ECC public key, usually
|
||||
retrieved via select().
|
||||
pin (bytes): The personal identification number (PIN) as bytes.
|
||||
puk (bytes): The personal unblocking key (PUK) as bytes.
|
||||
pairing_secret (bytes): A 32-byte shared secret or a passphrase that
|
||||
will be converted into one.
|
||||
|
||||
Raises:
|
||||
NotSelectedError: If no card public key is provided.
|
||||
ValueError: If the encrypted data exceeds a single APDU length.
|
||||
APDUError: If the card returns a failure status word.
|
||||
'''
|
||||
if card.card_public_key is None:
|
||||
raise NotSelectedError('Card not selected. Call select() first.')
|
||||
|
||||
if not isinstance(pin, bytes):
|
||||
pin = pin.encode('ascii')
|
||||
if not isinstance(puk, bytes):
|
||||
puk = puk.encode('ascii')
|
||||
if not isinstance(pairing_secret, bytes):
|
||||
pairing_secret = generate_pairing_token(pairing_secret)
|
||||
|
||||
ephemeral_key = SigningKey.generate(curve=SECP256k1)
|
||||
our_pubkey_bytes: bytes = \
|
||||
ephemeral_key.verifying_key.to_string('uncompressed')
|
||||
card_pubkey = VerifyingKey.from_string(
|
||||
card.card_public_key,
|
||||
curve=SECP256k1
|
||||
)
|
||||
ecdh = ECDH(
|
||||
curve=SECP256k1,
|
||||
private_key=ephemeral_key,
|
||||
public_key=card_pubkey
|
||||
)
|
||||
shared_secret = ecdh.generate_sharedsecret_bytes()
|
||||
|
||||
plaintext: bytes = pin + puk + pairing_secret
|
||||
iv: bytes = urandom(16)
|
||||
ciphertext: bytes = aes_cbc_encrypt(shared_secret, iv, plaintext)
|
||||
data: bytes = (
|
||||
bytes([len(our_pubkey_bytes)])
|
||||
+ our_pubkey_bytes
|
||||
+ iv
|
||||
+ ciphertext
|
||||
)
|
||||
|
||||
if len(data) > 255:
|
||||
raise ValueError('Data too long for single APDU')
|
||||
|
||||
card.send_apdu(
|
||||
ins=constants.INS_INIT,
|
||||
data=data
|
||||
)
|
||||
@ -1,65 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..parsing import tlv
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def load_key(
|
||||
card: CardInterface,
|
||||
key_type: constants.LoadKeyType,
|
||||
public_key: Optional[bytes] = None,
|
||||
private_key: Optional[bytes] = None,
|
||||
chain_code: Optional[bytes] = None,
|
||||
bip39_seed: Optional[bytes] = None,
|
||||
lee_seed: Optional[bytes] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Load a key into the card for signing purposes.
|
||||
|
||||
Args:
|
||||
card: The card interface.
|
||||
key_type: Key type
|
||||
public_key: Optional ECC public key (tag 0x80).
|
||||
private_key: ECC private key (tag 0x81).
|
||||
chain_code: Optional chain code (tag 0x82, only for extended key).
|
||||
bip39_seed: 64-byte BIP39 seed (only for key_type=BIP39_SEED).
|
||||
lee_seed: 64-byte LEE seed (only for key_type=BIP39_SEED).
|
||||
|
||||
Returns:
|
||||
UID of the loaded key (SHA-256 of public key).
|
||||
"""
|
||||
if key_type == constants.LoadKeyType.BIP39_SEED:
|
||||
if bip39_seed is None and lee_seed is None:
|
||||
raise ValueError(
|
||||
"Either bip39_seed or lee_seed must be provided for key_type = BIP39_SEED")
|
||||
data = bip39_seed if bip39_seed is not None else lee_seed
|
||||
|
||||
if data is not None and len(data) > 64 or len(data) < 16:
|
||||
raise ValueError(
|
||||
"BIP39/LEE seed must be 16-64 bytes")
|
||||
else:
|
||||
inner_tlv = []
|
||||
if public_key is not None:
|
||||
inner_tlv.append(tlv.encode_tlv(0x80, public_key))
|
||||
if private_key is None:
|
||||
raise ValueError("Private key (tag 0x81) is required")
|
||||
inner_tlv.append(tlv.encode_tlv(0x81, private_key))
|
||||
if (
|
||||
key_type == constants.LoadKeyType.EXTENDED_ECC and
|
||||
chain_code is not None
|
||||
):
|
||||
inner_tlv.append(tlv.encode_tlv(0x82, chain_code))
|
||||
tpl = tlv.encode_tlv(0xA1, b''.join(inner_tlv))
|
||||
data = tpl
|
||||
|
||||
response = card.send_secure_apdu(
|
||||
ins=constants.INS_LOAD_KEY,
|
||||
p1=key_type,
|
||||
p2=1 if lee_seed is not None else 0,
|
||||
data=data
|
||||
)
|
||||
|
||||
return response
|
||||
@ -1,43 +0,0 @@
|
||||
import os
|
||||
from ..card_interface import CardInterface
|
||||
from .. import constants
|
||||
from ..preconditions import require_secure_channel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def mutually_authenticate(
|
||||
card: CardInterface,
|
||||
client_challenge: Optional[bytes] = None
|
||||
) -> None:
|
||||
'''
|
||||
Performs mutual authentication between the client and the Keycard.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened
|
||||
|
||||
The card will respond with a cryptographic challenge. The secure
|
||||
session will verify the response. If the response is not exactly
|
||||
32 bytes, or if the response has an unexpected status word, the
|
||||
function raises an error.
|
||||
|
||||
Args:
|
||||
transport: A Transport instance for sending APDUs.
|
||||
session: A SecureChannel instance used for wrapping/unwrapping.
|
||||
client_challenge (bytes, optional): Optional challenge bytes.
|
||||
If not provided, a random 32-byte value will be generated.
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
ValueError: If the decrypted response is not exactly 32 bytes.
|
||||
'''
|
||||
client_challenge = client_challenge or os.urandom(32)
|
||||
|
||||
response: bytes = card.send_secure_apdu(
|
||||
ins=constants.INS_MUTUALLY_AUTHENTICATE,
|
||||
data=client_challenge
|
||||
)
|
||||
|
||||
if len(response) != 32:
|
||||
raise ValueError(
|
||||
'Response to MUTUALLY AUTHENTICATE is not 32 bytes')
|
||||
@ -1,68 +0,0 @@
|
||||
from ecdsa import SigningKey, VerifyingKey, SECP256k1, ECDH
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..exceptions import NotSelectedError
|
||||
from ..secure_channel import SecureChannel
|
||||
from ..preconditions import require_initialized
|
||||
|
||||
|
||||
@require_initialized
|
||||
def open_secure_channel(
|
||||
card: CardInterface,
|
||||
pairing_index: int,
|
||||
pairing_key: bytes
|
||||
) -> SecureChannel:
|
||||
'''
|
||||
Opens a secure session with the Keycard using ECDH and a pairing key.
|
||||
|
||||
This function performs an ephemeral ECDH key exchange with the card,
|
||||
sends the ephemeral public key, and receives cryptographic material
|
||||
from the card to derive a secure session.
|
||||
|
||||
Args:
|
||||
transport: The transport used to communicate with the card.
|
||||
card_public_key (bytes): The ECC public key of the card, retrieved
|
||||
via select().
|
||||
pairing_index (int): The index of the previously established
|
||||
pairing slot.
|
||||
pairing_key (bytes): The shared 32-byte pairing key.
|
||||
|
||||
Returns:
|
||||
SecureChannel: A newly established secure session with the card.
|
||||
|
||||
Raises:
|
||||
NotSelectedError: If no card public key is provided.
|
||||
APDUError: If the card returns a failure status word.
|
||||
'''
|
||||
if not card.card_public_key:
|
||||
raise NotSelectedError('Card not selected or missing public key')
|
||||
|
||||
ephemeral_key = SigningKey.generate(curve=SECP256k1)
|
||||
eph_pub_bytes = ephemeral_key.verifying_key.to_string('uncompressed')
|
||||
response: bytes = card.send_apdu(
|
||||
ins=constants.INS_OPEN_SECURE_CHANNEL,
|
||||
p1=pairing_index,
|
||||
data=eph_pub_bytes
|
||||
)
|
||||
|
||||
salt = bytes(response[:32])
|
||||
seed_iv = bytes(response[32:])
|
||||
|
||||
public_key = VerifyingKey.from_string(
|
||||
card.card_public_key,
|
||||
curve=SECP256k1
|
||||
)
|
||||
ecdh = ECDH(
|
||||
curve=SECP256k1,
|
||||
private_key=ephemeral_key,
|
||||
public_key=public_key
|
||||
)
|
||||
shared_secret = ecdh.generate_sharedsecret_bytes()
|
||||
|
||||
return SecureChannel.open(
|
||||
shared_secret,
|
||||
pairing_key,
|
||||
salt,
|
||||
seed_iv,
|
||||
)
|
||||
@ -1,75 +0,0 @@
|
||||
import hashlib
|
||||
from os import urandom
|
||||
from typing import Optional
|
||||
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..crypto.generate_pairing_token import generate_pairing_token
|
||||
from ..exceptions import InvalidResponseError
|
||||
from ..preconditions import require_initialized
|
||||
|
||||
|
||||
@require_initialized
|
||||
def pair(
|
||||
card: CardInterface,
|
||||
shared_secret: str | bytes,
|
||||
pairing_mode: Optional[constants.PairingMode] = constants.PairingMode.ANY
|
||||
) -> tuple[int, bytes]:
|
||||
'''
|
||||
Performs an ECDH-based pairing handshake with the card.
|
||||
|
||||
Args:
|
||||
card: The keycard interface.
|
||||
shared_secret: A 32-byte secret or a passphrase convertible to one.
|
||||
pairing_mode: Mode for pairing: ANY, EPHEMERAL, PERSISTENT
|
||||
|
||||
Returns:
|
||||
tuple[int, bytes]: Pairing index and derived 32-byte pairing key.
|
||||
|
||||
Raises:
|
||||
ValueError: If the shared secret is not 32 bytes.
|
||||
APDUError: If the card returns a non-success status word.
|
||||
InvalidResponseError: If response lengths or values are unexpected.
|
||||
'''
|
||||
if isinstance(shared_secret, str):
|
||||
shared_secret = generate_pairing_token(shared_secret)
|
||||
|
||||
if len(shared_secret) != 32:
|
||||
raise ValueError('Shared secret must be 32 bytes')
|
||||
|
||||
client_challenge = urandom(32)
|
||||
|
||||
response = card.send_apdu(
|
||||
ins=constants.INS_PAIR,
|
||||
p2=pairing_mode,
|
||||
data=client_challenge
|
||||
)
|
||||
|
||||
if len(response) != 64:
|
||||
raise InvalidResponseError('Unexpected response length')
|
||||
|
||||
card_cryptogram = response[:32]
|
||||
card_challenge = response[32:]
|
||||
|
||||
expected = hashlib.sha256(shared_secret + client_challenge).digest()
|
||||
|
||||
if card_cryptogram != expected:
|
||||
raise InvalidResponseError('Card cryptogram mismatch')
|
||||
|
||||
client_cryptogram = hashlib.sha256(shared_secret + card_challenge).digest()
|
||||
|
||||
response = card.send_apdu(
|
||||
ins=constants.INS_PAIR,
|
||||
p1=0x01,
|
||||
data=client_cryptogram
|
||||
)
|
||||
|
||||
if len(response) != 33:
|
||||
raise InvalidResponseError('Unexpected response length')
|
||||
|
||||
pairing_index = response[0]
|
||||
salt = response[1:]
|
||||
|
||||
pairing_key = hashlib.sha256(shared_secret + salt).digest()
|
||||
|
||||
return pairing_index, pairing_key
|
||||
@ -1,11 +0,0 @@
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def remove_key(card: CardInterface) -> None:
|
||||
'''
|
||||
Removes the key from the card, returning it to an uninitialized state.
|
||||
|
||||
'''
|
||||
card.send_secure_apdu(ins=0xD3)
|
||||
@ -1,33 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..parsing.application_info import ApplicationInfo
|
||||
|
||||
|
||||
def select(card: CardInterface) -> ApplicationInfo:
|
||||
'''
|
||||
Selects the Keycard application on the smart card and retrieves
|
||||
application information.
|
||||
|
||||
Sends a SELECT APDU command using the Keycard AID, checks for a
|
||||
successful response, parses the returned application information,
|
||||
and returns it.
|
||||
|
||||
Args:
|
||||
transport: The transport instance used to send the APDU command.
|
||||
|
||||
Returns:
|
||||
ApplicationInfo: Parsed information about the selected Keycard
|
||||
application.
|
||||
|
||||
Raises:
|
||||
APDUError: If the card returns a status word indicating failure.
|
||||
'''
|
||||
result = card.send_apdu(
|
||||
cla=constants.CLAISO7816,
|
||||
ins=constants.INS_SELECT,
|
||||
p1=0x04,
|
||||
p2=0x00,
|
||||
data=constants.KEYCARD_AID
|
||||
)
|
||||
|
||||
return ApplicationInfo.parse(result)
|
||||
@ -1,25 +0,0 @@
|
||||
from ..card_interface import CardInterface
|
||||
from ..constants import INS_SET_PINLESS_PATH
|
||||
from ..parsing.keypath import KeyPath
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def set_pinless_path(card: CardInterface, path: str) -> None:
|
||||
"""
|
||||
Set a PIN-less path on the card. Allows signing without PIN/auth if the
|
||||
current derived key matches this path.
|
||||
|
||||
Args:
|
||||
card (CardInterface): The card interface.
|
||||
path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0"). An empty
|
||||
string disables the pinless path.
|
||||
|
||||
Raises:
|
||||
APDUError: if the card rejects the input (invalid path)
|
||||
"""
|
||||
keypath = KeyPath(path).data if path else b""
|
||||
card.send_secure_apdu(
|
||||
ins=INS_SET_PINLESS_PATH,
|
||||
data=keypath
|
||||
)
|
||||
@ -1,136 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from ecdsa.util import sigdecode_der
|
||||
|
||||
|
||||
from .. import constants
|
||||
from ..constants import DerivationOption, DerivationSource, SigningAlgorithm
|
||||
from ..card_interface import CardInterface
|
||||
from ..exceptions import InvalidStateError
|
||||
from ..parsing import tlv
|
||||
from ..parsing.keypath import KeyPath
|
||||
from ..parsing.signature_result import SignatureResult
|
||||
|
||||
|
||||
def sign(
|
||||
card: CardInterface,
|
||||
digest: bytes,
|
||||
p1: DerivationOption = DerivationOption.CURRENT,
|
||||
p2: SigningAlgorithm = SigningAlgorithm.ECDSA_SECP256K1,
|
||||
derivation_path: Optional[str] = None
|
||||
) -> SignatureResult:
|
||||
"""
|
||||
Sign a 32-byte digest using the specified key and signing algorithm.
|
||||
|
||||
This command sends the SIGN APDU to the Keycard and parses the response,
|
||||
returning a structured `SignatureResult` object. The signature may be
|
||||
returned as a DER-encoded structure, a raw 65-byte format including
|
||||
the recovery ID, or an ECDSA template depending on card behavior.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened (unless using PINLESS)
|
||||
- PIN must be verified (unless using PINLESS)
|
||||
- A valid keypair must be loaded on the card
|
||||
- If P1=PINLESS, a PIN-less path must be configured
|
||||
|
||||
Args:
|
||||
card (CardInterface): Active Keycard transport session.
|
||||
digest (bytes): 32-byte hash to be signed.
|
||||
p1 (DerivationOption): Key derivation option. One of:
|
||||
- CURRENT: Sign with the currently loaded key
|
||||
- DERIVE: Derive key for signing without changing current
|
||||
- DERIVE_AND_MAKE_CURRENT: Derive and load for future use
|
||||
- PINLESS: Use pre-defined PIN-less key without SC/PIN
|
||||
p2 (SigningAlgorithm): Signing algorithm. Defaults to
|
||||
ECDSA_SECP256K1. Other options include SCHNORR_BIP340.
|
||||
derivation_path (Optional[str]): String-formatted BIP32 path
|
||||
(e.g. "m/44'/60'/0'/0/0"). Required if `p1` uses derivation.
|
||||
The source (master/parent/current) is inferred from the path
|
||||
prefix.
|
||||
|
||||
Returns:
|
||||
SignatureResult: Parsed signature result, including the signature
|
||||
(DER or raw), algorithm, and optional recovery ID or public key.
|
||||
|
||||
Raises:
|
||||
ValueError: If the digest is not 32 bytes or path is invalid.
|
||||
InvalidStateError: If preconditions (PIN, SC) are not met.
|
||||
APDUError: If the card returns an error (e.g., SW=0x6985).
|
||||
"""
|
||||
if p2 not in (
|
||||
SigningAlgorithm.ECDSA_SECP256K1,
|
||||
SigningAlgorithm.SCHNORR_BIP340
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"Signature algorithm {p2} not supported"
|
||||
)
|
||||
|
||||
if len(digest) != 32:
|
||||
raise ValueError("Digest must be exactly 32 bytes")
|
||||
|
||||
if p1 != DerivationOption.PINLESS and not card.is_pin_verified:
|
||||
raise InvalidStateError(
|
||||
"PIN must be verified to sign with this derivation option")
|
||||
|
||||
data = digest
|
||||
source = DerivationSource.MASTER
|
||||
if p1 in (
|
||||
DerivationOption.DERIVE,
|
||||
DerivationOption.DERIVE_AND_MAKE_CURRENT
|
||||
):
|
||||
if not derivation_path:
|
||||
raise ValueError("Derivation path cannot be empty")
|
||||
key_path = KeyPath(derivation_path)
|
||||
data += key_path.data
|
||||
source = key_path.source
|
||||
|
||||
response = card.send_secure_apdu(
|
||||
ins=constants.INS_SIGN,
|
||||
p1=p1 | source,
|
||||
p2=p2,
|
||||
data=data
|
||||
)
|
||||
|
||||
if response.startswith(b'\xA0'):
|
||||
outer = tlv.parse_tlv(response)
|
||||
inner = tlv.parse_tlv(outer[0xA0][0])
|
||||
pub = inner.get(0x80, [None])[0]
|
||||
|
||||
if len(inner.get(0x80, [])) > 1:
|
||||
return SignatureResult(
|
||||
algo=p2,
|
||||
digest=digest,
|
||||
r=int.from_bytes(inner[0x80][1][:32], "big"),
|
||||
s=int.from_bytes(inner[0x80][1][32:64], "big"),
|
||||
recovery_id=-1,
|
||||
public_key=pub
|
||||
)
|
||||
else:
|
||||
der_bytes = (
|
||||
b'\x30' +
|
||||
len(inner[0x30][0]).to_bytes(1, 'big') +
|
||||
inner[0x30][0]
|
||||
)
|
||||
signature = sigdecode_der(der_bytes, 0)
|
||||
r, s = signature
|
||||
return SignatureResult(
|
||||
algo=p2,
|
||||
digest=digest,
|
||||
r=r,
|
||||
s=s,
|
||||
public_key=pub
|
||||
)
|
||||
elif response.startswith(b'\x80'):
|
||||
outer = tlv.parse_tlv(response)
|
||||
raw = outer[0x80][0]
|
||||
if len(raw) != 65:
|
||||
raise ValueError("Expected 65-byte raw signature (r||s||recId)")
|
||||
return SignatureResult(
|
||||
algo=p2,
|
||||
digest=digest,
|
||||
r=int.from_bytes(raw[:32], "big"),
|
||||
s=int.from_bytes(raw[32:64], "big"),
|
||||
recovery_id=int(raw[64])
|
||||
)
|
||||
|
||||
raise ValueError("Unexpected SIGN response format")
|
||||
@ -1,30 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def store_data(
|
||||
card: CardInterface,
|
||||
data: bytes,
|
||||
slot: constants.StorageSlot = constants.StorageSlot.PUBLIC
|
||||
) -> None:
|
||||
"""
|
||||
Stores data on the card in the specified slot.
|
||||
|
||||
Args:
|
||||
card: The card session object.
|
||||
data (bytes): The data to store (max 127 bytes).
|
||||
slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH)
|
||||
|
||||
Raises:
|
||||
ValueError: If slot is invalid or data is too long.
|
||||
"""
|
||||
if len(data) > 127:
|
||||
raise ValueError("Data too long. Maximum allowed is 127 bytes.")
|
||||
|
||||
card.send_secure_apdu(
|
||||
ins=constants.INS_STORE_DATA,
|
||||
p1=slot.value,
|
||||
data=data
|
||||
)
|
||||
@ -1,32 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_secure_channel
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def unblock_pin(card: CardInterface, puk_and_pin: bytes | str) -> None:
|
||||
"""
|
||||
Unblocks the user PIN using the provided PUK and sets a new PIN.
|
||||
|
||||
Args:
|
||||
card: The card session object.
|
||||
puk_and_pin (bytes | str): Concatenation of PUK (12 digits) + new PIN
|
||||
(6 digits)
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is invalid.
|
||||
APDUError: If the card returns an error.
|
||||
"""
|
||||
if isinstance(puk_and_pin, str):
|
||||
if not puk_and_pin.isdigit():
|
||||
raise ValueError("PUK and PIN must be numeric digits.")
|
||||
puk_and_pin = puk_and_pin.encode("utf-8")
|
||||
|
||||
if len(puk_and_pin) != 18:
|
||||
raise ValueError(
|
||||
"Data must be exactly 18 digits (12-digit PUK + 6-digit PIN).")
|
||||
|
||||
card.send_secure_apdu(
|
||||
ins=constants.INS_UNBLOCK_PIN,
|
||||
data=puk_and_pin
|
||||
)
|
||||
@ -1,31 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..preconditions import require_pin_verified
|
||||
|
||||
|
||||
@require_pin_verified
|
||||
def unpair(card: CardInterface, index: int) -> None:
|
||||
'''
|
||||
Sends the UNPAIR command to remove a pairing index from the card.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened
|
||||
- PIN must be verified
|
||||
|
||||
This function securely communicates with the card using the established
|
||||
session to instruct it to forget a specific pairing index.
|
||||
|
||||
Args:
|
||||
transport: The transport interface used to send APDUs.
|
||||
secure_session: The active SecureChannel object used to wrap APDUs.
|
||||
index (int): The pairing index (0–15) to unpair from the card.
|
||||
|
||||
Raises:
|
||||
ValueError: If transport or secure_session is not provided, or if
|
||||
the session is not authenticated.
|
||||
APDUError: If the response status word indicates an error.
|
||||
'''
|
||||
card.send_secure_apdu(
|
||||
ins=constants.INS_UNPAIR,
|
||||
p1=index,
|
||||
)
|
||||
@ -1,49 +0,0 @@
|
||||
from .. import constants
|
||||
from ..card_interface import CardInterface
|
||||
from ..exceptions import APDUError
|
||||
from ..preconditions import require_secure_channel
|
||||
|
||||
|
||||
@require_secure_channel
|
||||
def verify_pin(card: CardInterface, pin: str | bytes) -> bool:
|
||||
'''
|
||||
Verifies the user PIN with the card using a secure session.
|
||||
|
||||
Preconditions:
|
||||
- Secure Channel must be opened
|
||||
- PIN must be verified
|
||||
|
||||
Sends the VERIFY PIN APDU command through the secure session. Returns
|
||||
True if the PIN is correct, False if incorrect with remaining attempts,
|
||||
and raises an error if blocked or another APDU error occurs.
|
||||
|
||||
Args:
|
||||
transport: The transport instance used to send the command.
|
||||
session: An established SecureChannel object.
|
||||
pin (str): The PIN string to be verified.
|
||||
|
||||
Returns:
|
||||
bool: True if the PIN is correct, False if incorrect but still allowed.
|
||||
|
||||
Raises:
|
||||
ValueError: If no secure session is provided.
|
||||
RuntimeError: If the PIN is blocked (no attempts remaining).
|
||||
APDUError: For other status word errors returned by the card.
|
||||
'''
|
||||
if not isinstance(pin, bytes):
|
||||
pin = pin.encode('ascii')
|
||||
|
||||
try:
|
||||
card.send_secure_apdu(
|
||||
ins=constants.INS_VERIFY_PIN,
|
||||
data=pin
|
||||
)
|
||||
except APDUError as e:
|
||||
if (e.sw & 0xFFF0) == 0x63C0:
|
||||
attempts = e.sw & 0x000F
|
||||
if attempts == 0:
|
||||
raise RuntimeError('PIN is blocked')
|
||||
return False
|
||||
raise e
|
||||
|
||||
return True
|
||||
@ -1,90 +0,0 @@
|
||||
"""
|
||||
This module defines constants used for communication with the Keycard applet
|
||||
via APDU commands.
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
# Applet AID
|
||||
KEYCARD_AID: bytes = bytes.fromhex('A000000804000101')
|
||||
|
||||
CLAISO7816: int = 0x00
|
||||
CLA_PROPRIETARY: int = 0x80
|
||||
|
||||
# APDU instructions
|
||||
INS_SELECT: int = 0xA4
|
||||
INS_INIT: int = 0xFE
|
||||
INS_IDENT: int = 0x14
|
||||
INS_OPEN_SECURE_CHANNEL: int = 0x10
|
||||
INS_MUTUALLY_AUTHENTICATE: int = 0x11
|
||||
INS_PAIR: int = 0x12
|
||||
INS_UNPAIR: int = 0x13
|
||||
INS_VERIFY_PIN: int = 0x20
|
||||
INS_GET_STATUS: int = 0xF2
|
||||
INS_FACTORY_RESET: int = 0xFD
|
||||
INS_GENERATE_KEY: int = 0xD4
|
||||
INS_CHANGE_SECRET: int = 0x21
|
||||
INS_UNBLOCK_PIN: int = 0x22
|
||||
INS_STORE_DATA: int = 0xE2
|
||||
INS_GET_DATA: int = 0xCA
|
||||
INS_SIGN: int = 0xC0
|
||||
INS_SET_PINLESS_PATH = 0xC1
|
||||
INS_EXPORT_KEY: int = 0xC2
|
||||
INS_LOAD_KEY: int = 0xD0
|
||||
INS_DERIVE_KEY = 0xD1
|
||||
INS_GENERATE_MNEMONIC = 0xD2
|
||||
INS_EXPORT_LEE_KEY = 0xC3
|
||||
|
||||
# Status words
|
||||
SW_SUCCESS: int = 0x9000
|
||||
|
||||
|
||||
class PinType(IntEnum):
|
||||
USER = 0x00
|
||||
PUK = 0x01
|
||||
PAIRING = 0x02
|
||||
|
||||
|
||||
class StorageSlot(IntEnum):
|
||||
PUBLIC = 0x00
|
||||
NDEF = 0x01
|
||||
CASH = 0x02
|
||||
|
||||
|
||||
class DerivationOption(IntEnum):
|
||||
CURRENT = 0x00
|
||||
DERIVE = 0x01
|
||||
DERIVE_AND_MAKE_CURRENT = 0x02
|
||||
PINLESS = 0x03
|
||||
|
||||
|
||||
class KeyExportOption(IntEnum):
|
||||
PRIVATE_AND_PUBLIC = 0x00
|
||||
PUBLIC_ONLY = 0x01
|
||||
EXTENDED_PUBLIC = 0x02
|
||||
|
||||
|
||||
class DerivationSource(IntEnum):
|
||||
MASTER = 0x00
|
||||
PARENT = 0x40
|
||||
CURRENT = 0x80
|
||||
|
||||
|
||||
class SigningAlgorithm(IntEnum):
|
||||
ECDSA_SECP256K1 = 0x00
|
||||
EDDSA_ED25519 = 0x01
|
||||
BLS12_381 = 0x02
|
||||
SCHNORR_BIP340 = 0x03
|
||||
|
||||
|
||||
class LoadKeyType(IntEnum):
|
||||
ECC = 0x01
|
||||
EXTENDED_ECC = 0x02
|
||||
BIP39_SEED = 0x03
|
||||
|
||||
|
||||
class PairingMode(IntEnum):
|
||||
ANY = 0x00
|
||||
EPHEMERAL = 0x01
|
||||
PERSISTENT = 0x02
|
||||
@ -1,32 +0,0 @@
|
||||
from .padding import iso7816_pad, iso7816_unpad
|
||||
|
||||
import pyaes
|
||||
|
||||
|
||||
def aes_cbc_encrypt(
|
||||
key: bytes,
|
||||
iv: bytes,
|
||||
data: bytes,
|
||||
padding: bool = True
|
||||
) -> bytes:
|
||||
if padding:
|
||||
data = iso7816_pad(data, 16)
|
||||
aes = pyaes.AESModeOfOperationCBC(key, iv=iv)
|
||||
|
||||
ciphertext = b''
|
||||
for i in range(0, len(data), 16):
|
||||
block = data[i:i+16]
|
||||
ciphertext += aes.encrypt(block)
|
||||
|
||||
return ciphertext
|
||||
|
||||
|
||||
def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
|
||||
aes = pyaes.AESModeOfOperationCBC(key, iv=iv)
|
||||
|
||||
decrypted = b''
|
||||
for i in range(0, len(ciphertext), 16):
|
||||
block = ciphertext[i:i+16]
|
||||
decrypted += aes.decrypt(block)
|
||||
|
||||
return iso7816_unpad(decrypted)
|
||||
@ -1,15 +0,0 @@
|
||||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
|
||||
|
||||
SALT = 'Keycard Pairing Password Salt'
|
||||
NUMBER_OF_ITERATIONS = 50000
|
||||
DKLEN = 32
|
||||
|
||||
|
||||
def generate_pairing_token(passphrase: str) -> bytes:
|
||||
norm_pass = unicodedata.normalize('NFKD', passphrase).encode('utf-8')
|
||||
salt = unicodedata.normalize('NFKD', SALT).encode('utf-8')
|
||||
return hashlib.pbkdf2_hmac(
|
||||
'sha256', norm_pass, salt, NUMBER_OF_ITERATIONS, dklen=DKLEN)
|
||||
@ -1,9 +0,0 @@
|
||||
def iso7816_pad(data: bytes, block_size: int) -> bytes:
|
||||
pad_len = block_size - (len(data) % block_size)
|
||||
return data + b'\x80' + b'\x00' * (pad_len - 1)
|
||||
|
||||
|
||||
def iso7816_unpad(padded: bytes) -> bytes:
|
||||
if b'\x80' not in padded:
|
||||
raise ValueError("Invalid ISO7816 padding")
|
||||
return padded[:padded.rindex(b'\x80')]
|
||||
@ -1,41 +0,0 @@
|
||||
class KeyCardError(Exception):
|
||||
"""Base exception for Keycard SDK"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APDUError(KeyCardError):
|
||||
"""Raised when APDU returns non-success status word."""
|
||||
|
||||
def __init__(self, sw: int):
|
||||
self.sw = sw
|
||||
super().__init__(f"APDU failed with SW={sw:04X}")
|
||||
|
||||
|
||||
class InvalidResponseError(KeyCardError):
|
||||
"""Raised when response parsing fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotInitializedError(KeyCardError):
|
||||
"""Raised when trying to use card public key before select()."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotSelectedError(KeyCardError):
|
||||
"""Raised when trying to use card before select()."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TransportError(KeyCardError):
|
||||
"""Raised there are no readers"""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStateError(KeyCardError):
|
||||
"""Raised when a precondition is not met."""
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
@ -1,630 +0,0 @@
|
||||
from types import TracebackType
|
||||
from typing import Optional, Union
|
||||
|
||||
from . import constants
|
||||
from . import commands
|
||||
from .apdu import APDUResponse
|
||||
from .constants import DerivationOption, PairingMode
|
||||
from .card_interface import CardInterface
|
||||
from .exceptions import APDUError
|
||||
from .parsing.application_info import ApplicationInfo
|
||||
from .parsing.exported_key import ExportedKey
|
||||
from .parsing.signature_result import SignatureResult
|
||||
from .transport import Transport
|
||||
from .secure_channel import SecureChannel
|
||||
|
||||
|
||||
class KeyCard(CardInterface):
|
||||
'''
|
||||
High-level interface for interacting with a Keycard device.
|
||||
|
||||
This class provides convenient methods to manage pairing, secure channels,
|
||||
and card operations.
|
||||
'''
|
||||
|
||||
def __init__(self, transport: Optional[Transport] = None):
|
||||
'''
|
||||
Initializes the KeyCard interface.
|
||||
|
||||
Args:
|
||||
transport (Transport): Instance used for APDU communication.
|
||||
|
||||
Raises:
|
||||
ValueError: If transport is None.
|
||||
'''
|
||||
self.transport = transport if transport else Transport()
|
||||
self.card_public_key: Optional[bytes] = None
|
||||
self.session: Optional[SecureChannel] = None
|
||||
self._is_pin_verified: bool = False
|
||||
|
||||
def __enter__(self) -> 'KeyCard':
|
||||
self.transport.connect()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
type_: type[BaseException] | None,
|
||||
value: BaseException | None,
|
||||
traceback: TracebackType | None
|
||||
) -> None:
|
||||
if self.transport:
|
||||
self.transport.disconnect()
|
||||
|
||||
@property
|
||||
def is_selected(self) -> bool:
|
||||
'''
|
||||
Checks if a card is selected and has a public key.
|
||||
|
||||
Returns:
|
||||
bool: True if a card is selected, False otherwise.
|
||||
'''
|
||||
return self.card_public_key is not None
|
||||
|
||||
@property
|
||||
def is_session_open(self) -> bool:
|
||||
'''
|
||||
Checks if a secure session is currently open.
|
||||
|
||||
Returns:
|
||||
bool: True if a secure session is established, False otherwise.
|
||||
'''
|
||||
return self.session is not None
|
||||
|
||||
@property
|
||||
def is_secure_channel_open(self) -> bool:
|
||||
'''
|
||||
Checks if a secure channel is currently open.
|
||||
|
||||
Returns:
|
||||
bool: True if a secure channel is established, False otherwise.
|
||||
'''
|
||||
return self.session is not None and self.session.authenticated
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
'''
|
||||
Checks if the Keycard is initialized.
|
||||
|
||||
Returns:
|
||||
bool: True if the Keycard is initialized, False otherwise.
|
||||
'''
|
||||
return self._is_initialized
|
||||
|
||||
@property
|
||||
def is_pin_verified(self) -> bool:
|
||||
'''
|
||||
Checks if the user PIN has been verified.
|
||||
|
||||
Returns:
|
||||
bool: True if the PIN is verified, False otherwise.
|
||||
'''
|
||||
return self._is_pin_verified
|
||||
|
||||
def select(self) -> 'ApplicationInfo':
|
||||
'''
|
||||
Selects the Keycard applet and retrieves application metadata.
|
||||
|
||||
Returns:
|
||||
ApplicationInfo: Object containing ECC public key and card info.
|
||||
'''
|
||||
info = commands.select(self)
|
||||
self.card_public_key = info.ecc_public_key
|
||||
self._is_initialized = info.is_initialized
|
||||
return info
|
||||
|
||||
def init(self, pin: str, puk: str, pairing_secret: str) -> None:
|
||||
'''
|
||||
Initializes the card with security credentials.
|
||||
|
||||
Args:
|
||||
pin (bytes): The PIN code in bytes.
|
||||
puk (bytes): The PUK code in bytes.
|
||||
pairing_secret (bytes): The shared secret for pairing.
|
||||
'''
|
||||
commands.init(
|
||||
self,
|
||||
pin,
|
||||
puk,
|
||||
pairing_secret,
|
||||
)
|
||||
|
||||
def ident(self, challenge: Optional[bytes] = None) -> bytes:
|
||||
'''
|
||||
Sends an identity challenge to the card.
|
||||
|
||||
Args:
|
||||
challenge (bytes): A challenge (nonce or data) to send to the
|
||||
card. If None, a random 32-byte challenge is generated.
|
||||
|
||||
Returns:
|
||||
bytes: The public key extracted from the card's identity response.
|
||||
'''
|
||||
return commands.ident(self, challenge)
|
||||
|
||||
def open_secure_channel(
|
||||
self,
|
||||
pairing_index: int,
|
||||
pairing_key: bytes,
|
||||
mutually_authenticate: Optional[bool] = True
|
||||
) -> None:
|
||||
'''
|
||||
Opens a secure session with the card.
|
||||
|
||||
Args:
|
||||
pairing_index (int): Index of the pairing slot to use.
|
||||
pairing_key (bytes): The shared pairing key (32 bytes).
|
||||
mutually_authenticate (bool): Execute mutually authenticate when
|
||||
a secure channel has been opened
|
||||
'''
|
||||
self.session = commands.open_secure_channel(
|
||||
self,
|
||||
pairing_index,
|
||||
pairing_key,
|
||||
)
|
||||
|
||||
if mutually_authenticate:
|
||||
self.mutually_authenticate()
|
||||
|
||||
def mutually_authenticate(self) -> None:
|
||||
'''
|
||||
Performs mutual authentication between host and card.
|
||||
|
||||
Raises:
|
||||
APDUError: If the authentication fails.
|
||||
'''
|
||||
commands.mutually_authenticate(self)
|
||||
|
||||
def pair(
|
||||
self,
|
||||
shared_secret: bytes,
|
||||
pairing_mode: Optional[PairingMode] = PairingMode.ANY
|
||||
) -> tuple[int, bytes]:
|
||||
'''
|
||||
Pairs with the card using an ECDH-derived shared secret.
|
||||
|
||||
Args:
|
||||
shared_secret (bytes): 32-byte ECDH shared secret.
|
||||
|
||||
Returns:
|
||||
tuple[int, bytes]: The pairing index and client cryptogram.
|
||||
'''
|
||||
return commands.pair(self, shared_secret, pairing_mode)
|
||||
|
||||
def verify_pin(self, pin: str) -> bool:
|
||||
'''
|
||||
Verifies the user PIN with the card.
|
||||
|
||||
Args:
|
||||
pin (str): The user-entered PIN.
|
||||
|
||||
Returns:
|
||||
bool: True if PIN is valid, otherwise False.
|
||||
'''
|
||||
result = commands.verify_pin(self, pin.encode('utf-8'))
|
||||
self._is_pin_verified = True
|
||||
return result
|
||||
|
||||
@property
|
||||
def status(self) -> dict[str, int | bool] | list[int]:
|
||||
'''
|
||||
Retrieves the application status using the secure session.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with:
|
||||
- pin_retry_count (int)
|
||||
- puk_retry_count (int)
|
||||
- initialized (bool)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the secure session is not open.
|
||||
'''
|
||||
if self.session is None:
|
||||
raise RuntimeError('Secure session not established')
|
||||
|
||||
return commands.get_status(self)
|
||||
|
||||
@property
|
||||
def get_key_path(self) -> dict[str, int | bool] | list[int]:
|
||||
'''
|
||||
Returns the current key derivation path from the card.
|
||||
|
||||
Returns:
|
||||
list of int: List of 32-bit integers representing the key path.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the secure session is not open.
|
||||
'''
|
||||
if self.session is None:
|
||||
raise RuntimeError('Secure session not established')
|
||||
|
||||
return commands.get_status(self, key_path=True)
|
||||
|
||||
def unpair(self, index: int) -> None:
|
||||
'''
|
||||
Removes a pairing slot from the card.
|
||||
|
||||
Args:
|
||||
index (int): Index of the pairing slot to remove.
|
||||
'''
|
||||
commands.unpair(self, index)
|
||||
|
||||
def factory_reset(self) -> None:
|
||||
'''
|
||||
Sends the FACTORY_RESET command to the card.
|
||||
|
||||
Raises:
|
||||
APDUError: If the card returns a failure status word.
|
||||
'''
|
||||
commands.factory_reset(self)
|
||||
|
||||
def generate_key(self) -> bytes:
|
||||
'''
|
||||
Generates a new key on the card and returns the key UID.
|
||||
|
||||
Returns:
|
||||
bytes: Key UID (SHA-256 of the public key)
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not 0x9000
|
||||
'''
|
||||
return commands.generate_key(self)
|
||||
|
||||
def change_pin(self, new_value: str) -> None:
|
||||
'''
|
||||
Changes the user PIN on the card.
|
||||
|
||||
Args:
|
||||
new_value (str): The new PIN value to set.
|
||||
|
||||
Raises:
|
||||
ValueError: If input format is invalid.
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
'''
|
||||
commands.change_secret(self, new_value, constants.PinType.USER)
|
||||
|
||||
def change_puk(self, new_value: str) -> None:
|
||||
'''
|
||||
Changes the PUK on the card.
|
||||
|
||||
Args:
|
||||
new_value (str): The new PUK value to set.
|
||||
|
||||
Raises:
|
||||
ValueError: If input format is invalid.
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
'''
|
||||
commands.change_secret(self, new_value, constants.PinType.PUK)
|
||||
|
||||
def change_pairing_secret(self, new_value: str | bytes) -> None:
|
||||
'''
|
||||
Changes the pairing secret on the card.
|
||||
|
||||
Args:
|
||||
new_value (str): The new pairing secret value to set.
|
||||
|
||||
Raises:
|
||||
ValueError: If input format is invalid.
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
'''
|
||||
commands.change_secret(self, new_value, constants.PinType.PAIRING)
|
||||
|
||||
def unblock_pin(self, puk: str | bytes, new_pin: str | bytes) -> None:
|
||||
'''
|
||||
Unblocks the user PIN using the provided PUK and sets a new PIN.
|
||||
|
||||
Args:
|
||||
puk_and_pin (str | bytes): Concatenation of PUK (12 digits) +
|
||||
new PIN (6 digits)
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is invalid.
|
||||
APDUError: If the card returns an error.
|
||||
'''
|
||||
if isinstance(puk, str):
|
||||
puk = puk.encode("utf-8")
|
||||
if isinstance(new_pin, str):
|
||||
new_pin = new_pin.encode("utf-8")
|
||||
|
||||
commands.unblock_pin(self, puk + new_pin)
|
||||
|
||||
def remove_key(self) -> None:
|
||||
'''
|
||||
Removes the current key from the card.
|
||||
|
||||
Raises:
|
||||
APDUError: If the response status word is not 0x9000.
|
||||
'''
|
||||
commands.remove_key(self)
|
||||
|
||||
def store_data(
|
||||
self,
|
||||
data: bytes,
|
||||
slot: constants.StorageSlot = constants.StorageSlot.PUBLIC
|
||||
) -> None:
|
||||
"""
|
||||
Stores data on the card in the specified slot.
|
||||
|
||||
Args:
|
||||
data (bytes): The data to store (max 127 bytes).
|
||||
slot (StorageSlot): Where to store the data (PUBLIC, NDEF, CASH)
|
||||
|
||||
Raises:
|
||||
ValueError: If slot is invalid or data is too long.
|
||||
"""
|
||||
commands.store_data(self, data, slot)
|
||||
|
||||
def get_data(
|
||||
self,
|
||||
slot: constants.StorageSlot = constants.StorageSlot.PUBLIC
|
||||
) -> bytes:
|
||||
"""
|
||||
Gets the data on the card previously stored with the store data command
|
||||
in the specified slot.
|
||||
|
||||
Args:
|
||||
slot (StorageSlot): Where to retrieve the data (PUBLIC, NDEF, CASH)
|
||||
|
||||
Raises:
|
||||
ValueError: If slot is invalid or data is too long.
|
||||
"""
|
||||
return commands.get_data(self, slot)
|
||||
|
||||
def export_key(
|
||||
self,
|
||||
derivation_option: constants.DerivationOption,
|
||||
public_only: bool,
|
||||
keypath: Optional[Union[str, bytes, bytearray]] = None,
|
||||
make_current: bool = False,
|
||||
source: constants.DerivationSource = constants.DerivationSource.MASTER
|
||||
) -> ExportedKey:
|
||||
"""
|
||||
Export a key from the card.
|
||||
|
||||
This is a proxy for :func:`keycard.commands.export_key`, provided here
|
||||
for convenience.
|
||||
|
||||
Args:
|
||||
derivation_option: One of the derivation options
|
||||
(CURRENT, DERIVE, DERIVE_AND_MAKE_CURRENT).
|
||||
public_only: If True, only the public key will be returned.
|
||||
keypath: BIP32-style string (e.g. "m/44'/60'/0'/0/0") or packed
|
||||
bytes. If derivation_option is CURRENT, this can be omitted.
|
||||
make_current: If True, updates the card’s current derivation path.
|
||||
source: Which node to derive from: MASTER, PARENT, or CURRENT.
|
||||
|
||||
Returns:
|
||||
ExportedKey: An object containing the public key, and optionally
|
||||
the private key and chain code.
|
||||
|
||||
See Also:
|
||||
- :func:`keycard.commands.export_key` - for the lower-level
|
||||
implementation
|
||||
- :class:`keycard.types.ExportedKey` - return value
|
||||
structure
|
||||
"""
|
||||
return commands.export_key(
|
||||
self,
|
||||
derivation_option=derivation_option,
|
||||
public_only=public_only,
|
||||
keypath=keypath,
|
||||
make_current=make_current,
|
||||
source=source
|
||||
)
|
||||
|
||||
def export_current_key(self, public_only: bool = False) -> ExportedKey:
|
||||
"""
|
||||
Exports the current key from the card.
|
||||
|
||||
This is a convenience method that uses the CURRENT derivation option
|
||||
and does not require a keypath.
|
||||
|
||||
Args:
|
||||
public_only (bool): If True, only the public key will be returned.
|
||||
|
||||
Returns:
|
||||
ExportedKey: An object containing the public key, and optionally
|
||||
the private key and chain code.
|
||||
"""
|
||||
return self.export_key(
|
||||
derivation_option=constants.DerivationOption.CURRENT,
|
||||
public_only=public_only
|
||||
)
|
||||
|
||||
def sign(
|
||||
self,
|
||||
digest: bytes,
|
||||
algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1,
|
||||
) -> SignatureResult:
|
||||
"""
|
||||
Sign using the currently loaded keypair.
|
||||
Requires PIN verification and secure channel.
|
||||
|
||||
Args:
|
||||
digest (bytes): 32-byte hash to sign
|
||||
algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to
|
||||
ECDSA_SECP256K1. Other options include SCHNORR_BIP340.
|
||||
|
||||
Returns:
|
||||
SignatureResult: Parsed signature result, including the signature
|
||||
(DER or raw), algorithm, and optional recovery ID or
|
||||
public key.
|
||||
"""
|
||||
return commands.sign(self, digest, DerivationOption.CURRENT, algorithm)
|
||||
|
||||
def sign_with_path(
|
||||
self,
|
||||
digest: bytes,
|
||||
path: str,
|
||||
make_current: bool = False,
|
||||
algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1,
|
||||
) -> SignatureResult:
|
||||
"""
|
||||
Sign using a derived keypath. Optionally updates the current path.
|
||||
|
||||
Args:
|
||||
digest (bytes): 32-byte hash to sign
|
||||
path (str): BIP-32-style path (e.g. "m/44'/60'/0'/0/0")
|
||||
make_current (bool): whether to update current path on card
|
||||
algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to
|
||||
ECDSA_SECP256K1. Other options include SCHNORR_BIP340.
|
||||
|
||||
Returns:
|
||||
SignatureResult: Parsed signature result, including the signature
|
||||
(DER or raw), algorithm, and optional recovery ID or
|
||||
public key.
|
||||
"""
|
||||
p1 = (
|
||||
DerivationOption.DERIVE_AND_MAKE_CURRENT
|
||||
if make_current else DerivationOption.DERIVE
|
||||
)
|
||||
return commands.sign(self, digest, p1, algorithm, derivation_path=path)
|
||||
|
||||
def sign_pinless(
|
||||
self,
|
||||
digest: bytes,
|
||||
algorithm: constants.SigningAlgorithm = constants.SigningAlgorithm.ECDSA_SECP256K1,
|
||||
) -> SignatureResult:
|
||||
"""
|
||||
Sign using the predefined PIN-less path.
|
||||
Does not require secure channel or PIN.
|
||||
|
||||
Args:
|
||||
digest (bytes): 32-byte hash to sign
|
||||
algorithm (SigningAlgorithm): Signing algorithm to use. Defaults to
|
||||
ECDSA_SECP256K1. Other options include SCHNORR_BIP340.
|
||||
|
||||
Returns:
|
||||
SignatureResult: Parsed signature result, including the signature
|
||||
(DER or raw), algorithm, and optional recovery ID or
|
||||
public key.
|
||||
|
||||
Raises:
|
||||
APDUError: if no PIN-less path is set
|
||||
"""
|
||||
return commands.sign(self, digest, DerivationOption.PINLESS, algorithm)
|
||||
|
||||
def load_key(
|
||||
self,
|
||||
key_type: constants.LoadKeyType,
|
||||
public_key: Optional[bytes] = None,
|
||||
private_key: Optional[bytes] = None,
|
||||
chain_code: Optional[bytes] = None,
|
||||
bip39_seed: Optional[bytes] = None,
|
||||
lee_seed: Optional[bytes] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Load a key into the card for signing purposes.
|
||||
|
||||
Args:
|
||||
key_type: Key type
|
||||
public_key: Optional ECC public key (tag 0x80).
|
||||
private_key: ECC private key (tag 0x81).
|
||||
chain_code: Optional chain code (tag 0x82, only for extended key).
|
||||
bip39_seed: 16 to 64-byte BIP39 seed (only for key_type=BIP39_SEED).
|
||||
lee_seed: 16 to 64-byte LEE seed (only for key_type=BIP39_SEED).
|
||||
|
||||
Returns:
|
||||
UID of the loaded key (SHA-256 of public key).
|
||||
"""
|
||||
return commands.load_key(
|
||||
self,
|
||||
key_type=key_type,
|
||||
public_key=public_key,
|
||||
private_key=private_key,
|
||||
chain_code=chain_code,
|
||||
bip39_seed=bip39_seed,
|
||||
lee_seed=lee_seed
|
||||
)
|
||||
|
||||
def set_pinless_path(self, path: str) -> None:
|
||||
"""
|
||||
Set a PIN-less path on the card. Allows signing without PIN/auth if the
|
||||
current derived key matches this path.
|
||||
|
||||
Args:
|
||||
path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0"). An empty
|
||||
string disables the pinless path.
|
||||
"""
|
||||
commands.set_pinless_path(self, path)
|
||||
|
||||
def generate_mnemonic(self, checksum_size: int = 6) -> list[int]:
|
||||
"""
|
||||
Generate a BIP39 mnemonic using the card's RNG.
|
||||
|
||||
Args:
|
||||
checksum_size (int): Number of checksum bits
|
||||
(between 4 and 8 inclusive).
|
||||
|
||||
Returns:
|
||||
List[int]: List of integers (0-2047) corresponding to wordlist
|
||||
indexes.
|
||||
"""
|
||||
return commands.generate_mnemonic(self, checksum_size)
|
||||
|
||||
def derive_key(self, path: str = '') -> None:
|
||||
"""
|
||||
Set the derivation path for subsequent SIGN and EXPORT KEY commands.
|
||||
|
||||
Args:
|
||||
path (str): BIP-32-style path (e.g., "m/44'/60'/0'/0/0") or
|
||||
"../0/1" (parent) or "./0" (current).
|
||||
"""
|
||||
commands.derive_key(self, path)
|
||||
|
||||
def send_apdu(
|
||||
self,
|
||||
ins: int,
|
||||
p1: int = 0x00,
|
||||
p2: int = 0x00,
|
||||
data: bytes = b'',
|
||||
cla: Optional[int] = None
|
||||
) -> bytes:
|
||||
if cla is None:
|
||||
cla = constants.CLA_PROPRIETARY
|
||||
|
||||
response: APDUResponse = self.transport.send_apdu(
|
||||
bytes([cla, ins, p1, p2, len(data)]) + data
|
||||
)
|
||||
|
||||
if response.status_word != constants.SW_SUCCESS:
|
||||
raise APDUError(response.status_word)
|
||||
|
||||
return bytes(response.data)
|
||||
|
||||
def send_secure_apdu(
|
||||
self,
|
||||
ins: int,
|
||||
p1: int = 0x00,
|
||||
p2: int = 0x00,
|
||||
data: bytes = b''
|
||||
) -> bytes:
|
||||
if not self.session or not self.session.authenticated:
|
||||
raise RuntimeError('Secure channel not established')
|
||||
|
||||
encrypted = self.session.wrap_apdu(
|
||||
cla=constants.CLA_PROPRIETARY,
|
||||
ins=ins,
|
||||
p1=p1,
|
||||
p2=p2,
|
||||
data=data
|
||||
)
|
||||
|
||||
response: APDUResponse = self.transport.send_apdu(
|
||||
bytes([
|
||||
constants.CLA_PROPRIETARY,
|
||||
ins,
|
||||
p1,
|
||||
p2,
|
||||
len(encrypted)
|
||||
]) + encrypted
|
||||
)
|
||||
|
||||
if response.status_word != 0x9000:
|
||||
raise APDUError(response.status_word)
|
||||
|
||||
plaintext, sw = self.session.unwrap_response(response)
|
||||
|
||||
if sw != 0x9000:
|
||||
raise APDUError(sw)
|
||||
|
||||
return plaintext
|
||||
@ -1,119 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from ..exceptions import InvalidResponseError
|
||||
from .capabilities import Capabilities
|
||||
from .tlv import parse_tlv
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplicationInfo:
|
||||
"""
|
||||
Represents parsed application information from a TLV-encoded response.
|
||||
|
||||
Attributes:
|
||||
capabilities (Optional[int]): Parsed capabilities value, if present.
|
||||
ecc_public_key (Optional[bytes]): ECC public key bytes, if present.
|
||||
instance_uid (Optional[bytes]): Unique identifier for the application
|
||||
instance, if present.
|
||||
key_uid (Optional[bytes]): Unique identifier for the key, if present.
|
||||
version_major (int): Major version number of the application.
|
||||
version_minor (int): Minor version number of the application.
|
||||
"""
|
||||
capabilities: Optional[int]
|
||||
ecc_public_key: Optional[bytes]
|
||||
instance_uid: Optional[bytes]
|
||||
key_uid: Optional[bytes]
|
||||
version_major: int
|
||||
version_minor: int
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""
|
||||
Checks if the application is initialized based on the presence of
|
||||
the key_uid.
|
||||
|
||||
Returns:
|
||||
bool: True if the key_uid is present, False otherwise.
|
||||
"""
|
||||
return self.key_uid is not None
|
||||
|
||||
@staticmethod
|
||||
def parse(data: bytes) -> "ApplicationInfo":
|
||||
"""
|
||||
Parses a byte sequence containing TLV-encoded application information
|
||||
and returns an ApplicationInfo instance.
|
||||
|
||||
Args:
|
||||
data (bytes): The TLV-encoded response data to parse.
|
||||
|
||||
Returns:
|
||||
ApplicationInfo: An instance populated with the parsed application
|
||||
information fields.
|
||||
|
||||
The function extracts the following fields from the TLV data:
|
||||
- version_major (int): Major version number (from tag 0x02).
|
||||
- version_minor (int): Minor version number (from tag 0x02).
|
||||
- instance_uid (bytes or None): Instance UID (from tag 0x8F).
|
||||
- key_uid (bytes or None): Key UID (from tag 0x8E).
|
||||
- ecc_public_key (bytes or None): ECC public key (from tag 0x80).
|
||||
- capabilities (Capabilities or None): Capabilities object
|
||||
(from tag 0x8D).
|
||||
|
||||
Raises:
|
||||
Any exceptions raised by ApplicationInfo._parse_response or
|
||||
Capabilities.parse.
|
||||
"""
|
||||
version_major = version_minor = 0
|
||||
instance_uid = None
|
||||
key_uid = None
|
||||
ecc_public_key = None
|
||||
capabilities = 0
|
||||
|
||||
if data[0] == 0x80:
|
||||
length = data[1]
|
||||
pubkey = data[2:2+length]
|
||||
ecc_public_key = bytes(pubkey)
|
||||
capabilities += Capabilities.CREDENTIALS_MANAGEMENT
|
||||
|
||||
if pubkey:
|
||||
capabilities += Capabilities.SECURE_CHANNEL
|
||||
capabilities = Capabilities.parse(capabilities)
|
||||
else:
|
||||
tlv = parse_tlv(data)
|
||||
if 0xA4 not in tlv:
|
||||
raise InvalidResponseError(
|
||||
"Invalid top-level tag, expected 0xA4")
|
||||
|
||||
inner_tlv = parse_tlv(tlv[0xA4][0])
|
||||
|
||||
instance_uid = bytes(inner_tlv[0x8F][0])
|
||||
ecc_public_key = bytes(inner_tlv[0x80][0])
|
||||
key_uid = inner_tlv[0x8E][0]
|
||||
capabilities = Capabilities.parse(inner_tlv[0x8D][0][0])
|
||||
for value in inner_tlv[0x02]:
|
||||
if len(value) == 2:
|
||||
version_major, version_minor = value[0], value[1]
|
||||
|
||||
return ApplicationInfo(
|
||||
capabilities=capabilities,
|
||||
ecc_public_key=ecc_public_key,
|
||||
instance_uid=instance_uid,
|
||||
key_uid=key_uid,
|
||||
version_major=version_major,
|
||||
version_minor=version_minor,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"ApplicationInfo(version="
|
||||
f"{self.version_major}.{self.version_minor}, "
|
||||
f"instance_uid="
|
||||
f"{self.instance_uid.hex() if self.instance_uid else None}, "
|
||||
f"key_uid="
|
||||
f"{self.key_uid.hex() if self.key_uid else None}, "
|
||||
f"ecc_public_key="
|
||||
f"{self.ecc_public_key.hex() if self.ecc_public_key else None}, "
|
||||
f"capabilities="
|
||||
f"{self.capabilities})"
|
||||
)
|
||||
@ -1,44 +0,0 @@
|
||||
from enum import IntFlag
|
||||
|
||||
|
||||
class Capabilities(IntFlag):
|
||||
"""
|
||||
An enumeration representing the various capabilities supported by a device
|
||||
or application.
|
||||
|
||||
Attributes:
|
||||
SECURE_CHANNEL (int): Indicates support for secure channel
|
||||
communication (0x01).
|
||||
KEY_MANAGEMENT (int): Indicates support for key management operations
|
||||
(0x02).
|
||||
CREDENTIALS_MANAGEMENT (int): Indicates support for credentials
|
||||
management (0x04).
|
||||
NDEF (int): Indicates support for NDEF (NFC Data Exchange Format)
|
||||
operations (0x08).
|
||||
"""
|
||||
SECURE_CHANNEL = 0x01
|
||||
KEY_MANAGEMENT = 0x02
|
||||
CREDENTIALS_MANAGEMENT = 0x04
|
||||
NDEF = 0x08
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: int) -> "Capabilities":
|
||||
"""
|
||||
Parses an integer value and returns a corresponding Capabilities
|
||||
instance.
|
||||
|
||||
Args:
|
||||
value (int): The integer value representing the capabilities.
|
||||
|
||||
Returns:
|
||||
Capabilities: An instance of the Capabilities class corresponding
|
||||
to the given value.
|
||||
"""
|
||||
return cls(value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(
|
||||
name
|
||||
for name, member in self.__class__.__members__.items()
|
||||
if member in self
|
||||
)
|
||||
@ -1,17 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportedKey:
|
||||
public_key: Optional[bytes] = None
|
||||
private_key: Optional[bytes] = None
|
||||
chain_code: Optional[bytes] = None
|
||||
|
||||
@property
|
||||
def is_extended(self) -> bool:
|
||||
return self.chain_code is not None
|
||||
|
||||
@property
|
||||
def has_private(self) -> bool:
|
||||
return self.private_key is not None
|
||||
@ -1,43 +0,0 @@
|
||||
from hashlib import sha256
|
||||
from ecdsa import VerifyingKey, SECP256k1, util
|
||||
|
||||
from ..exceptions import InvalidResponseError
|
||||
from ..parsing.tlv import parse_tlv
|
||||
|
||||
|
||||
def parse(challenge: bytes, data: bytes) -> bytes:
|
||||
tlvs = parse_tlv(data)
|
||||
|
||||
inner_tlvs = parse_tlv(tlvs[0xA0][0])
|
||||
|
||||
try:
|
||||
certificate = inner_tlvs[0x8A][0]
|
||||
signature = inner_tlvs[0x30][0]
|
||||
except (IndexError, KeyError):
|
||||
raise InvalidResponseError('Malformed identity response')
|
||||
|
||||
signature = b'\x30' + len(signature).to_bytes(1, 'big') + signature
|
||||
if len(certificate) < 95 or len(signature) < 65:
|
||||
raise InvalidResponseError('Malformed identity response')
|
||||
|
||||
_verify(certificate, signature, challenge)
|
||||
return _recover_public_key(certificate)
|
||||
|
||||
|
||||
def _verify(certificate: bytes, signature: bytes, challenge: bytes) -> None:
|
||||
pub_key = certificate[:33]
|
||||
vk = VerifyingKey.from_string(pub_key, curve=SECP256k1)
|
||||
vk.verify_digest(signature, challenge, sigdecode=util.sigdecode_der)
|
||||
|
||||
|
||||
def _recover_public_key(certificate: bytes) -> bytes:
|
||||
signature = certificate[33:]
|
||||
v = signature[-1]
|
||||
digest = sha256(certificate).digest()
|
||||
|
||||
vk = VerifyingKey.from_public_key_recovery_with_digest(
|
||||
signature[:-1], digest, SECP256k1)
|
||||
|
||||
public_key = vk[v] if isinstance(vk, list) else vk
|
||||
der: bytes = public_key.to_der('compressed')
|
||||
return der
|
||||
@ -1,87 +0,0 @@
|
||||
from typing import Union
|
||||
|
||||
from ..constants import DerivationSource
|
||||
|
||||
|
||||
class KeyPath:
|
||||
MAX_COMPONENTS = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[str, bytes, bytearray],
|
||||
source: DerivationSource = DerivationSource.MASTER
|
||||
):
|
||||
if isinstance(path, str):
|
||||
if path == '':
|
||||
raise ValueError("Empty path")
|
||||
self.source, components = self._parse_path_string(path)
|
||||
if len(components) > self.MAX_COMPONENTS:
|
||||
raise ValueError("Too many components in derivation path")
|
||||
self.data = self._encode_components(components)
|
||||
elif isinstance(path, (bytes, bytearray)):
|
||||
if len(path) % 4 != 0:
|
||||
raise ValueError("Byte path must be a multiple of 4")
|
||||
self.source = source
|
||||
self.data = bytes(path)
|
||||
else:
|
||||
raise TypeError("Path must be a string or bytes")
|
||||
|
||||
def _parse_path_string(
|
||||
self,
|
||||
path: str
|
||||
) -> tuple[DerivationSource, list[int]]:
|
||||
tokens = path.split('/')
|
||||
if not tokens:
|
||||
raise ValueError("Empty path")
|
||||
|
||||
first = tokens[0]
|
||||
if first == "m":
|
||||
source = DerivationSource.MASTER
|
||||
tokens = tokens[1:]
|
||||
elif first == "..":
|
||||
source = DerivationSource.PARENT
|
||||
tokens = tokens[1:]
|
||||
elif first == ".":
|
||||
source = DerivationSource.CURRENT
|
||||
tokens = tokens[1:]
|
||||
else:
|
||||
source = DerivationSource.CURRENT
|
||||
|
||||
components = [self._parse_component(token) for token in tokens]
|
||||
return source, components
|
||||
|
||||
def _parse_component(self, token: str) -> int:
|
||||
if token.endswith("'"):
|
||||
token = token[:-1]
|
||||
hardened = True
|
||||
else:
|
||||
hardened = False
|
||||
|
||||
if not token.isdigit():
|
||||
raise ValueError(f"Invalid component: {token}")
|
||||
|
||||
value = int(token)
|
||||
if hardened:
|
||||
value |= 0x80000000
|
||||
return value
|
||||
|
||||
def _encode_components(self, components: list[int]) -> bytes:
|
||||
return b''.join(comp.to_bytes(4, 'big') for comp in components)
|
||||
|
||||
def to_string(self) -> str:
|
||||
prefix = {
|
||||
DerivationSource.MASTER: 'm',
|
||||
DerivationSource.PARENT: '..',
|
||||
DerivationSource.CURRENT: '.'
|
||||
}.get(self.source, '.')
|
||||
|
||||
components = []
|
||||
for i in range(0, len(self.data), 4):
|
||||
chunk = self.data[i:i+4]
|
||||
val = int.from_bytes(chunk, 'big')
|
||||
if val & 0x80000000:
|
||||
components.append(f"{val & 0x7FFFFFFF}'")
|
||||
else:
|
||||
components.append(str(val))
|
||||
|
||||
return '/'.join([prefix] + components)
|
||||
@ -1,82 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from ecdsa import VerifyingKey, util, SECP256k1
|
||||
from typing import Optional
|
||||
|
||||
from ..constants import SigningAlgorithm
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignatureResult:
|
||||
algo: SigningAlgorithm
|
||||
r: bytes
|
||||
s: bytes
|
||||
recovery_id: Optional[int] = None
|
||||
public_key: Optional[bytes] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
digest: bytes,
|
||||
algo: SigningAlgorithm,
|
||||
r: int,
|
||||
s: int,
|
||||
recovery_id: Optional[int] = None,
|
||||
public_key: Optional[bytes] = None
|
||||
) -> None:
|
||||
self.algo = algo
|
||||
self.r = r.to_bytes((r.bit_length() + 7) // 8, 'big')
|
||||
self.s = s.to_bytes((s.bit_length() + 7) // 8, 'big')
|
||||
if public_key is None and recovery_id is None:
|
||||
raise ValueError(
|
||||
"Public key and recovery id not returned from card")
|
||||
|
||||
self.public_key = (
|
||||
public_key
|
||||
if public_key is not None
|
||||
else self._recover_public_key(digest)
|
||||
)
|
||||
|
||||
self.recovery_id = (
|
||||
recovery_id
|
||||
if recovery_id is not None
|
||||
else self._recover_v(digest)
|
||||
)
|
||||
|
||||
@property
|
||||
def signature(self) -> bytes:
|
||||
return self.r + self.s
|
||||
|
||||
@property
|
||||
def signature_der(self) -> bytes:
|
||||
signature: bytes = util.sigencode_der(
|
||||
int.from_bytes(self.r, 'big'),
|
||||
int.from_bytes(self.s, 'big'),
|
||||
self.recovery_id
|
||||
)
|
||||
return signature
|
||||
|
||||
def _recover_public_key(self, digest: bytes) -> bytes:
|
||||
if self.recovery_id is None:
|
||||
raise ValueError("Recovery ID is required for public key recovery")
|
||||
|
||||
public_key = VerifyingKey.from_public_key_recovery_with_digest(
|
||||
self.signature_der,
|
||||
digest,
|
||||
SECP256k1,
|
||||
sigdecode=util.sigdecode_der)
|
||||
public_key_bytes: bytes = public_key.to_string()
|
||||
return public_key_bytes
|
||||
|
||||
def _recover_v(self, digest: bytes) -> int:
|
||||
if self.public_key is None:
|
||||
raise ValueError("Public key is required for recovery ID")
|
||||
|
||||
public_keys = VerifyingKey.from_public_key_recovery_with_digest(
|
||||
self.signature, digest, SECP256k1)
|
||||
|
||||
index = 0
|
||||
for public_key in public_keys:
|
||||
if self.public_key[1:] == public_key.to_string():
|
||||
return index
|
||||
index += 1
|
||||
|
||||
raise RuntimeError("Recovery ID not found")
|
||||
@ -1,102 +0,0 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from keycard.exceptions import InvalidResponseError
|
||||
|
||||
|
||||
def _parse_ber_length(data: bytes, index: int) -> tuple[int, int]:
|
||||
"""
|
||||
Parses a BER-encoded length field from a byte sequence starting at the
|
||||
given index.
|
||||
|
||||
Args:
|
||||
data (bytes): The byte sequence containing the BER-encoded length.
|
||||
index (int): The starting index in the byte sequence to parse the
|
||||
length from.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: A tuple containing the parsed length (int) and the
|
||||
total number of bytes consumed (int).
|
||||
|
||||
Raises:
|
||||
InvalidResponseError: If the length encoding is unsupported or exceeds
|
||||
the remaining buffer.
|
||||
"""
|
||||
first = data[index]
|
||||
index += 1
|
||||
|
||||
if first < 0x80:
|
||||
return first, 1
|
||||
|
||||
num_bytes = first & 0x7F
|
||||
if num_bytes > 4:
|
||||
raise InvalidResponseError("Unsupported length encoding")
|
||||
|
||||
if index + num_bytes > len(data):
|
||||
raise InvalidResponseError("Length exceeds remaining buffer")
|
||||
|
||||
length = int.from_bytes(data[index:index+num_bytes], "big")
|
||||
return length, 1 + num_bytes
|
||||
|
||||
|
||||
def parse_tlv(data: bytes) -> defaultdict[int, list[bytes]]:
|
||||
"""
|
||||
Parses a byte sequence containing TLV (Tag-Length-Value) encoded data.
|
||||
|
||||
Args:
|
||||
data (bytes): The byte sequence to parse.
|
||||
|
||||
Returns:
|
||||
List[Tuple[int, bytes]]: A list of tuples, each containing the tag
|
||||
(as an int) and the value (as bytes).
|
||||
|
||||
Raises:
|
||||
InvalidResponseError: If the TLV header is incomplete or the declared
|
||||
length exceeds the available data.
|
||||
"""
|
||||
index = 0
|
||||
result = defaultdict(list)
|
||||
|
||||
while index < len(data):
|
||||
tag = data[index]
|
||||
index += 1
|
||||
|
||||
length, length_size = _parse_ber_length(data, index)
|
||||
index += length_size
|
||||
|
||||
value = data[index:index+length]
|
||||
|
||||
if len(value) < length:
|
||||
raise InvalidResponseError("Not enough bytes for value")
|
||||
|
||||
index += length
|
||||
|
||||
result[tag].append(value)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def encode_tlv(tag: int, value: bytes) -> bytes:
|
||||
"""
|
||||
Encode a tag-length-value (TLV) structure using BER-TLV rules.
|
||||
|
||||
Args:
|
||||
tag (int): A single-byte tag (0x00 - 0xFF).
|
||||
value (bytes): Value to encode.
|
||||
|
||||
Returns:
|
||||
bytes: Encoded TLV.
|
||||
"""
|
||||
if not (0 <= tag <= 0xFF):
|
||||
raise ValueError("Tag must fit in a single byte")
|
||||
|
||||
length = len(value)
|
||||
|
||||
if length < 0x80:
|
||||
length_bytes = bytes([length])
|
||||
else:
|
||||
len_len = (length.bit_length() + 7) // 8
|
||||
length_bytes = (
|
||||
bytes([0x80 | len_len]) + length.to_bytes(len_len, 'big')
|
||||
)
|
||||
|
||||
return bytes([tag]) + length_bytes + value
|
||||
@ -1,48 +0,0 @@
|
||||
from functools import wraps
|
||||
from typing import Callable, TypeVar, ParamSpec, cast
|
||||
|
||||
from .exceptions import InvalidStateError
|
||||
from .card_interface import CardInterface
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def make_precondition(
|
||||
attribute_name: str,
|
||||
display_name: str | None = None
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
card = args[0]
|
||||
if not isinstance(card, CardInterface):
|
||||
raise TypeError("First argument must be a CardInterface")
|
||||
if not getattr(card, attribute_name, False):
|
||||
readable = (
|
||||
display_name
|
||||
if display_name is not None
|
||||
else attribute_name.replace('_', ' ').title()
|
||||
)
|
||||
raise InvalidStateError(f"{readable} must be satisfied.")
|
||||
return func(*args, **kwargs)
|
||||
return cast(Callable[P, R], wrapper)
|
||||
return decorator
|
||||
|
||||
|
||||
require_selected = make_precondition(
|
||||
'is_selected',
|
||||
'Card Selection'
|
||||
)
|
||||
require_initialized = make_precondition(
|
||||
'is_initialized',
|
||||
'Card Initialization'
|
||||
)
|
||||
require_secure_channel = make_precondition(
|
||||
'is_secure_channel_open',
|
||||
'Secure Channel'
|
||||
)
|
||||
require_pin_verified = make_precondition(
|
||||
'is_pin_verified',
|
||||
'PIN verification'
|
||||
)
|
||||
@ -1,143 +0,0 @@
|
||||
# keycard/secure_channel.py
|
||||
|
||||
from hashlib import sha512
|
||||
|
||||
from .apdu import APDUResponse
|
||||
|
||||
from .crypto.aes import aes_cbc_encrypt, aes_cbc_decrypt
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecureChannel:
|
||||
"""
|
||||
SecureChannel manages a secure communication channel using AES encryption
|
||||
and MAC authentication.
|
||||
|
||||
Attributes:
|
||||
enc_key (bytes): The AES encryption key for the session.
|
||||
mac_key (bytes): The AES MAC key for message authentication.
|
||||
iv (bytes): The initialization vector for AES operations.
|
||||
authenticated (bool): Indicates if the session is authenticated.
|
||||
"""
|
||||
enc_key: bytes
|
||||
mac_key: bytes
|
||||
iv: bytes
|
||||
authenticated: bool = False
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
shared_secret: bytes,
|
||||
pairing_key: bytes,
|
||||
salt: bytes,
|
||||
seed_iv: bytes
|
||||
) -> "SecureChannel":
|
||||
"""
|
||||
Opens a new SecureChannel using the provided cryptographic parameters.
|
||||
|
||||
Args:
|
||||
shared_secret (bytes): The shared secret used for key derivation.
|
||||
pairing_key (bytes): The pairing key used for key derivation.
|
||||
salt (bytes): The salt value used in the key derivation process.
|
||||
seed_iv (bytes): The initialization vector (IV) to seed the
|
||||
session.
|
||||
|
||||
Returns:
|
||||
SecureChannel: An instance of SecureChannel initialized with
|
||||
derived encryption and MAC keys, and the provided IV.
|
||||
"""
|
||||
digest = sha512(shared_secret + pairing_key + salt).digest()
|
||||
enc_key, mac_key = digest[:32], digest[32:]
|
||||
return cls(
|
||||
enc_key=enc_key,
|
||||
mac_key=mac_key,
|
||||
iv=seed_iv,
|
||||
authenticated=True
|
||||
)
|
||||
|
||||
def wrap_apdu(
|
||||
self,
|
||||
cla: int,
|
||||
ins: int,
|
||||
p1: int,
|
||||
p2: int,
|
||||
data: bytes
|
||||
) -> bytes:
|
||||
"""
|
||||
Wraps an APDU command with secure channel encryption and MAC.
|
||||
|
||||
Args:
|
||||
cla (int): The APDU class byte.
|
||||
ins (int): The APDU instruction byte.
|
||||
p1 (int): The APDU parameter 1 byte.
|
||||
p2 (int): The APDU parameter 2 byte.
|
||||
data (bytes): The APDU data field to be encrypted.
|
||||
|
||||
Returns:
|
||||
tuple[int, int, int, int, bytes]: The wrapped APDU as a tuple
|
||||
containing the class, instruction, parameter 1, parameter 2,
|
||||
and the concatenated MAC and encrypted data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the secure channel is not authenticated and the
|
||||
instruction is not 0x11.
|
||||
"""
|
||||
if not self.authenticated and ins != 0x11:
|
||||
raise ValueError("Secure channel not authenticated")
|
||||
|
||||
encrypted = aes_cbc_encrypt(self.enc_key, self.iv, data)
|
||||
|
||||
lc = 16 + len(encrypted)
|
||||
mac_input = bytes([cla, ins, p1, p2, lc]) + bytes(11) + encrypted
|
||||
|
||||
enc_data = aes_cbc_encrypt(
|
||||
self.mac_key, bytes(16), mac_input, padding=False)
|
||||
|
||||
self.iv = enc_data[-16:]
|
||||
|
||||
return self.iv + encrypted
|
||||
|
||||
def unwrap_response(self, response: APDUResponse) -> tuple[bytes, int]:
|
||||
"""
|
||||
Unwraps and verifies a secure channel response.
|
||||
|
||||
Args:
|
||||
response (bytes): The encrypted response bytes to unwrap.
|
||||
|
||||
Returns:
|
||||
tuple[bytes, int]: A tuple containing the decrypted plaintext
|
||||
(excluding the status word) and the status word as an integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the secure channel is not authenticated.
|
||||
ValueError: If the response length is invalid.
|
||||
ValueError: If the MAC verification fails.
|
||||
ValueError: If the decrypted plaintext is too short to contain a
|
||||
status word.
|
||||
"""
|
||||
if not self.authenticated:
|
||||
raise ValueError("Secure channel not authenticated")
|
||||
|
||||
if len(response.data) < 18:
|
||||
raise ValueError("Invalid secure response length")
|
||||
|
||||
received_mac = bytes(response.data[:16])
|
||||
encrypted = bytes(response.data[16:])
|
||||
|
||||
lr = len(response.data)
|
||||
mac_input = bytes([lr]) + bytes(15) + bytes(encrypted)
|
||||
expected_mac = aes_cbc_encrypt(
|
||||
self.mac_key, bytes(16), mac_input, padding=False)[-16:]
|
||||
if received_mac != expected_mac:
|
||||
raise ValueError("Invalid MAC")
|
||||
|
||||
plaintext = aes_cbc_decrypt(self.enc_key, self.iv, encrypted)
|
||||
|
||||
self.iv = received_mac
|
||||
|
||||
if len(plaintext) < 2:
|
||||
raise ValueError("Missing status word in response")
|
||||
|
||||
return plaintext[:-2], int.from_bytes(plaintext[-2:], "big")
|
||||
@ -1,46 +0,0 @@
|
||||
from types import TracebackType
|
||||
from smartcard.System import readers
|
||||
from smartcard.pcsc.PCSCReader import PCSCReader
|
||||
|
||||
from .apdu import APDUResponse
|
||||
from .exceptions import TransportError
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self) -> None:
|
||||
self.connection: PCSCReader = None
|
||||
|
||||
def __enter__(self) -> 'Transport':
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
type_: type[BaseException] | None,
|
||||
value: BaseException | None,
|
||||
traceback: TracebackType | None
|
||||
) -> None:
|
||||
self.disconnect()
|
||||
|
||||
def connect(self, index: int = 0) -> None:
|
||||
r = readers()
|
||||
if not r:
|
||||
raise TransportError('No smart card readers found')
|
||||
self.connection = r[index].createConnection()
|
||||
self.connection.connect()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self.connection:
|
||||
self.connection.disconnect()
|
||||
self.connection = None
|
||||
|
||||
def send_apdu(self, apdu: bytes) -> APDUResponse:
|
||||
if not self.connection:
|
||||
self.connect()
|
||||
|
||||
apdu_list = list(apdu)
|
||||
|
||||
response, sw1, sw2 = self.connection.transmit(apdu_list)
|
||||
|
||||
sw = (sw1 << 8) | sw2
|
||||
return APDUResponse(response, sw)
|
||||
@ -1,12 +0,0 @@
|
||||
[mypy]
|
||||
ignore_missing_imports = True
|
||||
strict = True
|
||||
|
||||
[mypy-ecdsa.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pyaes.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-smartcard.*]
|
||||
ignore_missing_imports = True
|
||||
@ -1,35 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["flit_core >=3.11,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[project]
|
||||
name = "keycard"
|
||||
authors = [{name = "mmlado", email = "developer@mmlado.com"}]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
dynamic = ["version", "description"]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
dependencies = [
|
||||
"pyscard",
|
||||
"ecdsa",
|
||||
"pyaes"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"coverage",
|
||||
"sphinx",
|
||||
"sphinx-autodoc-typehints",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"mnemonic",
|
||||
"tox"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/mmlado/keycard-py"
|
||||
Documentation = "https://mmlado.github.io/keycard-py/"
|
||||
@ -1,123 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from invoke import task
|
||||
|
||||
|
||||
@task
|
||||
def venv(c):
|
||||
if not os.path.exists("venv"):
|
||||
c.run("python -m venv venv")
|
||||
print("venv created.")
|
||||
else:
|
||||
print("venv already exists.")
|
||||
|
||||
@task
|
||||
def install(c, dev=False):
|
||||
"""Install dependencies with Flit."""
|
||||
pip = "venv/bin/pip"
|
||||
c.run(f"{pip} install flit")
|
||||
if dev:
|
||||
c.run("venv/bin/flit install --symlink --deps develop")
|
||||
else:
|
||||
c.run("venv/bin/flit install --deps production")
|
||||
|
||||
|
||||
@task
|
||||
def test(c):
|
||||
"""Run pytest with coverage"""
|
||||
c.run("coverage run -m pytest", pty=True)
|
||||
|
||||
|
||||
@task
|
||||
def coverage(c):
|
||||
"""
|
||||
Runs the coverage report using the coverage tool.
|
||||
"""
|
||||
c.run("coverage report", pty=True)
|
||||
|
||||
|
||||
@task
|
||||
def htmlcov(c):
|
||||
"""
|
||||
Generates an HTML coverage report using the 'coverage' tool in html
|
||||
format.
|
||||
"""
|
||||
c.run("coverage html", pty=True)
|
||||
print("Open htmlcov/index.html in your browser")
|
||||
|
||||
|
||||
@task
|
||||
def lint(c):
|
||||
"""Run flake8 linting"""
|
||||
c.run("flake8 keycard tests", pty=True)
|
||||
|
||||
|
||||
@task
|
||||
def typecheck(c):
|
||||
"""Run mypy type checking."""
|
||||
c.run("mypy keycard")
|
||||
|
||||
|
||||
@task
|
||||
def docs(ctx, clean=False, open=False):
|
||||
"""
|
||||
Build Sphinx documentation.
|
||||
|
||||
Args:
|
||||
clean (bool): If True, removes the build directory before building.
|
||||
open (bool): If True, opens the built docs in a browser.
|
||||
"""
|
||||
docs_dir = "docs"
|
||||
build_dir = os.path.join(docs_dir, "_build")
|
||||
|
||||
if clean and os.path.exists(build_dir):
|
||||
ctx.run(f"rm -rf {build_dir}")
|
||||
|
||||
ctx.run(f"sphinx-build -b html {docs_dir} {build_dir}/html")
|
||||
|
||||
if open:
|
||||
index_path = os.path.join(build_dir, "html", "index.html")
|
||||
ctx.run(f"xdg-open {index_path} || open {index_path}", warn=True)
|
||||
|
||||
|
||||
@task
|
||||
def clean(c):
|
||||
"""Clean artifacts"""
|
||||
for pycache in Path(".").rglob("__pycache__"):
|
||||
shutil.rmtree(pycache, ignore_errors=True)
|
||||
|
||||
build_path = Path("docs") / "_build"
|
||||
if build_path.exists():
|
||||
shutil.rmtree(build_path, ignore_errors=True)
|
||||
|
||||
c.run("rm -rf .pytest_cache htmlcov .coverage", warn=True)
|
||||
|
||||
|
||||
@task
|
||||
def cleanall(c):
|
||||
"""Thorough cleanup of all build, cache, and pycache files."""
|
||||
patterns = [
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".coverage",
|
||||
"htmlcov",
|
||||
"dist",
|
||||
"build",
|
||||
"*.egg-info",
|
||||
"venv"
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for path in Path(".").rglob(pattern):
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
elif path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
# Sphinx docs
|
||||
docs_build = Path("docs") / "_build"
|
||||
if docs_build.exists():
|
||||
shutil.rmtree(docs_build, ignore_errors=True)
|
||||
print("All artifacts cleaned up.")
|
||||
@ -1,95 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from keycard import constants
|
||||
from keycard.card_interface import CardInterface
|
||||
from keycard.exceptions import APDUError
|
||||
from keycard.commands.change_secret import change_secret
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_card():
|
||||
card = Mock(spec=CardInterface)
|
||||
card.send_secure_apdu = Mock()
|
||||
return card
|
||||
|
||||
|
||||
def test_change_secret_pairing_str_success(mock_card):
|
||||
change_secret_module = sys.modules['keycard.commands.change_secret']
|
||||
with patch.object(
|
||||
change_secret_module, 'generate_pairing_token'
|
||||
) as mock_generate:
|
||||
mock_generate.return_value = bytes(32)
|
||||
change_secret(mock_card, 'pairingtoken', constants.PinType.PAIRING)
|
||||
mock_generate.assert_called_once_with('pairingtoken')
|
||||
mock_card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=constants.PinType.PAIRING.value,
|
||||
data=mock_generate.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_change_secret_user_pin_str_success(mock_card):
|
||||
pin = '123456'
|
||||
change_secret(mock_card, pin, constants.PinType.USER)
|
||||
mock_card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=constants.PinType.USER.value,
|
||||
data=pin.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def test_change_secret_user_pin_invalid_length(mock_card):
|
||||
with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."):
|
||||
change_secret(mock_card, b'12345', constants.PinType.USER)
|
||||
with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."):
|
||||
change_secret(mock_card, '12345', constants.PinType.USER)
|
||||
|
||||
|
||||
def test_change_secret_puk_success(mock_card):
|
||||
puk = b'123456789012'
|
||||
change_secret(mock_card, puk, constants.PinType.PUK)
|
||||
mock_card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=constants.PinType.PUK.value,
|
||||
data=puk
|
||||
)
|
||||
|
||||
|
||||
def test_change_secret_puk_str_success(mock_card):
|
||||
puk = '123456789012'
|
||||
change_secret(mock_card, puk, constants.PinType.PUK)
|
||||
mock_card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=constants.PinType.PUK.value,
|
||||
data=puk.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def test_change_secret_puk_invalid_length(mock_card):
|
||||
with pytest.raises(ValueError, match="PUK must be exactly 12 digits."):
|
||||
change_secret(mock_card, b'1234567890', constants.PinType.PUK)
|
||||
with pytest.raises(ValueError, match="PUK must be exactly 12 digits."):
|
||||
change_secret(mock_card, '1234567890', constants.PinType.PUK)
|
||||
|
||||
|
||||
def test_change_secret_pairing_bytes_success(mock_card):
|
||||
secret = b'a' * 32
|
||||
change_secret(mock_card, secret, constants.PinType.PAIRING)
|
||||
mock_card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_CHANGE_SECRET,
|
||||
p1=constants.PinType.PAIRING.value,
|
||||
data=secret
|
||||
)
|
||||
|
||||
|
||||
def test_change_secret_pairing_bytes_invalid_length(mock_card):
|
||||
with pytest.raises(ValueError, match="Pairing secret must be 32 bytes."):
|
||||
change_secret(mock_card, b'a' * 31, constants.PinType.PAIRING)
|
||||
|
||||
|
||||
def test_change_secret_raises_apdu_error(mock_card):
|
||||
mock_card.send_secure_apdu.side_effect = APDUError(0x6A80)
|
||||
with pytest.raises(APDUError):
|
||||
change_secret(mock_card, b'123456', constants.PinType.USER)
|
||||
@ -1,14 +0,0 @@
|
||||
from keycard.commands import derive_key
|
||||
from keycard.constants import INS_DERIVE_KEY, DerivationSource
|
||||
from keycard.parsing.keypath import KeyPath
|
||||
|
||||
|
||||
def test_derive_key_valid_master(card):
|
||||
key_path = KeyPath("m/44'/60'/0'/0/0")
|
||||
derive_key(card, key_path.to_string())
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=INS_DERIVE_KEY,
|
||||
p1=DerivationSource.MASTER,
|
||||
data=key_path.data
|
||||
)
|
||||
@ -1,100 +0,0 @@
|
||||
import pytest
|
||||
from keycard.commands.export_key import export_key
|
||||
from keycard.constants import DerivationOption, DerivationSource
|
||||
from keycard.parsing.exported_key import ExportedKey
|
||||
|
||||
|
||||
def test_export_key_success_public_only(card):
|
||||
public_key = b'\x04' + b'\x01' * 64
|
||||
inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key
|
||||
outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv
|
||||
card.send_secure_apdu.return_value = outer_tlv
|
||||
|
||||
result = export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.CURRENT,
|
||||
public_only=True,
|
||||
keypath=None,
|
||||
make_current=False,
|
||||
source=DerivationSource.MASTER
|
||||
)
|
||||
|
||||
assert isinstance(result, ExportedKey)
|
||||
assert result.public_key == public_key
|
||||
assert result.private_key is None
|
||||
assert result.chain_code is None
|
||||
|
||||
|
||||
def test_export_key_with_path_string(card):
|
||||
public_key = b'\x04' + b'\x02' * 64
|
||||
inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key
|
||||
outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv
|
||||
card.send_secure_apdu.return_value = outer_tlv
|
||||
|
||||
result = export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.DERIVE,
|
||||
public_only=True,
|
||||
keypath="m/44'/60'/0'/0/0",
|
||||
make_current=True,
|
||||
source=DerivationSource.MASTER
|
||||
)
|
||||
|
||||
assert isinstance(result, ExportedKey)
|
||||
assert result.public_key == public_key
|
||||
|
||||
|
||||
def test_export_key_invalid_keypath_length_bytes(card):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Byte keypath must be a multiple of 4"
|
||||
):
|
||||
export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.DERIVE,
|
||||
public_only=True,
|
||||
keypath=b'\x01\x02\x03',
|
||||
make_current=False,
|
||||
source=DerivationSource.PARENT
|
||||
)
|
||||
|
||||
|
||||
def test_export_key_requires_keypath_if_not_current(card):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Keypath required unless using CURRENT derivation"
|
||||
):
|
||||
export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.DERIVE,
|
||||
public_only=True,
|
||||
keypath=None,
|
||||
make_current=False,
|
||||
source=DerivationSource.CURRENT
|
||||
)
|
||||
|
||||
|
||||
def test_export_key_invalid_keypath_type(card):
|
||||
with pytest.raises(TypeError, match="Keypath must be a string or bytes"):
|
||||
export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.DERIVE,
|
||||
public_only=True,
|
||||
keypath=123,
|
||||
make_current=False,
|
||||
source=DerivationSource.CURRENT
|
||||
)
|
||||
|
||||
|
||||
def test_export_key_missing_keypair_template(card):
|
||||
card.send_secure_apdu.return_value = b'\xA0\x00'
|
||||
|
||||
with pytest.raises(ValueError, match="Missing keypair template"):
|
||||
export_key(
|
||||
card,
|
||||
derivation_option=DerivationOption.CURRENT,
|
||||
public_only=True,
|
||||
keypath=None,
|
||||
make_current=False,
|
||||
source=DerivationSource.MASTER
|
||||
)
|
||||
@ -1,25 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from keycard import constants
|
||||
from keycard.commands.factory_reset import factory_reset
|
||||
from keycard.exceptions import APDUError
|
||||
|
||||
|
||||
def test_factory_reset_success(card):
|
||||
mock_response = Mock()
|
||||
mock_response.status_word = 0x9000
|
||||
card.send_apdu.return_value = mock_response
|
||||
|
||||
factory_reset(card)
|
||||
card.send_apdu.assert_called_once_with(
|
||||
ins=constants.INS_FACTORY_RESET,
|
||||
p1=0xAA,
|
||||
p2=0x55
|
||||
)
|
||||
|
||||
|
||||
def test_factory_reset_failure(card):
|
||||
card.send_apdu.side_effect = APDUError(0x6A80)
|
||||
|
||||
with pytest.raises(APDUError):
|
||||
factory_reset(card)
|
||||
@ -1,20 +0,0 @@
|
||||
import pytest
|
||||
from keycard import constants
|
||||
from keycard.commands.generate_key import generate_key
|
||||
from keycard.exceptions import APDUError
|
||||
|
||||
|
||||
def test_generate_key_success(card):
|
||||
mock_id = b'\x01' * 32
|
||||
card.send_secure_apdu.return_value = mock_id
|
||||
result = generate_key(card)
|
||||
assert result == mock_id
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_GENERATE_KEY)
|
||||
|
||||
|
||||
def test_generate_key_apdu_error(card):
|
||||
card.send_secure_apdu.side_effect = APDUError(0x6A80)
|
||||
|
||||
with pytest.raises(APDUError):
|
||||
generate_key(card)
|
||||
@ -1,40 +0,0 @@
|
||||
import pytest
|
||||
from keycard.constants import INS_GENERATE_MNEMONIC
|
||||
from keycard.commands.generate_mnemonic import generate_mnemonic
|
||||
|
||||
|
||||
def test_generate_mnemonic_valid(card):
|
||||
card.send_secure_apdu.return_value = bytes([
|
||||
0x00, 0x00,
|
||||
0x07, 0xFF,
|
||||
0x05, 0x39,
|
||||
0x00, 0x2A
|
||||
])
|
||||
|
||||
result = generate_mnemonic(card, checksum_size=6)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=INS_GENERATE_MNEMONIC,
|
||||
p1=6
|
||||
)
|
||||
|
||||
assert result == [0, 2047, 1337, 42]
|
||||
|
||||
|
||||
def test_generate_mnemonic_invalid_checksum(card):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Checksum size must be between 4 and 8"
|
||||
):
|
||||
generate_mnemonic(card, checksum_size=2)
|
||||
|
||||
|
||||
def test_generate_mnemonic_odd_length_response(card):
|
||||
# Simulate invalid odd-length byte response
|
||||
card.send_secure_apdu.return_value = b'\x00\x01\x02'
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Response must contain an even number of bytes"
|
||||
):
|
||||
generate_mnemonic(card, checksum_size=6)
|
||||
@ -1,31 +0,0 @@
|
||||
import pytest
|
||||
from keycard.commands.get_data import get_data
|
||||
from keycard import constants
|
||||
|
||||
|
||||
def test_get_data_secure_channel(card):
|
||||
card.is_secure_channel_open = True
|
||||
card.send_secure_apdu.return_value = b"secure_data"
|
||||
result = get_data(card, slot=constants.StorageSlot.PUBLIC)
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_GET_DATA,
|
||||
p1=constants.StorageSlot.PUBLIC,
|
||||
)
|
||||
assert result == card.send_secure_apdu.return_value
|
||||
|
||||
|
||||
def test_get_data_proprietary_channel(card):
|
||||
card.is_secure_channel_open = False
|
||||
card.send_apdu.return_value = b"proprietary_data"
|
||||
result = get_data(card, slot=constants.StorageSlot.NDEF)
|
||||
card.send_apdu.assert_called_once_with(
|
||||
ins=constants.INS_GET_DATA,
|
||||
p1=constants.StorageSlot.NDEF.value,
|
||||
cla=constants.CLA_PROPRIETARY
|
||||
)
|
||||
assert result == card.send_apdu.return_value
|
||||
|
||||
|
||||
def test_get_data_invalid_slot(card):
|
||||
with pytest.raises(AttributeError):
|
||||
get_data(card, slot="INVALID_SLOT")
|
||||
@ -1,24 +0,0 @@
|
||||
from keycard.commands.get_status import get_status
|
||||
|
||||
|
||||
def test_get_application_status(card):
|
||||
card.send_secure_apdu.return_value = bytes.fromhex(
|
||||
'A309020103020102010101')
|
||||
|
||||
result = get_status(card)
|
||||
|
||||
assert result['pin_retry_count'] == 3
|
||||
assert result['puk_retry_count'] == 2
|
||||
assert result['initialized'] is True
|
||||
|
||||
|
||||
def test_get_key_path_status(card):
|
||||
key_path = [0x8000002C, 0x8000003C]
|
||||
|
||||
card.send_secure_apdu.return_value = b''.join(
|
||||
i.to_bytes(4, 'big') for i in key_path
|
||||
)
|
||||
|
||||
result = get_status(card, key_path=True)
|
||||
|
||||
assert result == key_path
|
||||
@ -1,85 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from keycard.commands.init import init
|
||||
from keycard.exceptions import APDUError
|
||||
from keycard import constants
|
||||
|
||||
|
||||
PIN = b'1234'
|
||||
PUK = b'5678'
|
||||
PAIRING_SECRET = b'abcdefgh'
|
||||
CARD_PUBLIC_KEY = b'\x04' + b'\x00' * 64 # Valid uncompressed pubkey format
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ecc_patches():
|
||||
init_module = sys.modules['keycard.commands.init']
|
||||
with (
|
||||
patch.object(init_module, 'urandom', return_value=b'\x00' * 16),
|
||||
patch.object(
|
||||
init_module,
|
||||
'aes_cbc_encrypt',
|
||||
side_effect=lambda k, iv,
|
||||
pt: b'\xAA' * len(pt)
|
||||
),
|
||||
patch.object(init_module, 'SigningKey') as mock_signing_key_cls,
|
||||
patch.object(init_module, 'VerifyingKey') as mock_verifying_key_cls,
|
||||
patch.object(init_module, 'ECDH') as mock_ecdh_cls,
|
||||
):
|
||||
mock_gen = mock_signing_key_cls.generate
|
||||
fake_privkey = MagicMock()
|
||||
fake_privkey.verifying_key.to_string.return_value = b'\x01' * 65
|
||||
mock_gen.return_value = fake_privkey
|
||||
|
||||
mock_parse = mock_verifying_key_cls.from_string
|
||||
mock_parse.return_value = 'parsed-pubkey'
|
||||
|
||||
ecdh_instance = MagicMock()
|
||||
ecdh_instance.generate_sharedsecret_bytes.return_value = b'\xBB' * 32
|
||||
mock_ecdh_cls.return_value = ecdh_instance
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def test_init_success(card, ecc_patches):
|
||||
card.send_apdu.return_value = b''
|
||||
card.card_public_key = CARD_PUBLIC_KEY
|
||||
|
||||
init(card, PIN, PUK, PAIRING_SECRET)
|
||||
|
||||
card.send_apdu.assert_called_once_with(
|
||||
ins=constants.INS_INIT,
|
||||
data=bytes.fromhex(
|
||||
'4101010101010101010101010101010101010101010101010101010'
|
||||
'1010101010101010101010101010101010101010101010101010101'
|
||||
'010101010101010101010100000000000000000000000000000000'
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('secret_length', [10, 240])
|
||||
def test_init_data_length(card, ecc_patches, secret_length):
|
||||
card.send_apdu.return_value = b''
|
||||
card.card_public_key = CARD_PUBLIC_KEY
|
||||
|
||||
pairing_secret = b'x' * secret_length
|
||||
plaintext = PIN + PUK + pairing_secret
|
||||
total_data_len = 1 + 65 + 16 + len(plaintext)
|
||||
|
||||
if total_data_len > 255:
|
||||
with pytest.raises(ValueError, match='Data too long'):
|
||||
init(card, PIN, PUK, pairing_secret)
|
||||
else:
|
||||
init(card, PIN, PUK, pairing_secret)
|
||||
assert card.send_apdu.call_count == 1
|
||||
|
||||
|
||||
def test_init_apdu_error(card, ecc_patches):
|
||||
card.send_apdu.side_effect = APDUError(0x6A84)
|
||||
card.card_public_key = CARD_PUBLIC_KEY
|
||||
|
||||
with pytest.raises(APDUError) as excinfo:
|
||||
init(card, PIN, PUK, PAIRING_SECRET)
|
||||
|
||||
assert excinfo.value.sw == 0x6A84
|
||||
@ -1,57 +0,0 @@
|
||||
import pytest
|
||||
from keycard.parsing.keypath import KeyPath
|
||||
from keycard.constants import DerivationSource
|
||||
|
||||
|
||||
def test_keypath_from_string_master():
|
||||
path = KeyPath("m/44'/60'/0'/0/0")
|
||||
assert path.source == DerivationSource.MASTER
|
||||
assert path.data == bytes.fromhex(
|
||||
'8000002c8000003c800000000000000000000000')
|
||||
assert path.to_string() == "m/44'/60'/0'/0/0"
|
||||
|
||||
|
||||
def test_keypath_from_string_parent():
|
||||
path = KeyPath('../1/2/3')
|
||||
assert path.source == DerivationSource.PARENT
|
||||
assert path.to_string() == '../1/2/3'
|
||||
|
||||
|
||||
def test_keypath_from_string_current_default():
|
||||
path = KeyPath('1/2/3')
|
||||
assert path.source == DerivationSource.CURRENT
|
||||
assert path.to_string() == './1/2/3'
|
||||
|
||||
|
||||
def test_keypath_from_bytes():
|
||||
data = bytes.fromhex('8000002c00000001')
|
||||
path = KeyPath(data, source=DerivationSource.PARENT)
|
||||
assert path.source == DerivationSource.PARENT
|
||||
assert path.data == data
|
||||
assert path.to_string() == "../44'/1"
|
||||
|
||||
|
||||
def test_keypath_empty_string_raises():
|
||||
with pytest.raises(ValueError, match="Empty path"):
|
||||
KeyPath('')
|
||||
|
||||
|
||||
def test_keypath_invalid_component():
|
||||
with pytest.raises(ValueError, match="Invalid component: abc"):
|
||||
KeyPath('m/abc')
|
||||
|
||||
|
||||
def test_keypath_too_many_components():
|
||||
long_path = 'm/' + '/'.join('0' for _ in range(11))
|
||||
with pytest.raises(ValueError, match="Too many components"):
|
||||
KeyPath(long_path)
|
||||
|
||||
|
||||
def test_keypath_invalid_byte_length():
|
||||
with pytest.raises(ValueError, match="Byte path must be a multiple of 4"):
|
||||
KeyPath(b'\x00\x01')
|
||||
|
||||
|
||||
def test_keypath_invalid_type():
|
||||
with pytest.raises(TypeError, match="Path must be a string or bytes"):
|
||||
KeyPath(123)
|
||||
@ -1,89 +0,0 @@
|
||||
import pytest
|
||||
from keycard.commands.load_key import load_key
|
||||
from keycard import constants
|
||||
from hashlib import sha256
|
||||
from keycard.parsing import tlv
|
||||
|
||||
|
||||
def test_load_key_bip39(card):
|
||||
seed = b"\xAA" * 64
|
||||
fake_uid = b"\xBB" * 32
|
||||
card.send_secure_apdu.return_value = fake_uid
|
||||
|
||||
result = load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.BIP39_SEED,
|
||||
bip39_seed=seed
|
||||
)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_LOAD_KEY,
|
||||
p1=constants.LoadKeyType.BIP39_SEED,
|
||||
p2=0,
|
||||
data=seed
|
||||
)
|
||||
assert result == fake_uid
|
||||
|
||||
|
||||
def test_load_key_pair(card):
|
||||
public_key = b'\x04' + b'\x01' * 64
|
||||
private_key = b'\x02' * 32
|
||||
uid = sha256(public_key).digest()
|
||||
card.send_secure_apdu.return_value = uid
|
||||
|
||||
encoded = tlv.encode_tlv(
|
||||
0xA1,
|
||||
tlv.encode_tlv(0x80, public_key) +
|
||||
tlv.encode_tlv(0x81, private_key)
|
||||
)
|
||||
|
||||
result = load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.ECC,
|
||||
public_key=public_key,
|
||||
private_key=private_key
|
||||
)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_LOAD_KEY,
|
||||
p1=constants.LoadKeyType.ECC,
|
||||
p2=0,
|
||||
data=encoded
|
||||
)
|
||||
assert result == uid
|
||||
|
||||
|
||||
def test_bip39_seed_too_short(card):
|
||||
with pytest.raises(ValueError, match="BIP39/LEE seed must be 16-64 bytes"):
|
||||
load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.BIP39_SEED,
|
||||
bip39_seed=b"\xAA" * 8
|
||||
)
|
||||
|
||||
|
||||
def test_bip39_seed_missing(card):
|
||||
with pytest.raises(ValueError, match="Either bip39_seed or lee_seed must be provided for key_type = BIP39_SEED"):
|
||||
load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.BIP39_SEED
|
||||
)
|
||||
|
||||
|
||||
def test_ecc_missing_private_key(card):
|
||||
with pytest.raises(ValueError, match="Private key.*required"):
|
||||
load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.ECC,
|
||||
public_key=b"\x04" + b"\x01" * 64
|
||||
)
|
||||
|
||||
|
||||
def test_extended_ecc_missing_private_key(card):
|
||||
with pytest.raises(ValueError, match="Private key.*required"):
|
||||
load_key(
|
||||
card,
|
||||
key_type=constants.LoadKeyType.EXTENDED_ECC,
|
||||
public_key=b"\x04" + b"\x02" * 64,
|
||||
chain_code=b"\x00" * 32
|
||||
)
|
||||
@ -1,48 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from keycard import constants
|
||||
from keycard.commands.mutually_authenticate import mutually_authenticate
|
||||
from keycard.exceptions import APDUError
|
||||
|
||||
|
||||
def test_mutually_authenticate_success(card):
|
||||
client_challenge = bytes(32)
|
||||
card.send_secure_apdu.return_value = bytes(32)
|
||||
|
||||
mutually_authenticate(card, client_challenge)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_MUTUALLY_AUTHENTICATE,
|
||||
data=client_challenge
|
||||
)
|
||||
|
||||
|
||||
def test_mutually_authenticate_invalid_status_word(card):
|
||||
card.send_secure_apdu.side_effect = APDUError(0x6F00)
|
||||
|
||||
with pytest.raises(APDUError, match='APDU failed with SW=6F00'):
|
||||
mutually_authenticate(card, bytes(32))
|
||||
|
||||
|
||||
def test_mutually_authenticate_invalid_response_length(card):
|
||||
client_challenge = b'\xAA' * 32
|
||||
response = b'\xBB' * 16 # Invalid length
|
||||
|
||||
card.send_secure_apdu.return_value = response
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='Response to MUTUALLY AUTHENTICATE is not 32 bytes'
|
||||
):
|
||||
mutually_authenticate(card, client_challenge)
|
||||
|
||||
|
||||
def test_mutually_authenticate_auto_challenge(card, monkeypatch):
|
||||
fake_challenge = b'\xCC' * 32
|
||||
monkeypatch.setattr('os.urandom', lambda n: fake_challenge)
|
||||
|
||||
card.send_secure_apdu.return_value = fake_challenge
|
||||
|
||||
mutually_authenticate(card)
|
||||
|
||||
card.send_secure_apdu.assert_called_once()
|
||||
@ -1,109 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from ecdsa import SECP256k1
|
||||
|
||||
from keycard.card_interface import CardInterface
|
||||
from keycard.commands.open_secure_channel import open_secure_channel
|
||||
from keycard.exceptions import APDUError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ecdsa():
|
||||
open_secure_channel_module = sys.modules[
|
||||
'keycard.commands.open_secure_channel'
|
||||
]
|
||||
with (
|
||||
patch.object(
|
||||
open_secure_channel_module,
|
||||
'SecureChannel'
|
||||
) as mock_secure_channel,
|
||||
patch.object(
|
||||
open_secure_channel_module,
|
||||
'VerifyingKey'
|
||||
) as mock_verifying_key,
|
||||
patch.object(
|
||||
open_secure_channel_module,
|
||||
'ECDH'
|
||||
) as mock_ecdh,
|
||||
patch.object(
|
||||
open_secure_channel_module,
|
||||
'SigningKey'
|
||||
) as mock_signing_key,
|
||||
):
|
||||
yield {
|
||||
'secure_channel': mock_secure_channel,
|
||||
'verifying_key': mock_verifying_key,
|
||||
'ecdh': mock_ecdh,
|
||||
'signing_key': mock_signing_key,
|
||||
}
|
||||
|
||||
|
||||
def test_open_secure_channel_success(mock_ecdsa):
|
||||
mock_verifying_key = mock_ecdsa['verifying_key']
|
||||
mock_ecdh = mock_ecdsa['ecdh']
|
||||
mock_signing_key = mock_ecdsa['signing_key']
|
||||
mock_secure_channel = mock_ecdsa['secure_channel']
|
||||
|
||||
pairing_index = 1
|
||||
pairing_key = b'pairing_key'
|
||||
card = MagicMock(spec=CardInterface)
|
||||
card.card_public_key = b'\x04' + b'\x01' * 64
|
||||
|
||||
salt = b'A' * 32
|
||||
seed_iv = b'B' * 16
|
||||
response_data = salt + seed_iv
|
||||
card.send_apdu.return_value = response_data
|
||||
|
||||
# Mock SigningKey.generate
|
||||
mock_signing_key_instance = MagicMock()
|
||||
mock_signing_key_instance.verifying_key.to_string.return_value = \
|
||||
b'\x04' + b'\x02' * 64
|
||||
mock_signing_key.generate.return_value = mock_signing_key_instance
|
||||
|
||||
mock_verifying_key.from_string.return_value = MagicMock()
|
||||
mock_ecdh_instance = MagicMock()
|
||||
mock_ecdh.return_value = mock_ecdh_instance
|
||||
mock_ecdh_instance.generate_sharedsecret_bytes.return_value = (
|
||||
b'shared_secret'
|
||||
)
|
||||
mock_secure_channel.open.return_value = 'secure_session'
|
||||
|
||||
result = open_secure_channel(
|
||||
card,
|
||||
pairing_index,
|
||||
pairing_key
|
||||
)
|
||||
|
||||
card.send_apdu.assert_called_once()
|
||||
mock_verifying_key.from_string.assert_called_once_with(
|
||||
card.card_public_key, curve=SECP256k1
|
||||
)
|
||||
mock_ecdh.assert_called_once()
|
||||
mock_ecdh_instance.generate_sharedsecret_bytes.assert_called_once()
|
||||
mock_secure_channel.open.assert_called_once_with(
|
||||
b'shared_secret', pairing_key, salt, seed_iv
|
||||
)
|
||||
assert result == 'secure_session'
|
||||
|
||||
|
||||
def test_open_secure_channel_raises_apdu_error(card, mock_ecdsa):
|
||||
mock_signing_key = mock_ecdsa['signing_key']
|
||||
|
||||
# Mock SigningKey.generate
|
||||
mock_signing_key_instance = MagicMock()
|
||||
mock_signing_key_instance.verifying_key.to_string.return_value = \
|
||||
b'\x04' + b'\x02' * 64
|
||||
mock_signing_key.generate.return_value = mock_signing_key_instance
|
||||
|
||||
pairing_index = 1
|
||||
pairing_key = b'pairing_key'
|
||||
card.card_public_key = b'\x04' + b'\x01' * 64
|
||||
card.send_apdu.side_effect = APDUError(0x6A80)
|
||||
|
||||
with pytest.raises(APDUError):
|
||||
open_secure_channel(
|
||||
card,
|
||||
pairing_index,
|
||||
pairing_key
|
||||
)
|
||||
@ -1,106 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
import hashlib
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from keycard.constants import INS_PAIR, PairingMode
|
||||
from keycard.commands.pair import pair
|
||||
from keycard.exceptions import APDUError, InvalidResponseError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urandom():
|
||||
pair_module = sys.modules['keycard.commands.pair']
|
||||
with patch.object(pair_module, 'urandom', return_value=b'\x01' * 32):
|
||||
yield
|
||||
|
||||
|
||||
def test_pair_success(card, mock_urandom):
|
||||
shared_secret = b'\xAA' * 32
|
||||
client_challenge = b'\x01' * 32
|
||||
card_challenge = b'\x02' * 32
|
||||
expected_card_cryptogram = hashlib.sha256(
|
||||
shared_secret + client_challenge).digest()
|
||||
expected_client_cryptogram = hashlib.sha256(
|
||||
shared_secret + card_challenge).digest()
|
||||
|
||||
first_response = expected_card_cryptogram + card_challenge
|
||||
second_response = b'\x05' + card_challenge
|
||||
|
||||
card.send_apdu.side_effect = [first_response, second_response]
|
||||
|
||||
result = pair(card, shared_secret)
|
||||
|
||||
assert result == (5, expected_client_cryptogram)
|
||||
assert card.send_apdu.call_count == 2
|
||||
|
||||
|
||||
def test_pairing_mode(card, mock_urandom):
|
||||
shared_secret = b'\xAA' * 32
|
||||
client_challenge = b'\x01' * 32
|
||||
card_challenge = b'\x02' * 32
|
||||
expected_card_cryptogram = hashlib.sha256(
|
||||
shared_secret + client_challenge).digest()
|
||||
first_response = expected_card_cryptogram + card_challenge
|
||||
second_response = b'\x05' + card_challenge
|
||||
|
||||
card.send_apdu.side_effect = [first_response, second_response]
|
||||
|
||||
pair(card, shared_secret, PairingMode.EPHEMERAL)
|
||||
card.send_apdu.assert_any_call(
|
||||
ins=INS_PAIR,
|
||||
p2=PairingMode.EPHEMERAL,
|
||||
data=client_challenge
|
||||
)
|
||||
|
||||
|
||||
def test_pair_invalid_shared_secret(card, mock_urandom):
|
||||
with pytest.raises(ValueError, match='Shared secret must be 32 bytes'):
|
||||
pair(card, b'short')
|
||||
|
||||
|
||||
def test_pair_apdu_error_on_first(card, mock_urandom):
|
||||
card.send_apdu.side_effect = APDUError(0x6A82)
|
||||
|
||||
with pytest.raises(APDUError):
|
||||
pair(card, b'\x00' * 32)
|
||||
|
||||
|
||||
def test_pair_invalid_response_length_first(card, mock_urandom):
|
||||
card.send_apdu.return_value = bytes(10)
|
||||
|
||||
with pytest.raises(
|
||||
InvalidResponseError,
|
||||
match='Unexpected response length'
|
||||
):
|
||||
pair(card, b'\x00' * 32)
|
||||
|
||||
|
||||
def test_pair_cryptogram_mismatch(card, mock_urandom):
|
||||
wrong_card_cryptogram = b'\xAB' * 32
|
||||
card_challenge = b'\x02' * 32
|
||||
response = wrong_card_cryptogram + card_challenge
|
||||
|
||||
card.send_apdu.side_effect = [response]
|
||||
|
||||
with pytest.raises(InvalidResponseError, match='Card cryptogram mismatch'):
|
||||
pair(card, b'\xAA' * 32)
|
||||
|
||||
|
||||
def test_pair_invalid_response_second_apdu(card, mock_urandom):
|
||||
shared_secret = b'\xAA' * 32
|
||||
client_challenge = b'\x01' * 32
|
||||
card_challenge = b'\x02' * 32
|
||||
card_cryptogram = hashlib.sha256(shared_secret + client_challenge).digest()
|
||||
|
||||
first_response = card_cryptogram + card_challenge
|
||||
second_response = b'\x00' * 10
|
||||
|
||||
card.send_apdu.side_effect = [first_response, second_response]
|
||||
|
||||
with pytest.raises(
|
||||
InvalidResponseError,
|
||||
match='Unexpected response length'
|
||||
):
|
||||
pair(card, shared_secret)
|
||||
@ -1,11 +0,0 @@
|
||||
from keycard.commands.remove_key import remove_key
|
||||
|
||||
|
||||
def test_remove_key_calls_send_secure_apdu_with_correct_ins(card):
|
||||
remove_key(card)
|
||||
card.send_secure_apdu.assert_called_once_with(ins=0xD3)
|
||||
|
||||
|
||||
def test_remove_key_returns_none(card):
|
||||
result = remove_key(card)
|
||||
assert result is None
|
||||
@ -1,41 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from keycard.commands.select import select
|
||||
from keycard.exceptions import APDUError
|
||||
from keycard import constants
|
||||
|
||||
|
||||
def test_select_success():
|
||||
select_module = sys.modules['keycard.commands.select']
|
||||
dummy_info = MagicMock()
|
||||
response_data = b'\x01\x02\x03\x04'
|
||||
|
||||
card = MagicMock()
|
||||
card.send_apdu.return_value = response_data
|
||||
|
||||
with patch.object(select_module, 'ApplicationInfo') as mock_app_info:
|
||||
mock_app_info.parse.return_value = dummy_info
|
||||
result = select(card)
|
||||
|
||||
card.send_apdu.assert_called_once_with(
|
||||
cla=constants.CLAISO7816,
|
||||
ins=constants.INS_SELECT,
|
||||
p1=0x04,
|
||||
p2=0x00,
|
||||
data=constants.KEYCARD_AID
|
||||
)
|
||||
mock_app_info.parse.assert_called_once_with(response_data)
|
||||
assert result == dummy_info
|
||||
|
||||
|
||||
def test_select_apdu_error():
|
||||
card = MagicMock()
|
||||
card.send_apdu.side_effect = APDUError(0x6A82)
|
||||
|
||||
with pytest.raises(APDUError) as excinfo:
|
||||
select(card)
|
||||
|
||||
assert excinfo.value.sw == 0x6A82
|
||||
@ -1,24 +0,0 @@
|
||||
from keycard.commands.set_pinless_path import set_pinless_path
|
||||
from keycard.constants import INS_SET_PINLESS_PATH
|
||||
from keycard.parsing.keypath import KeyPath
|
||||
|
||||
|
||||
def test_set_pinless_path(card):
|
||||
path = "m/44'/60'/0'/0/0"
|
||||
expected_data = KeyPath(path).data
|
||||
|
||||
set_pinless_path(card, path)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=INS_SET_PINLESS_PATH,
|
||||
data=expected_data
|
||||
)
|
||||
|
||||
|
||||
def test_set_pinless_path_empty(card):
|
||||
set_pinless_path(card, "")
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=INS_SET_PINLESS_PATH,
|
||||
data=b""
|
||||
)
|
||||
@ -1,101 +0,0 @@
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from keycard.commands.sign import sign
|
||||
from keycard import constants
|
||||
from keycard.exceptions import InvalidStateError
|
||||
from keycard.parsing.keypath import KeyPath
|
||||
|
||||
|
||||
def test_sign_current_key(card):
|
||||
sign_module = sys.modules['keycard.commands.sign']
|
||||
digest = b'\xAA' * 32
|
||||
raw = b'\x01' * 64 + b'\x1f'
|
||||
encoded = b'\x80' + bytes([len(raw)]) + raw
|
||||
card.send_secure_apdu.return_value = encoded
|
||||
with mock.patch.object(sign_module, "SignatureResult"):
|
||||
sign(card, digest)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_SIGN,
|
||||
p1=constants.DerivationOption.CURRENT,
|
||||
p2=constants.SigningAlgorithm.ECDSA_SECP256K1,
|
||||
data=digest,
|
||||
)
|
||||
|
||||
|
||||
def test_sign_with_derivation_path(card):
|
||||
sign_module = sys.modules['keycard.commands.sign']
|
||||
digest = bytes(32)
|
||||
raw = bytes(65)
|
||||
encoded = b'\x80' + bytes([len(raw)]) + raw
|
||||
card.send_secure_apdu.return_value = encoded
|
||||
key_path = KeyPath("m/44'/60'/0'/0/0")
|
||||
expected_data = digest + key_path.data
|
||||
|
||||
with mock.patch.object(sign_module, "SignatureResult"):
|
||||
sign(
|
||||
card,
|
||||
digest,
|
||||
p1=constants.DerivationOption.DERIVE,
|
||||
derivation_path=key_path.to_string()
|
||||
)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_SIGN,
|
||||
p1=constants.DerivationOption.DERIVE,
|
||||
p2=constants.SigningAlgorithm.ECDSA_SECP256K1,
|
||||
data=expected_data,
|
||||
)
|
||||
|
||||
|
||||
def test_sign_requires_pin(card):
|
||||
card.is_pin_verified = False
|
||||
digest = b'\xCC' * 32
|
||||
|
||||
with pytest.raises(
|
||||
InvalidStateError,
|
||||
match="PIN must be verified to sign with this derivation option"
|
||||
):
|
||||
sign(card, digest)
|
||||
|
||||
|
||||
def test_sign_short_digest(card):
|
||||
short_digest = b'\xDD' * 10
|
||||
|
||||
with pytest.raises(ValueError, match="Digest must be exactly 32 bytes"):
|
||||
sign(card, short_digest)
|
||||
|
||||
|
||||
def test_sign_missing_path(card):
|
||||
digest = b'\xEE' * 32
|
||||
|
||||
with pytest.raises(ValueError, match="Derivation path cannot be empty"):
|
||||
sign(
|
||||
card,
|
||||
digest,
|
||||
p1=constants.DerivationOption.DERIVE,
|
||||
derivation_path=None
|
||||
)
|
||||
|
||||
|
||||
def test_sign_not_implemented_algo(card):
|
||||
digest = b'\xAB' * 32
|
||||
|
||||
with pytest.raises(
|
||||
NotImplementedError,
|
||||
match="Signature algorithm 255 not supported"
|
||||
):
|
||||
sign(card, digest, p2=0xFF)
|
||||
|
||||
|
||||
def test_sign_raw_signature_wrong_length(card):
|
||||
digest = b'\xCC' * 32
|
||||
raw = b'\x01' * 64 # Should be 65 bytes
|
||||
encoded = b'\x80' + bytes([len(raw)]) + raw
|
||||
card.send_secure_apdu.return_value = encoded
|
||||
card.is_pin_verified = True
|
||||
with pytest.raises(ValueError, match="Expected 65-byte raw signature"):
|
||||
sign(card, digest)
|
||||
@ -1,29 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from keycard.commands import store_data
|
||||
from keycard import constants
|
||||
|
||||
|
||||
def test_store_data_calls_send_secure_apdu_with_correct_args(card):
|
||||
store_data(card, b"hello", constants.StorageSlot.PUBLIC)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_STORE_DATA,
|
||||
p1=constants.StorageSlot.PUBLIC.value,
|
||||
data=b'hello'
|
||||
)
|
||||
|
||||
|
||||
def test_store_data_uses_default_slot(card):
|
||||
store_data(card, b'world')
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_STORE_DATA,
|
||||
p1=constants.StorageSlot.PUBLIC,
|
||||
data=b'world'
|
||||
)
|
||||
|
||||
|
||||
def test_store_data_raises_value_error_on_too_long_data(card):
|
||||
with pytest.raises(ValueError, match="Data too long"):
|
||||
store_data(card, b'a' * 128, constants.StorageSlot.PUBLIC)
|
||||
@ -1,43 +0,0 @@
|
||||
import pytest
|
||||
from keycard.commands.unblock_pin import unblock_pin
|
||||
from keycard import constants
|
||||
|
||||
|
||||
def test_unblock_pin_with_valid_str(card):
|
||||
puk = '123456789012'
|
||||
pin = '123456'
|
||||
unblock_pin(card, puk + pin)
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_UNBLOCK_PIN,
|
||||
data=(puk + pin).encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def test_unblock_pin_with_valid_bytes(card):
|
||||
data = b'123456789012123456'
|
||||
unblock_pin(card, data)
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_UNBLOCK_PIN,
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad_input', [
|
||||
'12345678901212345', # Too short
|
||||
'1234567890121234567', # Too long
|
||||
b'12345678901212345', # Too short (bytes)
|
||||
b'1234567890121234567', # Too long (bytes)
|
||||
])
|
||||
def test_unblock_pin_invalid_length(card, bad_input):
|
||||
with pytest.raises(ValueError, match='exactly 18 digits'):
|
||||
unblock_pin(card, bad_input)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad_input', [
|
||||
'12345678901A123456', # Non-digit in PUK
|
||||
'12345678901212345A', # Non-digit in PIN
|
||||
'ABCDEFGHIJKL123456', # All non-digits in PUK
|
||||
])
|
||||
def test_unblock_pin_invalid_digits(card, bad_input):
|
||||
with pytest.raises(ValueError, match='must be numeric digits'):
|
||||
unblock_pin(card, bad_input)
|
||||
@ -1,25 +0,0 @@
|
||||
import pytest
|
||||
from keycard.commands.unpair import unpair
|
||||
from keycard.apdu import APDUResponse
|
||||
from keycard.exceptions import APDUError
|
||||
from keycard import constants
|
||||
|
||||
|
||||
def test_unpair_success(card):
|
||||
card.send_secure_apdu.return_value = APDUResponse(b'', 0x9000)
|
||||
|
||||
unpair(card, 1)
|
||||
|
||||
card.send_secure_apdu.assert_called_once_with(
|
||||
ins=constants.INS_UNPAIR,
|
||||
p1=0x01,
|
||||
)
|
||||
|
||||
|
||||
def test_unpair_apdu_error(card):
|
||||
card.send_secure_apdu.side_effect = APDUError(0x6A84)
|
||||
|
||||
with pytest.raises(APDUError) as excinfo:
|
||||
unpair(card, 1)
|
||||
|
||||
assert excinfo.value.sw == 0x6A84
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user