From 41f34f4ff4145b7abb60fd9bec168ae4b60f23b4 Mon Sep 17 00:00:00 2001 From: jonesmarvin8 <83104039+jonesmarvin8@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:27:22 -0400 Subject: [PATCH] fixes --- Cargo.lock | 158 ++++- Cargo.toml | 2 + .../src/bin/run_hello_world_private.rs | 2 + ...n_hello_world_through_tail_call_private.rs | 2 + .../bin/run_hello_world_with_move_function.rs | 4 + integration_tests/tests/amm.rs | 8 + integration_tests/tests/ata.rs | 6 + .../tests/auth_transfer/private.rs | 20 + .../tests/auth_transfer/public.rs | 16 + integration_tests/tests/indexer.rs | 6 + integration_tests/tests/keys_restoration.rs | 14 + integration_tests/tests/pinata.rs | 12 + integration_tests/tests/token.rs | 24 + keycard_tests.sh | 61 ++ nssa/src/encoding/public_transaction.rs | 5 +- .../witness_set.rs | 22 +- nssa/src/public_transaction/witness_set.rs | 2 +- python/keycard-py/.gitignore | 194 ++++++ python/keycard-py/CHANGELOG.md | 61 ++ python/keycard-py/LICENSE | 21 + python/keycard-py/README.md | 27 + python/keycard-py/docs/conf.py | 36 + python/keycard-py/docs/index.rst | 21 + python/keycard-py/docs/keycard.commands.rst | 77 +++ python/keycard-py/docs/keycard.crypto.rst | 29 + python/keycard-py/docs/keycard.parsing.rst | 45 ++ python/keycard-py/docs/keycard.rst | 63 ++ python/keycard-py/docs/modules.rst | 7 + python/keycard-py/example/example.py | 196 ++++++ python/keycard-py/keycard/__init__.py | 4 + python/keycard-py/keycard/apdu.py | 48 ++ python/keycard-py/keycard/card_interface.py | 38 ++ .../keycard-py/keycard/commands/__init__.py | 49 ++ .../keycard/commands/change_secret.py | 46 ++ .../keycard-py/keycard/commands/derive_key.py | 25 + .../keycard-py/keycard/commands/export_key.py | 80 +++ .../keycard/commands/export_lee_key.py | 82 +++ .../keycard/commands/factory_reset.py | 15 + .../keycard/commands/generate_key.py | 27 + .../keycard/commands/generate_mnemonic.py | 41 ++ .../keycard-py/keycard/commands/get_data.py | 35 + .../keycard-py/keycard/commands/get_status.py | 63 ++ python/keycard-py/keycard/commands/ident.py | 34 + python/keycard-py/keycard/commands/init.py | 79 +++ .../keycard-py/keycard/commands/load_key.py | 65 ++ .../keycard/commands/mutually_authenticate.py | 43 ++ .../keycard/commands/open_secure_channel.py | 68 ++ python/keycard-py/keycard/commands/pair.py | 75 +++ .../keycard-py/keycard/commands/remove_key.py | 11 + python/keycard-py/keycard/commands/select.py | 33 + .../keycard/commands/set_pinless_path.py | 25 + python/keycard-py/keycard/commands/sign.py | 136 ++++ .../keycard-py/keycard/commands/store_data.py | 30 + .../keycard/commands/unblock_pin.py | 32 + python/keycard-py/keycard/commands/unpair.py | 31 + .../keycard-py/keycard/commands/verify_pin.py | 49 ++ python/keycard-py/keycard/constants.py | 90 +++ python/keycard-py/keycard/crypto/__init__.py | 0 python/keycard-py/keycard/crypto/aes.py | 32 + .../keycard/crypto/generate_pairing_token.py | 15 + python/keycard-py/keycard/crypto/padding.py | 9 + python/keycard-py/keycard/exceptions.py | 41 ++ python/keycard-py/keycard/keycard.py | 630 ++++++++++++++++++ python/keycard-py/keycard/parsing/__init__.py | 0 .../keycard/parsing/application_info.py | 119 ++++ .../keycard/parsing/capabilities.py | 44 ++ .../keycard/parsing/exported_key.py | 17 + python/keycard-py/keycard/parsing/identity.py | 43 ++ python/keycard-py/keycard/parsing/keypath.py | 87 +++ .../keycard/parsing/signature_result.py | 82 +++ python/keycard-py/keycard/parsing/tlv.py | 102 +++ python/keycard-py/keycard/preconditions.py | 48 ++ python/keycard-py/keycard/secure_channel.py | 143 ++++ python/keycard-py/keycard/transport.py | 46 ++ python/keycard-py/mypy.ini | 12 + python/keycard-py/pyproject.toml | 35 + python/keycard-py/tasks.py | 123 ++++ python/keycard-py/tests/__init__.py | 0 python/keycard-py/tests/commands/__init__.py | 0 .../tests/commands/test_change_secret.py | 95 +++ .../tests/commands/test_derive_key.py | 14 + .../tests/commands/test_export_key.py | 100 +++ .../tests/commands/test_factory_reset.py | 25 + .../tests/commands/test_generate_key.py | 20 + .../tests/commands/test_generate_mnemonic.py | 40 ++ .../tests/commands/test_get_data.py | 31 + .../tests/commands/test_get_status.py | 24 + python/keycard-py/tests/commands/test_init.py | 85 +++ .../keycard-py/tests/commands/test_keypath.py | 57 ++ .../tests/commands/test_load_key.py | 89 +++ .../commands/test_mutually_authenticate.py | 48 ++ .../commands/test_open_secure_channel.py | 109 +++ python/keycard-py/tests/commands/test_pair.py | 106 +++ .../tests/commands/test_remove_key.py | 11 + .../keycard-py/tests/commands/test_select.py | 41 ++ .../tests/commands/test_set_pinless_path.py | 24 + python/keycard-py/tests/commands/test_sign.py | 101 +++ .../tests/commands/test_store_data.py | 29 + .../tests/commands/test_unblock_pin.py | 43 ++ .../keycard-py/tests/commands/test_unpair.py | 25 + .../tests/commands/test_verify_pin.py | 29 + python/keycard-py/tests/conftest.py | 9 + python/keycard-py/tests/crypto/__init__.py | 0 python/keycard-py/tests/crypto/test_aes.py | 55 ++ .../crypto/test_generate_pairing_token.py | 28 + python/keycard-py/tests/parsing/__init__.py | 0 .../tests/parsing/test_application_info.py | 162 +++++ .../keycard-py/tests/parsing/test_identity.py | 87 +++ .../tests/parsing/test_signature_result.py | 117 ++++ python/keycard-py/tests/parsing/test_tlv.py | 122 ++++ python/keycard-py/tests/test_apdu.py | 51 ++ python/keycard-py/tests/test_keycard.py | 531 +++++++++++++++ python/keycard-py/tests/test_preconditions.py | 56 ++ .../keycard-py/tests/test_secure_channel.py | 132 ++++ python/keycard-py/tests/test_transport.py | 87 +++ python/keycard-py/tests/test_vectors.py | 50 ++ python/keycard-py/tox.ini | 48 ++ python/keycard_test.py | 24 + python/keycard_wallet.py | 137 ++++ wallet-ffi/src/transfer.rs | 4 +- wallet/Cargo.toml | 2 + wallet/src/cli/account.rs | 19 +- wallet/src/cli/keycard.rs | 73 ++ wallet/src/cli/mod.rs | 7 + wallet/src/cli/programs/amm.rs | 22 + .../src/cli/programs/native_token_transfer.rs | 99 ++- wallet/src/cli/programs/pinata.rs | 21 +- wallet/src/cli/programs/token.rs | 66 +- wallet/src/helperfunctions.rs | 16 +- wallet/src/lib.rs | 146 +++- wallet/src/program_facades/ata.rs | 24 +- .../native_token_transfer/deshielded.rs | 2 + .../native_token_transfer/private.rs | 6 + .../native_token_transfer/public.rs | 51 +- .../native_token_transfer/shielded.rs | 8 + wallet/src/program_facades/pinata.rs | 2 + wallet/src/program_facades/token.rs | 109 ++- wallet_with_keycard.sh | 21 + 138 files changed, 7538 insertions(+), 125 deletions(-) create mode 100644 keycard_tests.sh create mode 100644 python/keycard-py/.gitignore create mode 100644 python/keycard-py/CHANGELOG.md create mode 100644 python/keycard-py/LICENSE create mode 100644 python/keycard-py/README.md create mode 100644 python/keycard-py/docs/conf.py create mode 100644 python/keycard-py/docs/index.rst create mode 100644 python/keycard-py/docs/keycard.commands.rst create mode 100644 python/keycard-py/docs/keycard.crypto.rst create mode 100644 python/keycard-py/docs/keycard.parsing.rst create mode 100644 python/keycard-py/docs/keycard.rst create mode 100644 python/keycard-py/docs/modules.rst create mode 100644 python/keycard-py/example/example.py create mode 100644 python/keycard-py/keycard/__init__.py create mode 100644 python/keycard-py/keycard/apdu.py create mode 100644 python/keycard-py/keycard/card_interface.py create mode 100644 python/keycard-py/keycard/commands/__init__.py create mode 100644 python/keycard-py/keycard/commands/change_secret.py create mode 100644 python/keycard-py/keycard/commands/derive_key.py create mode 100644 python/keycard-py/keycard/commands/export_key.py create mode 100644 python/keycard-py/keycard/commands/export_lee_key.py create mode 100644 python/keycard-py/keycard/commands/factory_reset.py create mode 100644 python/keycard-py/keycard/commands/generate_key.py create mode 100644 python/keycard-py/keycard/commands/generate_mnemonic.py create mode 100644 python/keycard-py/keycard/commands/get_data.py create mode 100644 python/keycard-py/keycard/commands/get_status.py create mode 100644 python/keycard-py/keycard/commands/ident.py create mode 100644 python/keycard-py/keycard/commands/init.py create mode 100644 python/keycard-py/keycard/commands/load_key.py create mode 100644 python/keycard-py/keycard/commands/mutually_authenticate.py create mode 100644 python/keycard-py/keycard/commands/open_secure_channel.py create mode 100644 python/keycard-py/keycard/commands/pair.py create mode 100644 python/keycard-py/keycard/commands/remove_key.py create mode 100644 python/keycard-py/keycard/commands/select.py create mode 100644 python/keycard-py/keycard/commands/set_pinless_path.py create mode 100644 python/keycard-py/keycard/commands/sign.py create mode 100644 python/keycard-py/keycard/commands/store_data.py create mode 100644 python/keycard-py/keycard/commands/unblock_pin.py create mode 100644 python/keycard-py/keycard/commands/unpair.py create mode 100644 python/keycard-py/keycard/commands/verify_pin.py create mode 100644 python/keycard-py/keycard/constants.py create mode 100644 python/keycard-py/keycard/crypto/__init__.py create mode 100644 python/keycard-py/keycard/crypto/aes.py create mode 100644 python/keycard-py/keycard/crypto/generate_pairing_token.py create mode 100644 python/keycard-py/keycard/crypto/padding.py create mode 100644 python/keycard-py/keycard/exceptions.py create mode 100644 python/keycard-py/keycard/keycard.py create mode 100644 python/keycard-py/keycard/parsing/__init__.py create mode 100644 python/keycard-py/keycard/parsing/application_info.py create mode 100644 python/keycard-py/keycard/parsing/capabilities.py create mode 100644 python/keycard-py/keycard/parsing/exported_key.py create mode 100644 python/keycard-py/keycard/parsing/identity.py create mode 100644 python/keycard-py/keycard/parsing/keypath.py create mode 100644 python/keycard-py/keycard/parsing/signature_result.py create mode 100644 python/keycard-py/keycard/parsing/tlv.py create mode 100644 python/keycard-py/keycard/preconditions.py create mode 100644 python/keycard-py/keycard/secure_channel.py create mode 100644 python/keycard-py/keycard/transport.py create mode 100644 python/keycard-py/mypy.ini create mode 100644 python/keycard-py/pyproject.toml create mode 100644 python/keycard-py/tasks.py create mode 100644 python/keycard-py/tests/__init__.py create mode 100644 python/keycard-py/tests/commands/__init__.py create mode 100644 python/keycard-py/tests/commands/test_change_secret.py create mode 100644 python/keycard-py/tests/commands/test_derive_key.py create mode 100644 python/keycard-py/tests/commands/test_export_key.py create mode 100644 python/keycard-py/tests/commands/test_factory_reset.py create mode 100644 python/keycard-py/tests/commands/test_generate_key.py create mode 100644 python/keycard-py/tests/commands/test_generate_mnemonic.py create mode 100644 python/keycard-py/tests/commands/test_get_data.py create mode 100644 python/keycard-py/tests/commands/test_get_status.py create mode 100644 python/keycard-py/tests/commands/test_init.py create mode 100644 python/keycard-py/tests/commands/test_keypath.py create mode 100644 python/keycard-py/tests/commands/test_load_key.py create mode 100644 python/keycard-py/tests/commands/test_mutually_authenticate.py create mode 100644 python/keycard-py/tests/commands/test_open_secure_channel.py create mode 100644 python/keycard-py/tests/commands/test_pair.py create mode 100644 python/keycard-py/tests/commands/test_remove_key.py create mode 100644 python/keycard-py/tests/commands/test_select.py create mode 100644 python/keycard-py/tests/commands/test_set_pinless_path.py create mode 100644 python/keycard-py/tests/commands/test_sign.py create mode 100644 python/keycard-py/tests/commands/test_store_data.py create mode 100644 python/keycard-py/tests/commands/test_unblock_pin.py create mode 100644 python/keycard-py/tests/commands/test_unpair.py create mode 100644 python/keycard-py/tests/commands/test_verify_pin.py create mode 100644 python/keycard-py/tests/conftest.py create mode 100644 python/keycard-py/tests/crypto/__init__.py create mode 100644 python/keycard-py/tests/crypto/test_aes.py create mode 100644 python/keycard-py/tests/crypto/test_generate_pairing_token.py create mode 100644 python/keycard-py/tests/parsing/__init__.py create mode 100644 python/keycard-py/tests/parsing/test_application_info.py create mode 100644 python/keycard-py/tests/parsing/test_identity.py create mode 100644 python/keycard-py/tests/parsing/test_signature_result.py create mode 100644 python/keycard-py/tests/parsing/test_tlv.py create mode 100644 python/keycard-py/tests/test_apdu.py create mode 100644 python/keycard-py/tests/test_keycard.py create mode 100644 python/keycard-py/tests/test_preconditions.py create mode 100644 python/keycard-py/tests/test_secure_channel.py create mode 100644 python/keycard-py/tests/test_transport.py create mode 100644 python/keycard-py/tests/test_vectors.py create mode 100644 python/keycard-py/tox.ini create mode 100644 python/keycard_test.py create mode 100644 python/keycard_wallet.py create mode 100644 wallet/src/cli/keycard.rs create mode 100644 wallet_with_keycard.sh diff --git a/Cargo.lock b/Cargo.lock index ca46abde..78b97777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1303,7 +1303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" dependencies = [ "clap", - "heck", + "heck 0.5.0", "indexmap 2.13.0", "log", "proc-macro2", @@ -1450,7 +1450,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2183,7 +2183,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "proc-macro2-diagnostics", ] @@ -3007,6 +3007,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -3560,6 +3566,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3879,7 +3894,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro-crate", "proc-macro2", "quote", @@ -3996,6 +4011,43 @@ 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" @@ -5114,6 +5166,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mempool" version = "0.1.0" @@ -5388,7 +5449,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6134,7 +6195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6147,7 +6208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6185,6 +6246,69 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7872,7 +7996,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7997,6 +8121,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.26.0" @@ -8845,6 +8975,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -9008,10 +9144,12 @@ dependencies = [ "indicatif", "itertools 0.14.0", "key_protocol", + "keycard_wallet", "log", "nssa", "nssa_core", "optfield", + "pyo3", "rand 0.8.5", "sequencer_service_rpc", "serde", @@ -9551,7 +9689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -9562,7 +9700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap 2.13.0", "prettyplease", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 5514c300..5621b827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "examples/program_deployment/methods/guest", "bedrock_client", "testnet_initial_state", + "keycard_wallet", ] [workspace.dependencies] @@ -67,6 +68,7 @@ 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", diff --git a/examples/program_deployment/src/bin/run_hello_world_private.rs b/examples/program_deployment/src/bin/run_hello_world_private.rs index 27ac2079..78c11656 100644 --- a/examples/program_deployment/src/bin/run_hello_world_private.rs +++ b/examples/program_deployment/src/bin/run_hello_world_private.rs @@ -52,6 +52,8 @@ async fn main() { accounts, Program::serialize_instruction(greeting).unwrap(), &program.into(), + &None, + &None, ) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs index 4fac3eec..e25c4b0e 100644 --- a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs +++ b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs @@ -60,6 +60,8 @@ async fn main() { accounts, Program::serialize_instruction(instruction).unwrap(), &program_with_dependencies, + &None, + &None, ) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs index a1c2517e..89763bd7 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs @@ -106,6 +106,8 @@ async fn main() { accounts, Program::serialize_instruction(instruction).unwrap(), &program.into(), + &None, + &None, ) .await .unwrap(); @@ -147,6 +149,8 @@ async fn main() { accounts, Program::serialize_instruction(instruction).unwrap(), &program.into(), + &None, + &None, ) .await .unwrap(); diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index dde9e7f5..2c3c404e 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -134,6 +134,8 @@ 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?; @@ -163,6 +165,8 @@ 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?; @@ -551,6 +555,8 @@ 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; @@ -575,6 +581,8 @@ 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; diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index c0918635..608b15d4 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -269,6 +269,8 @@ 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?; @@ -501,6 +503,8 @@ 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?; @@ -615,6 +619,8 @@ 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?; diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index cf02d0ac..2661084b 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -31,6 +31,8 @@ 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?; @@ -72,6 +74,8 @@ 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?; @@ -122,6 +126,8 @@ 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?; @@ -187,6 +193,8 @@ 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?; @@ -237,6 +245,8 @@ 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?; @@ -281,6 +291,8 @@ 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?; @@ -353,6 +365,8 @@ 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?; @@ -399,6 +413,8 @@ 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?; @@ -456,6 +472,8 @@ 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?; @@ -499,6 +517,8 @@ 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?; diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 416c4490..617bce53 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -24,6 +24,8 @@ 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?; @@ -82,6 +84,8 @@ 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?; @@ -120,6 +124,8 @@ 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; @@ -160,6 +166,8 @@ 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?; @@ -194,6 +202,8 @@ 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?; @@ -238,6 +248,8 @@ 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?; @@ -279,6 +291,8 @@ 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?; @@ -326,6 +340,8 @@ 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?; diff --git a/integration_tests/tests/indexer.rs b/integration_tests/tests/indexer.rs index 0aef4a42..0e3e4d4d 100644 --- a/integration_tests/tests/indexer.rs +++ b/integration_tests/tests/indexer.rs @@ -113,6 +113,8 @@ 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?; @@ -149,6 +151,8 @@ 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?; @@ -235,6 +239,8 @@ 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?; diff --git a/integration_tests/tests/keys_restoration.rs b/integration_tests/tests/keys_restoration.rs index 8dca027c..60c10cbe 100644 --- a/integration_tests/tests/keys_restoration.rs +++ b/integration_tests/tests/keys_restoration.rs @@ -76,6 +76,8 @@ 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?; @@ -152,6 +154,8 @@ 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?; @@ -164,6 +168,8 @@ 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?; @@ -204,6 +210,8 @@ 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?; @@ -216,6 +224,8 @@ 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?; @@ -281,6 +291,8 @@ 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?; @@ -292,6 +304,8 @@ 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?; diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index 77c4a646..82873843 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -54,6 +54,8 @@ 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; @@ -109,6 +111,8 @@ 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; @@ -141,6 +145,8 @@ 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 @@ -182,6 +188,8 @@ 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 @@ -247,6 +255,8 @@ 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?; @@ -263,6 +273,8 @@ 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 diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index e40e27c8..45e9a639 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -135,6 +135,8 @@ 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?; @@ -180,6 +182,8 @@ 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?; @@ -228,6 +232,8 @@ 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?; @@ -373,6 +379,8 @@ 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?; @@ -400,6 +408,8 @@ 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?; @@ -567,6 +577,8 @@ 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?; @@ -615,6 +627,8 @@ 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?; @@ -757,6 +771,8 @@ 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?; @@ -888,6 +904,8 @@ 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?; @@ -1014,6 +1032,8 @@ 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?; @@ -1149,6 +1169,8 @@ 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?; @@ -1352,6 +1374,8 @@ 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?; diff --git a/keycard_tests.sh b/keycard_tests.sh new file mode 100644 index 00000000..bd06168a --- /dev/null +++ b/keycard_tests.sh @@ -0,0 +1,61 @@ +# 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" diff --git a/nssa/src/encoding/public_transaction.rs b/nssa/src/encoding/public_transaction.rs index 2549cf27..b689e403 100644 --- a/nssa/src/encoding/public_transaction.rs +++ b/nssa/src/encoding/public_transaction.rs @@ -1,7 +1,8 @@ use crate::{PublicTransaction, error::NssaError, public_transaction::Message}; impl Message { - pub(crate) fn to_bytes(&self) -> Vec { + #[must_use] + pub fn to_bytes(&self) -> Vec { borsh::to_vec(&self).expect("Autoderived borsh serialization failure") } } @@ -13,6 +14,6 @@ impl PublicTransaction { } pub fn from_bytes(bytes: &[u8]) -> Result { - Ok(borsh::from_slice(bytes)?) + Ok(borsh::from_slice(bytes).expect("Autoderived borsh serialization failure")) } } diff --git a/nssa/src/privacy_preserving_transaction/witness_set.rs b/nssa/src/privacy_preserving_transaction/witness_set.rs index 86e3fff9..8d1ea033 100644 --- a/nssa/src/privacy_preserving_transaction/witness_set.rs +++ b/nssa/src/privacy_preserving_transaction/witness_set.rs @@ -13,8 +13,6 @@ 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 @@ -32,6 +30,26 @@ 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(); diff --git a/nssa/src/public_transaction/witness_set.rs b/nssa/src/public_transaction/witness_set.rs index 8222c5ed..f9692068 100644 --- a/nssa/src/public_transaction/witness_set.rs +++ b/nssa/src/public_transaction/witness_set.rs @@ -90,7 +90,7 @@ mod tests { assert_eq!(witness_set.signatures_and_public_keys.len(), 2); - let message_bytes = message.hash_message(); + let message_bytes = message.to_bytes(); for ((signature, public_key), expected_public_key) in witness_set .signatures_and_public_keys .into_iter() diff --git a/python/keycard-py/.gitignore b/python/keycard-py/.gitignore new file mode 100644 index 00000000..fea70884 --- /dev/null +++ b/python/keycard-py/.gitignore @@ -0,0 +1,194 @@ +# 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 \ No newline at end of file diff --git a/python/keycard-py/CHANGELOG.md b/python/keycard-py/CHANGELOG.md new file mode 100644 index 00000000..3a76f92b --- /dev/null +++ b/python/keycard-py/CHANGELOG.md @@ -0,0 +1,61 @@ +# 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 \ No newline at end of file diff --git a/python/keycard-py/LICENSE b/python/keycard-py/LICENSE new file mode 100644 index 00000000..a49f04cf --- /dev/null +++ b/python/keycard-py/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/python/keycard-py/README.md b/python/keycard-py/README.md new file mode 100644 index 00000000..2ad7df90 --- /dev/null +++ b/python/keycard-py/README.md @@ -0,0 +1,27 @@ +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![codecov](https://codecov.io/gh/mmlado/keycard-py/branch/main/graph/badge.svg)](https://codecov.io/gh/mmlado/keycard-py) [![PyPI version](https://img.shields.io/pypi/v/keycard.svg)](https://pypi.org/project/keycard/) [![Build status](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml/badge.svg)](https://github.com/mmlado/keycard-py/actions/workflows/publish.yml) [![Documentation](https://img.shields.io/badge/docs-gh--pages-blue.svg)](https://mmlado.github.io/keycard-py/) ![stars](https://img.shields.io/github/stars/mmlado/keycard-py.svg?style=social) ![lastcommit](https://img.shields.io/github/last-commit/mmlado/keycard-py.svg) ![numcontributors](https://img.shields.io/github/contributors-anon/mmlado/keycard-py.svg) + +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. \ No newline at end of file diff --git a/python/keycard-py/docs/conf.py b/python/keycard-py/docs/conf.py new file mode 100644 index 00000000..c11d3d65 --- /dev/null +++ b/python/keycard-py/docs/conf.py @@ -0,0 +1,36 @@ + +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'] diff --git a/python/keycard-py/docs/index.rst b/python/keycard-py/docs/index.rst new file mode 100644 index 00000000..cd2e7345 --- /dev/null +++ b/python/keycard-py/docs/index.rst @@ -0,0 +1,21 @@ +.. 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 `_ +documentation for details. + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules + +.. automodule:: keycard.keycard + :members: \ No newline at end of file diff --git a/python/keycard-py/docs/keycard.commands.rst b/python/keycard-py/docs/keycard.commands.rst new file mode 100644 index 00000000..d99d666c --- /dev/null +++ b/python/keycard-py/docs/keycard.commands.rst @@ -0,0 +1,77 @@ +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: diff --git a/python/keycard-py/docs/keycard.crypto.rst b/python/keycard-py/docs/keycard.crypto.rst new file mode 100644 index 00000000..1725b670 --- /dev/null +++ b/python/keycard-py/docs/keycard.crypto.rst @@ -0,0 +1,29 @@ +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: diff --git a/python/keycard-py/docs/keycard.parsing.rst b/python/keycard-py/docs/keycard.parsing.rst new file mode 100644 index 00000000..2252c043 --- /dev/null +++ b/python/keycard-py/docs/keycard.parsing.rst @@ -0,0 +1,45 @@ +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: diff --git a/python/keycard-py/docs/keycard.rst b/python/keycard-py/docs/keycard.rst new file mode 100644 index 00000000..2d743859 --- /dev/null +++ b/python/keycard-py/docs/keycard.rst @@ -0,0 +1,63 @@ +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: diff --git a/python/keycard-py/docs/modules.rst b/python/keycard-py/docs/modules.rst new file mode 100644 index 00000000..e50e4a79 --- /dev/null +++ b/python/keycard-py/docs/modules.rst @@ -0,0 +1,7 @@ +keycard +======= + +.. toctree:: + :maxdepth: 4 + + keycard diff --git a/python/keycard-py/example/example.py b/python/keycard-py/example/example.py new file mode 100644 index 00000000..e3237df4 --- /dev/null +++ b/python/keycard-py/example/example.py @@ -0,0 +1,196 @@ +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}.') diff --git a/python/keycard-py/keycard/__init__.py b/python/keycard-py/keycard/__init__.py new file mode 100644 index 00000000..e20f382a --- /dev/null +++ b/python/keycard-py/keycard/__init__.py @@ -0,0 +1,4 @@ +"""KeyCard Python SDK - APDU communication and cryptographic utilities.""" + +__version__ = "0.3.0" +__doc__ = "Python SDK for interacting with Status Keycard." diff --git a/python/keycard-py/keycard/apdu.py b/python/keycard-py/keycard/apdu.py new file mode 100644 index 00000000..05a9d8ff --- /dev/null +++ b/python/keycard-py/keycard/apdu.py @@ -0,0 +1,48 @@ +''' +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 diff --git a/python/keycard-py/keycard/card_interface.py b/python/keycard-py/keycard/card_interface.py new file mode 100644 index 00000000..f699d8f3 --- /dev/null +++ b/python/keycard-py/keycard/card_interface.py @@ -0,0 +1,38 @@ +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: ... diff --git a/python/keycard-py/keycard/commands/__init__.py b/python/keycard-py/keycard/commands/__init__.py new file mode 100644 index 00000000..359388d6 --- /dev/null +++ b/python/keycard-py/keycard/commands/__init__.py @@ -0,0 +1,49 @@ +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', +] diff --git a/python/keycard-py/keycard/commands/change_secret.py b/python/keycard-py/keycard/commands/change_secret.py new file mode 100644 index 00000000..97cd3799 --- /dev/null +++ b/python/keycard-py/keycard/commands/change_secret.py @@ -0,0 +1,46 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/derive_key.py b/python/keycard-py/keycard/commands/derive_key.py new file mode 100644 index 00000000..ba4ef93a --- /dev/null +++ b/python/keycard-py/keycard/commands/derive_key.py @@ -0,0 +1,25 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/export_key.py b/python/keycard-py/keycard/commands/export_key.py new file mode 100644 index 00000000..3a72f572 --- /dev/null +++ b/python/keycard-py/keycard/commands/export_key.py @@ -0,0 +1,80 @@ +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], + ) diff --git a/python/keycard-py/keycard/commands/export_lee_key.py b/python/keycard-py/keycard/commands/export_lee_key.py new file mode 100644 index 00000000..4d532360 --- /dev/null +++ b/python/keycard-py/keycard/commands/export_lee_key.py @@ -0,0 +1,82 @@ +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], + ) diff --git a/python/keycard-py/keycard/commands/factory_reset.py b/python/keycard-py/keycard/commands/factory_reset.py new file mode 100644 index 00000000..8a4a296a --- /dev/null +++ b/python/keycard-py/keycard/commands/factory_reset.py @@ -0,0 +1,15 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/generate_key.py b/python/keycard-py/keycard/commands/generate_key.py new file mode 100644 index 00000000..8afbe295 --- /dev/null +++ b/python/keycard-py/keycard/commands/generate_key.py @@ -0,0 +1,27 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/generate_mnemonic.py b/python/keycard-py/keycard/commands/generate_mnemonic.py new file mode 100644 index 00000000..1d00210c --- /dev/null +++ b/python/keycard-py/keycard/commands/generate_mnemonic.py @@ -0,0 +1,41 @@ +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) + ] diff --git a/python/keycard-py/keycard/commands/get_data.py b/python/keycard-py/keycard/commands/get_data.py new file mode 100644 index 00000000..6478773c --- /dev/null +++ b/python/keycard-py/keycard/commands/get_data.py @@ -0,0 +1,35 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/get_status.py b/python/keycard-py/keycard/commands/get_status.py new file mode 100644 index 00000000..e887a604 --- /dev/null +++ b/python/keycard-py/keycard/commands/get_status.py @@ -0,0 +1,63 @@ +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 + } diff --git a/python/keycard-py/keycard/commands/ident.py b/python/keycard-py/keycard/commands/ident.py new file mode 100644 index 00000000..aaa72d33 --- /dev/null +++ b/python/keycard-py/keycard/commands/ident.py @@ -0,0 +1,34 @@ +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) diff --git a/python/keycard-py/keycard/commands/init.py b/python/keycard-py/keycard/commands/init.py new file mode 100644 index 00000000..921d0cb3 --- /dev/null +++ b/python/keycard-py/keycard/commands/init.py @@ -0,0 +1,79 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/load_key.py b/python/keycard-py/keycard/commands/load_key.py new file mode 100644 index 00000000..8e09a255 --- /dev/null +++ b/python/keycard-py/keycard/commands/load_key.py @@ -0,0 +1,65 @@ +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 diff --git a/python/keycard-py/keycard/commands/mutually_authenticate.py b/python/keycard-py/keycard/commands/mutually_authenticate.py new file mode 100644 index 00000000..84379d86 --- /dev/null +++ b/python/keycard-py/keycard/commands/mutually_authenticate.py @@ -0,0 +1,43 @@ +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') diff --git a/python/keycard-py/keycard/commands/open_secure_channel.py b/python/keycard-py/keycard/commands/open_secure_channel.py new file mode 100644 index 00000000..6ce0fdee --- /dev/null +++ b/python/keycard-py/keycard/commands/open_secure_channel.py @@ -0,0 +1,68 @@ +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, + ) diff --git a/python/keycard-py/keycard/commands/pair.py b/python/keycard-py/keycard/commands/pair.py new file mode 100644 index 00000000..5f3dec4f --- /dev/null +++ b/python/keycard-py/keycard/commands/pair.py @@ -0,0 +1,75 @@ +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 diff --git a/python/keycard-py/keycard/commands/remove_key.py b/python/keycard-py/keycard/commands/remove_key.py new file mode 100644 index 00000000..fb08698d --- /dev/null +++ b/python/keycard-py/keycard/commands/remove_key.py @@ -0,0 +1,11 @@ +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) diff --git a/python/keycard-py/keycard/commands/select.py b/python/keycard-py/keycard/commands/select.py new file mode 100644 index 00000000..17b64125 --- /dev/null +++ b/python/keycard-py/keycard/commands/select.py @@ -0,0 +1,33 @@ +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) diff --git a/python/keycard-py/keycard/commands/set_pinless_path.py b/python/keycard-py/keycard/commands/set_pinless_path.py new file mode 100644 index 00000000..729afdf3 --- /dev/null +++ b/python/keycard-py/keycard/commands/set_pinless_path.py @@ -0,0 +1,25 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/sign.py b/python/keycard-py/keycard/commands/sign.py new file mode 100644 index 00000000..17993612 --- /dev/null +++ b/python/keycard-py/keycard/commands/sign.py @@ -0,0 +1,136 @@ +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") diff --git a/python/keycard-py/keycard/commands/store_data.py b/python/keycard-py/keycard/commands/store_data.py new file mode 100644 index 00000000..672fe7a6 --- /dev/null +++ b/python/keycard-py/keycard/commands/store_data.py @@ -0,0 +1,30 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/unblock_pin.py b/python/keycard-py/keycard/commands/unblock_pin.py new file mode 100644 index 00000000..c3707410 --- /dev/null +++ b/python/keycard-py/keycard/commands/unblock_pin.py @@ -0,0 +1,32 @@ +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 + ) diff --git a/python/keycard-py/keycard/commands/unpair.py b/python/keycard-py/keycard/commands/unpair.py new file mode 100644 index 00000000..9266eef1 --- /dev/null +++ b/python/keycard-py/keycard/commands/unpair.py @@ -0,0 +1,31 @@ +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, + ) diff --git a/python/keycard-py/keycard/commands/verify_pin.py b/python/keycard-py/keycard/commands/verify_pin.py new file mode 100644 index 00000000..4a977300 --- /dev/null +++ b/python/keycard-py/keycard/commands/verify_pin.py @@ -0,0 +1,49 @@ +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 diff --git a/python/keycard-py/keycard/constants.py b/python/keycard-py/keycard/constants.py new file mode 100644 index 00000000..f2867358 --- /dev/null +++ b/python/keycard-py/keycard/constants.py @@ -0,0 +1,90 @@ +""" +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 diff --git a/python/keycard-py/keycard/crypto/__init__.py b/python/keycard-py/keycard/crypto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/keycard/crypto/aes.py b/python/keycard-py/keycard/crypto/aes.py new file mode 100644 index 00000000..c78d4029 --- /dev/null +++ b/python/keycard-py/keycard/crypto/aes.py @@ -0,0 +1,32 @@ +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) diff --git a/python/keycard-py/keycard/crypto/generate_pairing_token.py b/python/keycard-py/keycard/crypto/generate_pairing_token.py new file mode 100644 index 00000000..3afca8e0 --- /dev/null +++ b/python/keycard-py/keycard/crypto/generate_pairing_token.py @@ -0,0 +1,15 @@ + +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) diff --git a/python/keycard-py/keycard/crypto/padding.py b/python/keycard-py/keycard/crypto/padding.py new file mode 100644 index 00000000..ffdf3d09 --- /dev/null +++ b/python/keycard-py/keycard/crypto/padding.py @@ -0,0 +1,9 @@ +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')] diff --git a/python/keycard-py/keycard/exceptions.py b/python/keycard-py/keycard/exceptions.py new file mode 100644 index 00000000..494d815f --- /dev/null +++ b/python/keycard-py/keycard/exceptions.py @@ -0,0 +1,41 @@ +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) diff --git a/python/keycard-py/keycard/keycard.py b/python/keycard-py/keycard/keycard.py new file mode 100644 index 00000000..5d7f23ac --- /dev/null +++ b/python/keycard-py/keycard/keycard.py @@ -0,0 +1,630 @@ +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 diff --git a/python/keycard-py/keycard/parsing/__init__.py b/python/keycard-py/keycard/parsing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/keycard/parsing/application_info.py b/python/keycard-py/keycard/parsing/application_info.py new file mode 100644 index 00000000..cc21aefd --- /dev/null +++ b/python/keycard-py/keycard/parsing/application_info.py @@ -0,0 +1,119 @@ +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})" + ) diff --git a/python/keycard-py/keycard/parsing/capabilities.py b/python/keycard-py/keycard/parsing/capabilities.py new file mode 100644 index 00000000..9fc8122e --- /dev/null +++ b/python/keycard-py/keycard/parsing/capabilities.py @@ -0,0 +1,44 @@ +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 + ) diff --git a/python/keycard-py/keycard/parsing/exported_key.py b/python/keycard-py/keycard/parsing/exported_key.py new file mode 100644 index 00000000..059ced4f --- /dev/null +++ b/python/keycard-py/keycard/parsing/exported_key.py @@ -0,0 +1,17 @@ +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 diff --git a/python/keycard-py/keycard/parsing/identity.py b/python/keycard-py/keycard/parsing/identity.py new file mode 100644 index 00000000..c67a0f88 --- /dev/null +++ b/python/keycard-py/keycard/parsing/identity.py @@ -0,0 +1,43 @@ +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 diff --git a/python/keycard-py/keycard/parsing/keypath.py b/python/keycard-py/keycard/parsing/keypath.py new file mode 100644 index 00000000..a3ff528b --- /dev/null +++ b/python/keycard-py/keycard/parsing/keypath.py @@ -0,0 +1,87 @@ +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) diff --git a/python/keycard-py/keycard/parsing/signature_result.py b/python/keycard-py/keycard/parsing/signature_result.py new file mode 100644 index 00000000..db5ddfb9 --- /dev/null +++ b/python/keycard-py/keycard/parsing/signature_result.py @@ -0,0 +1,82 @@ +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") diff --git a/python/keycard-py/keycard/parsing/tlv.py b/python/keycard-py/keycard/parsing/tlv.py new file mode 100644 index 00000000..93b97b95 --- /dev/null +++ b/python/keycard-py/keycard/parsing/tlv.py @@ -0,0 +1,102 @@ +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 diff --git a/python/keycard-py/keycard/preconditions.py b/python/keycard-py/keycard/preconditions.py new file mode 100644 index 00000000..edadaa9e --- /dev/null +++ b/python/keycard-py/keycard/preconditions.py @@ -0,0 +1,48 @@ +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' +) diff --git a/python/keycard-py/keycard/secure_channel.py b/python/keycard-py/keycard/secure_channel.py new file mode 100644 index 00000000..2cc9e33a --- /dev/null +++ b/python/keycard-py/keycard/secure_channel.py @@ -0,0 +1,143 @@ +# 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") diff --git a/python/keycard-py/keycard/transport.py b/python/keycard-py/keycard/transport.py new file mode 100644 index 00000000..9a56e599 --- /dev/null +++ b/python/keycard-py/keycard/transport.py @@ -0,0 +1,46 @@ +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) diff --git a/python/keycard-py/mypy.ini b/python/keycard-py/mypy.ini new file mode 100644 index 00000000..462cb50b --- /dev/null +++ b/python/keycard-py/mypy.ini @@ -0,0 +1,12 @@ +[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 \ No newline at end of file diff --git a/python/keycard-py/pyproject.toml b/python/keycard-py/pyproject.toml new file mode 100644 index 00000000..8a45f79a --- /dev/null +++ b/python/keycard-py/pyproject.toml @@ -0,0 +1,35 @@ +[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/" diff --git a/python/keycard-py/tasks.py b/python/keycard-py/tasks.py new file mode 100644 index 00000000..c2168110 --- /dev/null +++ b/python/keycard-py/tasks.py @@ -0,0 +1,123 @@ +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.") diff --git a/python/keycard-py/tests/__init__.py b/python/keycard-py/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/tests/commands/__init__.py b/python/keycard-py/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/tests/commands/test_change_secret.py b/python/keycard-py/tests/commands/test_change_secret.py new file mode 100644 index 00000000..2503ded2 --- /dev/null +++ b/python/keycard-py/tests/commands/test_change_secret.py @@ -0,0 +1,95 @@ +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) diff --git a/python/keycard-py/tests/commands/test_derive_key.py b/python/keycard-py/tests/commands/test_derive_key.py new file mode 100644 index 00000000..85e2646b --- /dev/null +++ b/python/keycard-py/tests/commands/test_derive_key.py @@ -0,0 +1,14 @@ +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 + ) diff --git a/python/keycard-py/tests/commands/test_export_key.py b/python/keycard-py/tests/commands/test_export_key.py new file mode 100644 index 00000000..cf9db5f7 --- /dev/null +++ b/python/keycard-py/tests/commands/test_export_key.py @@ -0,0 +1,100 @@ +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 + ) diff --git a/python/keycard-py/tests/commands/test_factory_reset.py b/python/keycard-py/tests/commands/test_factory_reset.py new file mode 100644 index 00000000..31d1e530 --- /dev/null +++ b/python/keycard-py/tests/commands/test_factory_reset.py @@ -0,0 +1,25 @@ +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) diff --git a/python/keycard-py/tests/commands/test_generate_key.py b/python/keycard-py/tests/commands/test_generate_key.py new file mode 100644 index 00000000..ef2c820e --- /dev/null +++ b/python/keycard-py/tests/commands/test_generate_key.py @@ -0,0 +1,20 @@ +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) diff --git a/python/keycard-py/tests/commands/test_generate_mnemonic.py b/python/keycard-py/tests/commands/test_generate_mnemonic.py new file mode 100644 index 00000000..54160cc4 --- /dev/null +++ b/python/keycard-py/tests/commands/test_generate_mnemonic.py @@ -0,0 +1,40 @@ +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) diff --git a/python/keycard-py/tests/commands/test_get_data.py b/python/keycard-py/tests/commands/test_get_data.py new file mode 100644 index 00000000..2faf5127 --- /dev/null +++ b/python/keycard-py/tests/commands/test_get_data.py @@ -0,0 +1,31 @@ +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") diff --git a/python/keycard-py/tests/commands/test_get_status.py b/python/keycard-py/tests/commands/test_get_status.py new file mode 100644 index 00000000..0464b905 --- /dev/null +++ b/python/keycard-py/tests/commands/test_get_status.py @@ -0,0 +1,24 @@ +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 diff --git a/python/keycard-py/tests/commands/test_init.py b/python/keycard-py/tests/commands/test_init.py new file mode 100644 index 00000000..d24dd33b --- /dev/null +++ b/python/keycard-py/tests/commands/test_init.py @@ -0,0 +1,85 @@ +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 diff --git a/python/keycard-py/tests/commands/test_keypath.py b/python/keycard-py/tests/commands/test_keypath.py new file mode 100644 index 00000000..a1177493 --- /dev/null +++ b/python/keycard-py/tests/commands/test_keypath.py @@ -0,0 +1,57 @@ +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) diff --git a/python/keycard-py/tests/commands/test_load_key.py b/python/keycard-py/tests/commands/test_load_key.py new file mode 100644 index 00000000..5f92a196 --- /dev/null +++ b/python/keycard-py/tests/commands/test_load_key.py @@ -0,0 +1,89 @@ +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 + ) diff --git a/python/keycard-py/tests/commands/test_mutually_authenticate.py b/python/keycard-py/tests/commands/test_mutually_authenticate.py new file mode 100644 index 00000000..dec88a24 --- /dev/null +++ b/python/keycard-py/tests/commands/test_mutually_authenticate.py @@ -0,0 +1,48 @@ +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() diff --git a/python/keycard-py/tests/commands/test_open_secure_channel.py b/python/keycard-py/tests/commands/test_open_secure_channel.py new file mode 100644 index 00000000..c6adab33 --- /dev/null +++ b/python/keycard-py/tests/commands/test_open_secure_channel.py @@ -0,0 +1,109 @@ +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 + ) diff --git a/python/keycard-py/tests/commands/test_pair.py b/python/keycard-py/tests/commands/test_pair.py new file mode 100644 index 00000000..b43b9269 --- /dev/null +++ b/python/keycard-py/tests/commands/test_pair.py @@ -0,0 +1,106 @@ +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) diff --git a/python/keycard-py/tests/commands/test_remove_key.py b/python/keycard-py/tests/commands/test_remove_key.py new file mode 100644 index 00000000..20c76d04 --- /dev/null +++ b/python/keycard-py/tests/commands/test_remove_key.py @@ -0,0 +1,11 @@ +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 diff --git a/python/keycard-py/tests/commands/test_select.py b/python/keycard-py/tests/commands/test_select.py new file mode 100644 index 00000000..75a040b9 --- /dev/null +++ b/python/keycard-py/tests/commands/test_select.py @@ -0,0 +1,41 @@ +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 diff --git a/python/keycard-py/tests/commands/test_set_pinless_path.py b/python/keycard-py/tests/commands/test_set_pinless_path.py new file mode 100644 index 00000000..ff8be399 --- /dev/null +++ b/python/keycard-py/tests/commands/test_set_pinless_path.py @@ -0,0 +1,24 @@ +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"" + ) diff --git a/python/keycard-py/tests/commands/test_sign.py b/python/keycard-py/tests/commands/test_sign.py new file mode 100644 index 00000000..40ae3ee9 --- /dev/null +++ b/python/keycard-py/tests/commands/test_sign.py @@ -0,0 +1,101 @@ +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) diff --git a/python/keycard-py/tests/commands/test_store_data.py b/python/keycard-py/tests/commands/test_store_data.py new file mode 100644 index 00000000..f24c8f75 --- /dev/null +++ b/python/keycard-py/tests/commands/test_store_data.py @@ -0,0 +1,29 @@ +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) diff --git a/python/keycard-py/tests/commands/test_unblock_pin.py b/python/keycard-py/tests/commands/test_unblock_pin.py new file mode 100644 index 00000000..8eea2a1d --- /dev/null +++ b/python/keycard-py/tests/commands/test_unblock_pin.py @@ -0,0 +1,43 @@ +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) diff --git a/python/keycard-py/tests/commands/test_unpair.py b/python/keycard-py/tests/commands/test_unpair.py new file mode 100644 index 00000000..e65c80cd --- /dev/null +++ b/python/keycard-py/tests/commands/test_unpair.py @@ -0,0 +1,25 @@ +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 diff --git a/python/keycard-py/tests/commands/test_verify_pin.py b/python/keycard-py/tests/commands/test_verify_pin.py new file mode 100644 index 00000000..910b7fea --- /dev/null +++ b/python/keycard-py/tests/commands/test_verify_pin.py @@ -0,0 +1,29 @@ +import pytest +from keycard.commands.verify_pin import verify_pin +from keycard.exceptions import APDUError +from keycard import constants + + +def test_verify_pin_success(card): + assert verify_pin(card, '1234') is True + card.send_secure_apdu.assert_called_once_with( + ins=constants.INS_VERIFY_PIN, + data=b'1234' + ) + + +def test_verify_pin_incorrect_but_allowed(card): + card.send_secure_apdu.side_effect = APDUError(0x63C2) + assert verify_pin(card, '0000') is False + + +def test_verify_pin_blocked(card): + card.send_secure_apdu.side_effect = APDUError(0x63C0) + with pytest.raises(RuntimeError, match='PIN is blocked'): + verify_pin(card, '0000') + + +def test_verify_pin_other_apdu_error(card): + card.send_secure_apdu.side_effect = APDUError(0x6A80) + with pytest.raises(APDUError): + verify_pin(card, '0000') diff --git a/python/keycard-py/tests/conftest.py b/python/keycard-py/tests/conftest.py new file mode 100644 index 00000000..81c5a6b1 --- /dev/null +++ b/python/keycard-py/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from unittest.mock import Mock +from keycard.card_interface import CardInterface + + +@pytest.fixture +def card(): + mock = Mock(spec=CardInterface) + return mock diff --git a/python/keycard-py/tests/crypto/__init__.py b/python/keycard-py/tests/crypto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/tests/crypto/test_aes.py b/python/keycard-py/tests/crypto/test_aes.py new file mode 100644 index 00000000..c1b6b70d --- /dev/null +++ b/python/keycard-py/tests/crypto/test_aes.py @@ -0,0 +1,55 @@ +import pytest +from keycard.crypto import aes + + +def test_aes_cbc_encrypt_decrypt_roundtrip(): + key = b'0123456789abcdef' + iv = b'abcdef9876543210' + data = b'hello world 1234' + ciphertext = aes.aes_cbc_encrypt(key, iv, data) + decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) + assert decrypted == data + + +def test_aes_cbc_encrypt_padding(): + key = b'0123456789abcdef' + iv = b'abcdef9876543210' + data = b'abc' + ciphertext = aes.aes_cbc_encrypt(key, iv, data) + assert len(ciphertext) % 16 == 0 + decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) + assert decrypted == data + + +def test_aes_cbc_encrypt_no_padding(): + key = b'0123456789abcdef' + iv = b'abcdef9876543210' + data = b'1234567890abcdef' + ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False) + assert len(ciphertext) == 16 + with pytest.raises(ValueError): + aes.aes_cbc_decrypt(key, iv, ciphertext) + + +def test_aes_cbc_decrypt_invalid_padding(): + key = b'0123456789abcdef' + iv = b'abcdef9876543210' + data = b'1234567890abcdef' + ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False) + with pytest.raises(ValueError): + aes.aes_cbc_decrypt(key, iv, ciphertext) + + +@pytest.mark.parametrize('data', [ + b'', + b'a', + b'short', + b'exactly16bytes!!', + b'longer data that is not a multiple of block size', +]) +def test_various_lengths(data): + key = b'0123456789abcdef' + iv = b'abcdef9876543210' + ciphertext = aes.aes_cbc_encrypt(key, iv, data) + decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext) + assert decrypted == data diff --git a/python/keycard-py/tests/crypto/test_generate_pairing_token.py b/python/keycard-py/tests/crypto/test_generate_pairing_token.py new file mode 100644 index 00000000..67aec706 --- /dev/null +++ b/python/keycard-py/tests/crypto/test_generate_pairing_token.py @@ -0,0 +1,28 @@ +import hashlib +import unicodedata +from keycard.crypto.generate_pairing_token import generate_pairing_token + + +def test_generate_pairing_token_deterministic(): + passphrase = "correct horse battery staple" + expected = hashlib.pbkdf2_hmac( + 'sha256', + unicodedata.normalize('NFKD', passphrase).encode('utf-8'), + unicodedata.normalize( + 'NFKD', 'Keycard Pairing Password Salt').encode('utf-8'), + 50000, + dklen=32 + ) + assert generate_pairing_token(passphrase) == expected + + +def test_generate_pairing_token_unicode_normalization(): + token_plain = generate_pairing_token("é") + token_composed = generate_pairing_token("é") + assert token_plain == token_composed + + +def test_generate_pairing_token_output_length(): + token = generate_pairing_token("whatever") + assert isinstance(token, bytes) + assert len(token) == 32 diff --git a/python/keycard-py/tests/parsing/__init__.py b/python/keycard-py/tests/parsing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/keycard-py/tests/parsing/test_application_info.py b/python/keycard-py/tests/parsing/test_application_info.py new file mode 100644 index 00000000..3b666a61 --- /dev/null +++ b/python/keycard-py/tests/parsing/test_application_info.py @@ -0,0 +1,162 @@ +import pytest +from keycard.exceptions import InvalidResponseError +from keycard.parsing.application_info import ApplicationInfo + + +class DummyCapabilities: + CREDENTIALS_MANAGEMENT = 1 + SECURE_CHANNEL = 2 + + @staticmethod + def parse(val): + return val + + +def test_parse_simple_pubkey(monkeypatch): + monkeypatch.setattr( + 'keycard.parsing.application_info.Capabilities', + DummyCapabilities + ) + data = bytes([0x80, 0x04, 0x01, 0x02, 0x03, 0x04]) + info = ApplicationInfo.parse(data) + + assert info.ecc_public_key == b'\x01\x02\x03\x04' + assert info.capabilities == 3 + assert info.instance_uid is None + assert info.key_uid is None + assert info.version_major == 0 + assert info.version_minor == 0 + + +def test_str_method(): + info = ApplicationInfo( + capabilities=7, + ecc_public_key=b'\x01\x02', + instance_uid=b'\xAA\xBB', + key_uid=b'\xCC\xDD', + version_major=2, + version_minor=5, + ) + s = str(info) + + assert '2.5' in s + assert 'aabb' in s + assert 'ccdd' in s + assert '0102' in s + assert '7' in s + + +def test_parse_tlv_success(monkeypatch): + def dummy_parse_tlv(data): + if data == b'\xA4\x0C' + b'\x01'*12: + return { + 0xA4: [ + b'\x8F\x02\xAA\xBB' + b'\x80\x02\x01\x02' + b'\x8E\x02\xCC\xDD' + b'\x8D\x01\x07' + b'\x02\x02\x02\x05' + ] + } + return { + 0x8F: [b'\xAA\xBB'], + 0x80: [b'\x01\x02'], + 0x8E: [b'\xCC\xDD'], + 0x8D: [b'\x07'], + 0x02: [b'\x02\x05'] + } + + class DummyCapabilities: + @staticmethod + def parse(val): + return val + + monkeypatch.setattr( + 'keycard.parsing.application_info.parse_tlv', + dummy_parse_tlv + ) + monkeypatch.setattr( + 'keycard.parsing.application_info.Capabilities', + DummyCapabilities + ) + + # Simulate TLV-encoded data + data = b'\xA4\x0C' + b'\x01'*12 + info = ApplicationInfo.parse(data) + + assert info.instance_uid == b'\xAA\xBB' + assert info.ecc_public_key == b'\x01\x02' + assert info.key_uid == b'\xCC\xDD' + assert info.capabilities == 7 + assert info.version_major == 2 + assert info.version_minor == 5 + + +def test_parse_tlv_missing_a4(monkeypatch): + def dummy_parse_tlv(data): + # No 0xA4 tag present + return {} + + monkeypatch.setattr( + 'keycard.parsing.application_info.parse_tlv', + dummy_parse_tlv + ) + with pytest.raises(InvalidResponseError): + ApplicationInfo.parse(b'\x00\x01\x02') + + +def test_parse_tlv_missing_fields(monkeypatch): + def dummy_parse_tlv(data): + # Missing some tags + return { + 0xA4: [b''] + } + + class DummyCapabilities: + @staticmethod + def parse(val): + return val + + monkeypatch.setattr( + 'keycard.parsing.application_info.parse_tlv', + dummy_parse_tlv + ) + monkeypatch.setattr( + 'keycard.parsing.application_info.Capabilities', + DummyCapabilities + ) + + # Should raise KeyError due to missing tags in inner_tlv + with pytest.raises(KeyError): + ApplicationInfo.parse(b'\xA4\x01\x00') + + +def test_parse_pubkey_empty(monkeypatch): + monkeypatch.setattr( + 'keycard.parsing.application_info.Capabilities', + DummyCapabilities + ) + # No pubkey bytes + data = bytes([0x80, 0x00]) + info = ApplicationInfo.parse(data) + assert info.ecc_public_key == b'' + assert info.capabilities == 1 # Only CREDENTIALS_MANAGEMENT + assert info.instance_uid is None + assert info.key_uid is None + assert info.version_major == 0 + assert info.version_minor == 0 + + +def test_is_initialized_property(): + info = ApplicationInfo( + capabilities=1, + ecc_public_key=None, + instance_uid=None, + key_uid=None, + version_major=0, + version_minor=0, + ) + assert not info.is_initialized + + info.key_uid = b'\x01' + assert info.is_initialized diff --git a/python/keycard-py/tests/parsing/test_identity.py b/python/keycard-py/tests/parsing/test_identity.py new file mode 100644 index 00000000..f8b16d02 --- /dev/null +++ b/python/keycard-py/tests/parsing/test_identity.py @@ -0,0 +1,87 @@ +import pytest +from keycard.parsing.identity import parse, InvalidResponseError +from keycard.parsing import identity + + +def make_tlv(tag, value): + return bytes([tag, len(value)]) + value + + +def fake_parse_tlv(data): + if data == b'outer': + return {0xA0: [b'inner']} + elif data == b'inner': + return {0x8A: [b'cert'], 0x30: [b'sig']} + return {} + + +def test_parse_success(monkeypatch): + monkeypatch.setattr( + identity, + "parse_tlv", + lambda data: + {0xA0: [b'inner']} if data == b'data' + else {0x8A: [b'c'*95], 0x30: [b's'*64]}) + monkeypatch.setattr( + identity, + "_verify", + lambda certificate, signature, challenge: None + ) + monkeypatch.setattr( + identity, + "_recover_public_key", + lambda certificate: b'pubkey' + ) + + challenge = b'challenge' + data = b'data' + result = parse(challenge, data) + assert result == b'pubkey' + + +def test_parse_malformed_index(monkeypatch): + monkeypatch.setattr( + identity, + "parse_tlv", + lambda data: {0xA0: [b'inner']} if data == b'data' else {} + ) + challenge = b'challenge' + data = b'data' + with pytest.raises( + InvalidResponseError, + match="Malformed identity response" + ): + parse(challenge, data) + + +def test_parse_certificate_too_short(monkeypatch): + monkeypatch.setattr( + identity, + "parse_tlv", + lambda data: + {0xA0: [b'inner']} if data == b'data' + else {0x8A: [b'c'*10], 0x30: [b's'*64]} + ) + challenge = b'challenge' + data = b'data' + with pytest.raises( + InvalidResponseError, + match="Malformed identity response" + ): + parse(challenge, data) + + +def test_parse_signature_too_short(monkeypatch): + monkeypatch.setattr( + identity, + "parse_tlv", + lambda data: + {0xA0: [b'inner']} if data == b'data' + else {0x8A: [b'c'*95], 0x30: [b's'*10]}) + challenge = b'challenge' + data = b'data' + with pytest.raises( + InvalidResponseError, + match="Malformed identity response" + ): + parse(challenge, data) diff --git a/python/keycard-py/tests/parsing/test_signature_result.py b/python/keycard-py/tests/parsing/test_signature_result.py new file mode 100644 index 00000000..4f8d4a3c --- /dev/null +++ b/python/keycard-py/tests/parsing/test_signature_result.py @@ -0,0 +1,117 @@ +from keycard.parsing.signature_result import SignatureResult +from keycard.constants import SigningAlgorithm +from unittest import mock + + +def test_signature_result_with_minimal_r_s(): + r = 1 + s = 1 + digest = b'\x01' * 32 + public_key = b'\x02' + b'\x01' * 32 + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=public_key, + recovery_id=0 + ) + assert sig.r == b'\x01' + assert sig.s == b'\x01' + assert sig.signature == b'\x01\x01' + + +def test_signature_result_with_large_r_s(): + r = 2**255 + s = 2**255 - 1 + digest = b'\x01' * 32 + public_key = b'\x02' + b'\x01' * 32 + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=public_key, + recovery_id=2 + ) + assert sig.r == r.to_bytes((r.bit_length() + 7) // 8, 'big') + assert sig.s == s.to_bytes((s.bit_length() + 7) // 8, 'big') + assert sig.recovery_id == 2 + + +def test_signature_result_signature_der_property(): + r = 123 + s = 456 + digest = b'\x01' * 32 + public_key = b'\x02' + b'\x01' * 32 + + with mock.patch( + 'keycard.parsing.signature_result.util.sigencode_der' + ) as mock_sigencode_der: + mock_sigencode_der.return_value = b'der' + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=public_key, + recovery_id=3 + ) + der = sig.signature_der + assert der == b'der' + mock_sigencode_der.assert_called_once_with(r, s, 3) + + +def test_signature_result_repr_exists(): + r = int.from_bytes(b'\x01' * 32, 'big') + s = int.from_bytes(b'\x01' * 32, 'big') + digest = b'\x01' * 32 + public_key = b'\x02' + b'\x01' * 32 + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=public_key, + recovery_id=0 + ) + assert isinstance(repr(sig), str) + + +def test_signature_result_public_key_and_recovery_id_priority(): + r = 5 + s = 6 + digest = b'\x01' * 32 + public_key = b'\x02' + b'\x01' * 32 + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=public_key, + recovery_id=7 + ) + assert sig.public_key == public_key + assert sig.recovery_id == 7 + + +def test_signature_result_missing_public_key_calls_recover(): + r = 10 + s = 20 + digest = b'\x01' * 32 + with mock.patch.object( + SignatureResult, + "_recover_public_key", + return_value=b'\x02' + b'\x02' * 32 + ) as mock_pubkey: + sig = SignatureResult( + digest=digest, + algo=SigningAlgorithm.ECDSA_SECP256K1, + r=r, + s=s, + public_key=None, + recovery_id=9 + ) + assert sig.public_key == b'\x02' + b'\x02' * 32 + assert sig.recovery_id == 9 + mock_pubkey.assert_called_once_with(digest) diff --git a/python/keycard-py/tests/parsing/test_tlv.py b/python/keycard-py/tests/parsing/test_tlv.py new file mode 100644 index 00000000..f6e44c80 --- /dev/null +++ b/python/keycard-py/tests/parsing/test_tlv.py @@ -0,0 +1,122 @@ +import pytest + +from keycard.exceptions import InvalidResponseError +from keycard.parsing import tlv + + +def test_parse_ber_length_short_form(): + data = bytes([0x05]) + length, consumed = tlv._parse_ber_length(data, 0) + assert length == 5 + assert consumed == 1 + + +def test_parse_ber_length_long_form_1byte(): + data = bytes([0x81, 0x10]) + length, consumed = tlv._parse_ber_length(data, 0) + assert length == 0x10 + assert consumed == 2 + + +def test_parse_ber_length_long_form_2bytes(): + data = bytes([0x82, 0x01, 0xF4]) + length, consumed = tlv._parse_ber_length(data, 0) + assert length == 500 + assert consumed == 3 + + +def test_parse_ber_length_unsupported_length(): + data = bytes([0x85, 0, 0, 0, 0, 0]) + with pytest.raises(InvalidResponseError): + tlv._parse_ber_length(data, 0) + + +def test_parse_ber_length_exceeds_buffer(): + data = bytes([0x82, 0x01]) + with pytest.raises(InvalidResponseError): + tlv._parse_ber_length(data, 0) + + +def test_parse_tlv_single(): + data = bytes([0x01, 0x03, ord('a'), ord('b'), ord('c')]) + result = tlv.parse_tlv(data) + assert 0x01 in result + assert result[0x01][0] == b'abc' + + +def test_parse_tlv_multiple_tags(): + data = bytes([ + 0x01, 0x02, ord('h'), ord('i'), + 0x02, 0x01, ord('x')]) + result = tlv.parse_tlv(data) + assert result[0x01][0] == b'hi' + assert result[0x02][0] == b'x' + + +def test_parse_tlv_repeated_tag(): + data = bytes([ + 0x01, 0x01, ord('a'), + 0x01, 0x02, ord('b'), ord('c') + ]) + result = tlv.parse_tlv(data) + assert result[0x01][0] == b'a' + assert result[0x01][1] == b'bc' + + +def test_parse_tlv_long_length(): + data = bytes([0x10, 0x82, 0x01, 0x01]) + b'a' * 257 + result = tlv.parse_tlv(data) + assert result[0x10][0] == b'a' * 257 + + +def test_parse_tlv_incomplete_value(): + data = bytes([0x01, 0x05, ord('a'), ord('b'), ord('c')]) + with pytest.raises(InvalidResponseError): + tlv.parse_tlv(data) + + +def test_encode_tlv_short(): + tag = 0x01 + value = b'\xAB\xCD' + encoded = tlv.encode_tlv(tag, value) + assert encoded == b'\x01\x02\xAB\xCD' + + +def test_encode_tlv_1byte_long_form(): + tag = 0x02 + value = b'\x00' * 130 # >127 triggers long form + encoded = tlv.encode_tlv(tag, value) + assert encoded[:2] == b'\x02\x81' + assert encoded[2] == 130 + assert encoded[3:] == value + + +def test_encode_tlv_2byte_long_form(): + tag = 0x03 + value = b'\xFF' * 300 # >255 triggers 2-byte length + encoded = tlv.encode_tlv(tag, value) + assert encoded[:3] == b'\x03\x82\x01' + assert encoded[3] == 0x2C # 300 = 0x012C + assert encoded[4:] == value + + +def test_encode_tlv_empty_value(): + tag = 0x04 + value = b"" + encoded = tlv.encode_tlv(tag, value) + assert encoded == b'\x04\x00' + + +def test_encode_tlv_max_short_length(): + tag = 0x10 + value = b"A" * 127 + encoded = tlv.encode_tlv(tag, value) + assert encoded[1] == 127 + assert len(encoded) == 2 + 127 + + +def test_encode_tlv_max_1byte_long_form(): + tag = 0x20 + value = b"A" * 255 + encoded = tlv.encode_tlv(tag, value) + assert encoded[1:3] == b'\x81\xFF' diff --git a/python/keycard-py/tests/test_apdu.py b/python/keycard-py/tests/test_apdu.py new file mode 100644 index 00000000..0d4c83f4 --- /dev/null +++ b/python/keycard-py/tests/test_apdu.py @@ -0,0 +1,51 @@ +import pytest +from keycard.apdu import encode_lv, APDUResponse + + +def test_encode_lv_valid(): + value = bytes(10) + result = encode_lv(value) + assert result == b"\x0A" + value + + +def test_encode_lv_too_long(): + value = bytes(256) + with pytest.raises(ValueError): + encode_lv(value) + + +def test_encode_lv_empty(): + value = bytes() + result = encode_lv(value) + assert result == b"\x00" + + +def test_encode_lv_single_byte(): + value = bytes([0xFF]) + result = encode_lv(value) + assert result == b"\x01\xFF" + + +def test_encode_lv_max_length(): + value = bytes(255) + result = encode_lv(value) + assert result == b"\xFF" + value + + +def test_apdu_response_success(): + r = APDUResponse([0x01, 0x02], 0x9000) + assert r.data == [0x01, 0x02] + assert r.status_word == 0x9000 + + +def test_apdu_response_error_status(): + r = APDUResponse([], 0x6A82) + assert r.status_word == 0x6A82 + assert isinstance(r.status_word, int) + + +def test_apdu_response_all_status_range(): + for sw in [0x9000, 0x6A80, 0x6A84, 0x6982]: + r = APDUResponse([0x00], sw) + assert r.status_word == sw + assert r.data == [0x00] diff --git a/python/keycard-py/tests/test_keycard.py b/python/keycard-py/tests/test_keycard.py new file mode 100644 index 00000000..d84dcead --- /dev/null +++ b/python/keycard-py/tests/test_keycard.py @@ -0,0 +1,531 @@ +import pytest +from unittest.mock import MagicMock, patch + +from keycard import constants +from keycard.apdu import APDUResponse +from keycard.exceptions import APDUError +from keycard.parsing.exported_key import ExportedKey +from keycard.keycard import KeyCard +from keycard.transport import Transport + + +def test_keycard_init_with_transport(): + transport = MagicMock(spec=Transport) + kc = KeyCard(transport) + assert kc.transport == transport + assert kc.card_public_key is None + assert kc.session is None + + +def test_select_sets_card_pubkey(): + mock_info = MagicMock() + mock_info.ecc_public_key = b'pubkey' + with patch('keycard.keycard.commands.select', return_value=mock_info): + kc = KeyCard(MagicMock()) + result = kc.select() + assert kc.card_public_key == b'pubkey' + assert result == mock_info + + +def test_init_calls_command(): + transport = MagicMock() + with patch('keycard.keycard.commands.init') as mock_init: + kc = KeyCard(transport) + kc.card_public_key = b'pub' + kc.init(b'pin', b'puk', b'secret') + mock_init.assert_called_once_with(kc, b'pin', b'puk', b'secret') + + +def test_ident_calls_command(): + with patch('keycard.keycard.commands.ident', return_value='identity') as m: + kc = KeyCard(MagicMock()) + result = kc.ident(b'challenge') + m.assert_called_once() + assert result == 'identity' + + +def test_open_secure_channel_with_mutual_authentication(): + with patch( + 'keycard.keycard.commands.open_secure_channel' + ) as mock_osc: + with patch( + 'keycard.keycard.commands.mutually_authenticate' + ) as mock_ma: + mock_osc.return_value = 'session' + kc = KeyCard(MagicMock()) + kc._card_public_key = b'pub' + kc.open_secure_channel(1, b'pairing_key') + mock_osc.assert_called_once_with(kc, 1, b'pairing_key') + mock_ma.assert_called_once_with(kc) + assert kc.session == 'session' + + +def test_open_secure_channel_without_mutual_authentication(): + with patch( + 'keycard.keycard.commands.open_secure_channel' + ) as mock_osc: + with patch( + 'keycard.keycard.commands.mutually_authenticate' + ) as mock_ma: + mock_osc.return_value = 'session' + kc = KeyCard(MagicMock()) + kc._card_public_key = b'pub' + kc.open_secure_channel(1, b'pairing_key', False) + mock_osc.assert_called_once_with(kc, 1, b'pairing_key') + mock_ma.assert_not_called() + assert kc.session == 'session' + + +def test_mutually_authenticate_calls_command(): + with patch('keycard.keycard.commands.mutually_authenticate') as mock_auth: + kc = KeyCard(MagicMock()) + kc.secure_session = 'sess' + kc.mutually_authenticate() + mock_auth.assert_called_once() + + +def test_pair_returns_expected_tuple(): + with patch('keycard.keycard.commands.pair', return_value=(1, b'crypt')): + kc = KeyCard(MagicMock()) + result = kc.pair(b'shared') + assert result == (1, b'crypt') + + +def test_verify_pin_delegates_call_and_returns_result(): + with patch( + 'keycard.keycard.commands.verify_pin', + return_value=True + ) as mock_cmd: + kc = KeyCard(MagicMock()) + kc.secure_session = 'sess' + result = kc.verify_pin('1234') + mock_cmd.assert_called_once_with(kc, b'1234') + assert result is True + + +def test_unpair_delegates_call(): + transport = MagicMock() + with patch('keycard.keycard.commands.unpair') as mock_unpair: + kc = KeyCard(transport) + kc.secure_session = 'sess' + kc.unpair(2) + mock_unpair.assert_called_once_with(kc, 2) + + +def test_send_secure_apdu_success(): + mock_session = MagicMock() + mock_session.wrap_apdu.return_value = b'encrypted' + mock_session.unwrap_response.return_value = (b'plaintext', 0x9000) + mock_transport = MagicMock() + mock_response = MagicMock() + mock_response.status_word = 0x9000 + mock_response.data = b'ciphertext' + mock_transport.send_apdu.return_value = mock_response + + kc = KeyCard(mock_transport) + kc.session = mock_session + + result = kc.send_secure_apdu(0xA4, 0x01, 0x02, b'data') + + mock_session.wrap_apdu.assert_called_once_with( + cla=kc.transport.send_apdu.call_args[0][0][0], + ins=0xA4, + p1=0x01, + p2=0x02, + data=b'data' + ) + mock_transport.send_apdu.assert_called_once() + mock_session.unwrap_response.assert_called_once_with(mock_response) + assert result == b'plaintext' + + +def test_send_secure_apdu_raises_on_transport_status_word(): + mock_session = MagicMock() + mock_session.wrap_apdu.return_value = b'encrypted' + mock_transport = MagicMock() + mock_transport.send_apdu.return_value = APDUResponse( + b'', status_word=0x6A82) + + kc = KeyCard(mock_transport) + kc.session = mock_session + + with pytest.raises(APDUError) as exc: + kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data') + assert exc.value.args[0] == 'APDU failed with SW=6A82' + + +def test_send_secure_apdu_raises_on_unwrap_status_word(): + mock_session = MagicMock() + mock_session.wrap_apdu.return_value = b'encrypted' + mock_session.unwrap_response.return_value = (b'plaintext', 0x6A84) + mock_transport = MagicMock() + mock_transport.send_apdu.return_value = APDUResponse( + b'', status_word=0x9000) + + kc = KeyCard(mock_transport) + kc.session = mock_session + + with pytest.raises(APDUError) as exc: + kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data') + assert exc.value.args[0] == 'APDU failed with SW=6A84' + + +def test_send_apdu_success(monkeypatch): + mock_transport = MagicMock() + mock_response = MagicMock() + mock_response.status_word = 0x9000 + mock_response.data = b'response' + mock_transport.send_apdu.return_value = mock_response + + kc = KeyCard(mock_transport) + + result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data') + expected_apdu = bytes([0x80, 0xA4, 0x01, 0x02, 4]) + b'data' + mock_transport.send_apdu.assert_called_once_with(expected_apdu) + assert result == b'response' + + +def test_send_apdu_raises_on_non_success_status(monkeypatch): + mock_transport = MagicMock() + mock_transport.send_apdu.return_value = APDUResponse(b'', 0x6A82) + + kc = KeyCard(mock_transport) + + with pytest.raises(APDUError) as exc: + kc.send_apdu(ins=0xA4, p1=0x00, p2=0x00, data=b'') + assert exc.value.args[0] == 'APDU failed with SW=6A82' + + +def test_send_apdu_with_custom_cla(monkeypatch): + mock_transport = MagicMock() + mock_response = MagicMock() + mock_response.status_word = 0x9000 + mock_response.data = b'abc' + mock_transport.send_apdu.return_value = mock_response + + kc = KeyCard(mock_transport) + + result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data', cla=0x90) + expected_apdu = bytes([0x90, 0xA4, 0x01, 0x02, 4]) + b'data' + mock_transport.send_apdu.assert_called_once_with(expected_apdu) + assert result == b'abc' + + +def test_unblock_pin_calls_command_with_bytes(): + with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: + kc = KeyCard(MagicMock()) + puk = b'123456789012' + new_pin = b'654321' + kc.unblock_pin(puk, new_pin) + mock_unblock.assert_called_once_with(kc, puk + new_pin) + + +def test_unblock_pin_calls_command_with_str(): + with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: + kc = KeyCard(MagicMock()) + puk = '123456789012' + new_pin = '654321' + kc.unblock_pin(puk, new_pin) + mock_unblock.assert_called_once_with( + kc, + (puk + new_pin).encode('utf-8') + ) + + +def test_unblock_pin_calls_command_with_mixed_types(): + with patch('keycard.keycard.commands.unblock_pin') as mock_unblock: + kc = KeyCard(MagicMock()) + puk = '123456789012' + new_pin = b'654321' + kc.unblock_pin(puk, new_pin) + mock_unblock.assert_called_once_with(kc, puk.encode('utf-8') + new_pin) + + +def test_remove_key_calls_command(): + with patch('keycard.keycard.commands.remove_key') as mock_remove_key: + kc = KeyCard(MagicMock()) + kc.remove_key() + mock_remove_key.assert_called_once_with(kc) + + +def test_store_data_calls_command_with_default_slot(): + with patch('keycard.keycard.commands.store_data') as mock_store_data: + kc = KeyCard(MagicMock()) + data = b'testdata' + kc.store_data(data) + mock_store_data.assert_called_once_with( + kc, data, constants.StorageSlot.PUBLIC + ) + + +def test_store_data_calls_command_with_custom_slot(): + with patch('keycard.keycard.commands.store_data') as mock_store_data: + kc = KeyCard(MagicMock()) + data = b'testdata' + slot = MagicMock() + kc.store_data(data, slot) + mock_store_data.assert_called_once_with(kc, data, slot) + + +def test_store_data_raises_value_error_on_invalid_slot(): + with patch( + 'keycard.keycard.commands.store_data', + side_effect=ValueError("Invalid slot") + ): + kc = KeyCard(MagicMock()) + with pytest.raises(ValueError, match="Invalid slot"): + kc.store_data(b'testdata', slot="INVALID") + + +def test_store_data_raises_value_error_on_data_too_long(): + with patch( + 'keycard.keycard.commands.store_data', + side_effect=ValueError("data is too long") + ): + kc = KeyCard(MagicMock()) + long_data = b'a' * 128 + with pytest.raises(ValueError, match="data is too long"): + kc.store_data(long_data) + + +def test_get_data_calls_command_with_default_slot(): + with patch( + 'keycard.keycard.commands.get_data', + return_value=b'data' + ) as mock_get_data: + kc = KeyCard(MagicMock()) + result = kc.get_data() + mock_get_data.assert_called_once_with(kc, constants.StorageSlot.PUBLIC) + assert result == b'data' + + +def test_get_data_calls_command_with_custom_slot(): + with patch( + 'keycard.keycard.commands.get_data', + return_value=b'data' + ) as mock_get_data: + kc = KeyCard(MagicMock()) + slot = MagicMock() + result = kc.get_data(slot) + mock_get_data.assert_called_once_with(kc, slot) + assert result == b'data' + + +def test_export_key_delegates_and_returns_result(): + mock_exported = MagicMock(spec=ExportedKey) + with patch( + 'keycard.keycard.commands.export_key', + return_value=mock_exported + ) as mock_cmd: + kc = KeyCard(MagicMock()) + result = kc.export_key( + derivation_option=constants.DerivationOption.DERIVE, + public_only=True, + keypath="m/44'/60'/0'/0/0", + make_current=True, + source=constants.DerivationSource.PARENT + ) + + mock_cmd.assert_called_once_with( + kc, + derivation_option=constants.DerivationOption.DERIVE, + public_only=True, + keypath="m/44'/60'/0'/0/0", + make_current=True, + source=constants.DerivationSource.PARENT + ) + assert result is mock_exported + + +def test_export_current_key_delegates_and_returns_result(): + mock_exported = MagicMock(spec=ExportedKey) + with patch( + 'keycard.keycard.commands.export_key', + return_value=mock_exported + ) as mock_cmd: + kc = KeyCard(MagicMock()) + result = kc.export_current_key(public_only=False) + + mock_cmd.assert_called_once_with( + kc, + derivation_option=constants.DerivationOption.CURRENT, + public_only=False, + keypath=None, + make_current=False, + source=constants.DerivationSource.MASTER + ) + assert result is mock_exported + + +def test_sign_current_key(): + with patch("keycard.keycard.commands.sign") as mock_sign: + card = KeyCard(MagicMock()) + digest = b"\xAA" * 32 + mock_sign.return_value = "signed" + + result = card.sign(digest) + + mock_sign.assert_called_once_with( + card, + digest, + constants.DerivationOption.CURRENT, + constants.SigningAlgorithm.ECDSA_SECP256K1 + ) + assert result == "signed" + + +def test_sign_with_path(): + with patch("keycard.keycard.commands.sign") as mock_sign: + card = KeyCard(MagicMock()) + digest = b"\xBB" * 32 + path = [0x8000002C, 0x8000003C, 0, 0, 0] # m/44'/60'/0'/0/0 + mock_sign.return_value = "sig" + + result = card.sign_with_path(digest, path) + + mock_sign.assert_called_once_with( + card, + digest, + constants.DerivationOption.DERIVE, + constants.SigningAlgorithm.ECDSA_SECP256K1, + derivation_path=path + ) + assert result == "sig" + + +def test_sign_with_path_make_current(): + with patch("keycard.keycard.commands.sign") as mock_sign: + card = KeyCard(MagicMock()) + digest = b"\xCC" * 32 + path = [0x8000002C, 0x8000003C, 0, 0, 0] + mock_sign.return_value = "sig" + + result = card.sign_with_path(digest, path, make_current=True) + + mock_sign.assert_called_once_with( + card, + digest, + constants.DerivationOption.DERIVE_AND_MAKE_CURRENT, + constants.SigningAlgorithm.ECDSA_SECP256K1, + derivation_path=path + ) + assert result == "sig" + + +def test_sign_pinless(): + with patch("keycard.keycard.commands.sign") as mock_sign: + card = KeyCard(MagicMock()) + digest = b"\xDD" * 32 + mock_sign.return_value = "sig" + + result = card.sign_pinless(digest) + + mock_sign.assert_called_once_with( + card, + digest, + constants.DerivationOption.PINLESS, + constants.SigningAlgorithm.ECDSA_SECP256K1 + ) + assert result == "sig" + + +def test_load_key_bip39_seed(): + with patch("keycard.keycard.commands.load_key") as mock_load_key: + card = KeyCard(MagicMock()) + seed = b"\xAB" * 64 + mock_load_key.return_value = b"uid" + + result = card.load_key( + key_type=constants.LoadKeyType.BIP39_SEED, + bip39_seed=seed + ) + + mock_load_key.assert_called_once_with( + card, + key_type=constants.LoadKeyType.BIP39_SEED, + public_key=None, + private_key=None, + chain_code=None, + bip39_seed=seed, + lee_seed=None + ) + assert result == b"uid" + + +def test_load_key_ecc_pair(): + with patch("keycard.keycard.commands.load_key") as mock_load_key: + card = KeyCard(MagicMock()) + pub = b"\x04" + b"\x01" * 64 + priv = b"\x02" * 32 + mock_load_key.return_value = b"uid" + + result = card.load_key( + key_type=constants.LoadKeyType.ECC, + public_key=pub, + private_key=priv + ) + + mock_load_key.assert_called_once_with( + card, + key_type=constants.LoadKeyType.ECC, + public_key=pub, + private_key=priv, + chain_code=None, + bip39_seed=None, + lee_seed=None + ) + assert result == b"uid" + + +def test_load_key_extended(): + with patch("keycard.keycard.commands.load_key") as mock_load_key: + card = KeyCard(MagicMock()) + pub = b"\x04" + b"\x01" * 64 + priv = b"\x02" * 32 + chain = b"\x00" * 32 + mock_load_key.return_value = b"uid" + + result = card.load_key( + key_type=constants.LoadKeyType.EXTENDED_ECC, + public_key=pub, + private_key=priv, + chain_code=chain + ) + + mock_load_key.assert_called_once_with( + card, + key_type=constants.LoadKeyType.EXTENDED_ECC, + public_key=pub, + private_key=priv, + chain_code=chain, + bip39_seed=None, + lee_seed=None + ) + assert result == b"uid" + + +def test_keycard_set_pinless_path(): + with patch("keycard.keycard.commands.set_pinless_path") as mock_cmd: + card = KeyCard(MagicMock()) + card.set_pinless_path("m/44'/60'/0'/0/0") + + mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0") + + +def test_keycard_generate_mnemonic(): + with patch("keycard.keycard.commands.generate_mnemonic") as mock_cmd: + card = KeyCard(None) + mock_cmd.return_value = [0, 2047, 1337, 42] + + result = card.generate_mnemonic(checksum_size=6) + + mock_cmd.assert_called_once_with(card, 6) + assert result == [0, 2047, 1337, 42] + + +def test_keycard_derive_key(): + with patch("keycard.keycard.commands.derive_key") as mock_cmd: + card = KeyCard(MagicMock()) + card.derive_key("m/44'/60'/0'/0/0") + + mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0") diff --git a/python/keycard-py/tests/test_preconditions.py b/python/keycard-py/tests/test_preconditions.py new file mode 100644 index 00000000..1c46bd15 --- /dev/null +++ b/python/keycard-py/tests/test_preconditions.py @@ -0,0 +1,56 @@ +import pytest +from keycard.card_interface import CardInterface +from keycard.preconditions import make_precondition +from keycard.exceptions import InvalidStateError + + +class DummyCard(CardInterface): + def __init__(self, **attrs): + for k, v in attrs.items(): + setattr(self, k, v) + + +def test_precondition_passes_when_attribute_true(): + @make_precondition('is_ready') + def do_something(card): + return "success" + card = DummyCard(is_ready=True) + assert do_something(card) == "success" + + +def test_precondition_raises_when_attribute_false(): + @make_precondition('is_ready') + def do_something(card): + return "should not reach" + card = DummyCard(is_ready=False) + with pytest.raises(InvalidStateError) as exc: + do_something(card) + assert "Is Ready must be satisfied." in str(exc.value) + + +def test_precondition_raises_when_attribute_missing(): + @make_precondition('is_ready') + def do_something(card): + return "should not reach" + card = DummyCard() + with pytest.raises(InvalidStateError) as exc: + do_something(card) + assert "Is Ready must be satisfied." in str(exc.value) + + +def test_precondition_custom_display_name(): + @make_precondition('is_ready', display_name="Custom Name") + def do_something(card): + return "success" + card = DummyCard(is_ready=False) + with pytest.raises(InvalidStateError) as exc: + do_something(card) + assert "Custom Name must be satisfied." in str(exc.value) + + +def test_precondition_passes_args_kwargs(): + @make_precondition('is_ready') + def do_something(card, x, y=2): + return x + y + card = DummyCard(is_ready=True) + assert do_something(card, 3, y=4) == 7 diff --git a/python/keycard-py/tests/test_secure_channel.py b/python/keycard-py/tests/test_secure_channel.py new file mode 100644 index 00000000..2622f9e8 --- /dev/null +++ b/python/keycard-py/tests/test_secure_channel.py @@ -0,0 +1,132 @@ +import pytest + +from keycard.apdu import APDUResponse +from keycard.secure_channel import SecureChannel + + +@pytest.fixture +def session_params(): + return { + "shared_secret": bytes(32), + "pairing_key": bytes(32), + "salt": bytes(16), + "seed_iv": bytes(16), + } + + +def test_open_sets_authenticated_and_keys(session_params): + session = SecureChannel.open(**session_params) + assert session.authenticated is True + assert isinstance(session.enc_key, bytes) and len(session.enc_key) == 32 + assert isinstance(session.mac_key, bytes) and len(session.mac_key) == 32 + assert session.iv == session_params['seed_iv'] + + +def test_wrap_apdu_authenticated(session_params): + session = SecureChannel.open(**session_params) + wrapped = session.wrap_apdu( + 0x80, + 0xCA, + 0x00, + 0x00, + b'testdata' + ) + assert isinstance(wrapped, bytes) + assert len(wrapped) > 16 # IV + encrypted data + + +@pytest.mark.parametrize("ins,should_raise", [ + (0x11, False), + (0xCA, True), +]) +def test_wrap_apdu_auth_check(ins, should_raise): + session = SecureChannel( + b'\x01' * 32, + b'\x02' * 32, + bytes(16), + authenticated=False + ) + if should_raise: + with pytest.raises(ValueError, match="not authenticated"): + session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test') + else: + session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test') + + +def test_unwrap_response_authenticated_and_mac(monkeypatch, session_params): + # Patch aes_cbc_encrypt and aes_cbc_decrypt to simulate expected behavior + session = SecureChannel.open(**session_params) + plaintext = b"hello world" + b'\x90\x00' # status word 0x9000 + + # Simulate encryption and MAC + + def fake_decrypt(key, iv, data): + return plaintext + + def fake_encrypt(key, iv, data, padding=True): + # Return 16 bytes MAC for mac_key, else just return dummy + if key == session.mac_key: + return b'Y' * 16 + return b'Z' * (len(data) // 16 * 16) + + monkeypatch.setattr('keycard.secure_channel.aes_cbc_decrypt', fake_decrypt) + monkeypatch.setattr('keycard.secure_channel.aes_cbc_encrypt', fake_encrypt) + + # Compose response: 16 bytes MAC + encrypted data + response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900) + out, sw = session.unwrap_response(response) + assert out == plaintext[:-2] + assert sw == 0x9000 + + +def test_unwrap_response_not_authenticated_raises(session_params): + session = SecureChannel.open(**session_params) + session.authenticated = False + response = APDUResponse(bytes(32), 0x900) + with pytest.raises(ValueError, match="not authenticated"): + session.unwrap_response(response) + + +def test_unwrap_response_invalid_length_raises(session_params): + session = SecureChannel.open(**session_params) + session.authenticated = True + response = APDUResponse(bytes(10), 0x900) + with pytest.raises(ValueError, match="Invalid secure response length"): + session.unwrap_response(response) + + +def test_unwrap_response_invalid_mac_raises(monkeypatch, session_params): + session = SecureChannel.open(**session_params) + # Patch aes_cbc_encrypt to return a different MAC + + def fake_encrypt(key, iv, data, padding=True): + return b'X' * 16 + + monkeypatch.setattr( + 'keycard.secure_channel.aes_cbc_encrypt', + fake_encrypt + ) + + response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900) + + with pytest.raises(ValueError, match="Invalid MAC"): + session.unwrap_response(response) + + +def test_unwrap_response_missing_status_word(monkeypatch, session_params): + session = SecureChannel.open(**session_params) + + def fake_decrypt(key, iv, data): + return b'\x01' + + monkeypatch.setattr( + 'keycard.secure_channel.aes_cbc_decrypt', + fake_decrypt) + monkeypatch.setattr( + 'keycard.secure_channel.aes_cbc_encrypt', + lambda *a, **k: b'Y' * 16) + + response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x9000) + + with pytest.raises(ValueError, match="Missing status word"): + session.unwrap_response(response) diff --git a/python/keycard-py/tests/test_transport.py b/python/keycard-py/tests/test_transport.py new file mode 100644 index 00000000..f3ecf6fc --- /dev/null +++ b/python/keycard-py/tests/test_transport.py @@ -0,0 +1,87 @@ +import pytest +from unittest.mock import MagicMock, patch +from keycard.transport import Transport +from keycard.apdu import APDUResponse +from keycard.exceptions import TransportError + + +@patch("keycard.transport.readers") +def test_transport_connect_success(mock_readers): + mock_connection = MagicMock() + mock_reader = MagicMock() + mock_reader.createConnection.return_value = mock_connection + mock_readers.return_value = [mock_reader] + + transport = Transport() + transport.connect() + + mock_readers.assert_called_once() + mock_connection.connect.assert_called_once() + assert transport.connection == mock_connection + + +@patch("keycard.transport.readers", return_value=[]) +def test_transport_connect_no_reader(mock_readers): + transport = Transport() + with pytest.raises(TransportError, match="No smart card readers found"): + transport.connect() + + +@patch("keycard.transport.readers") +def test_send_apdu_success(mock_readers): + mock_connection = MagicMock() + mock_connection.transmit.return_value = ([1, 2, 3], 0x90, 0x00) + + mock_reader = MagicMock() + mock_reader.createConnection.return_value = mock_connection + mock_readers.return_value = [mock_reader] + + transport = Transport() + transport.connection = mock_connection + + apdu = b"\x00\xA4\x04\x00" + response = transport.send_apdu(apdu) + + mock_connection.transmit.assert_called_once_with(list(apdu)) + assert isinstance(response, APDUResponse) + assert response.data == [1, 2, 3] + assert response.status_word == 0x9000 + + +@patch("keycard.transport.readers") +def test_send_apdu_auto_connect(mock_readers): + mock_connection = MagicMock() + mock_connection.transmit.return_value = ([0x90], 0x90, 0x00) + + mock_reader = MagicMock() + mock_reader.createConnection.return_value = mock_connection + mock_readers.return_value = [mock_reader] + + transport = Transport() + response = transport.send_apdu(b"\x00") + + assert isinstance(response, APDUResponse) + assert response.status_word == 0x9000 + assert mock_connection.connect.called + + +@patch("keycard.transport.readers") +def test_transport_context_manager(mock_readers): + mock_connection = MagicMock() + mock_reader = MagicMock() + mock_reader.createConnection.return_value = mock_connection + mock_readers.return_value = [mock_reader] + + with Transport() as transport: + assert transport.connection == mock_connection + + mock_connection.disconnect.assert_called_once() + assert transport.connection is None + + +def test_exit_without_connection(): + transport = Transport() + transport.connection = None + + transport.__exit__(None, None, None) + assert transport.connection is None diff --git a/python/keycard-py/tests/test_vectors.py b/python/keycard-py/tests/test_vectors.py new file mode 100644 index 00000000..de390d0a --- /dev/null +++ b/python/keycard-py/tests/test_vectors.py @@ -0,0 +1,50 @@ +from ecdsa import ECDH, SigningKey, SECP256k1, VerifyingKey +from keycard.crypto.aes import aes_cbc_encrypt + + +def test_full_crypto_vector(): + card_pubkey_bytes = bytes.fromhex( + '04525481c70263f79c29092e95cfc972e0eb427ea31fe6cc6c96787eb12205737' + 'd431929f0837c66a4ee514578a7d5eb78087927851b15b691a79cdea431bd63d9' + ) + ephemeral_private_bytes = bytes.fromhex( + 'e3b9a83efa7b113bac4562a77c496de21a9f91a17fa8dcb2384ed7154bb43c5c' + ) + iv = bytes.fromhex('d2c5feedf4bdb935057f8c78cf92395e') + expected_ciphertext = bytes.fromhex( + '4707ca7edf4218c416f252967da55f1b6e2e65f0ffa0305f71501f53aa283fd5' + 'aaa8b049e75288c01034f25893db43d4db4bd6dfc4a6546658dd22227082aa58' + ) + + ephemeral_key = SigningKey.from_string( + ephemeral_private_bytes, + curve=SECP256k1 + ) + card_pubkey = VerifyingKey.from_string( + card_pubkey_bytes, + curve=SECP256k1 + ) + + ecdh = ECDH( + curve=SECP256k1, + private_key=ephemeral_key, + public_key=card_pubkey + ) + shared_secret = ecdh.generate_sharedsecret_bytes() + + pin = b'123456' + puk = b'123456789012' + pairing_secret = b'A' * 32 + plaintext = pin + puk + pairing_secret + + ciphertext: bytes = aes_cbc_encrypt(shared_secret, iv, plaintext) + + assert ciphertext == expected_ciphertext, ( + "Ciphertext does not match expected test vector" + ) + + +def test_crypto_vector_fails_on_mismatch(): + bogus = b"\x00" * 48 + actual = b"\x01" * 48 + assert bogus != actual, "Test vector should intentionally fail mismatch" diff --git a/python/keycard-py/tox.ini b/python/keycard-py/tox.ini new file mode 100644 index 00000000..e2a96ef0 --- /dev/null +++ b/python/keycard-py/tox.ini @@ -0,0 +1,48 @@ +[tox] +envlist = py310,py311,py312,py313,py314 +isolated_build = True +skip_missing_interpreters = True + +[testenv] +description = Run tests with pytest +basepython = + py310: python3.10 + py311: python3.11 + py312: python3.12 + py313: python3.13 + py314: python3.14 +deps = + pytest + pytest-cov + coverage + mnemonic +extras = dev +commands = + pytest --maxfail=1 --disable-warnings {posargs} + +[testenv:lint] +description = Run linting checks +deps = + flake8 + mypy +commands = + flake8 keycard tests + mypy keycard + +[testenv:coverage] +description = Run tests with coverage report +deps = + pytest + pytest-cov + coverage + mnemonic +commands = + pytest --cov=keycard --cov-report=term-missing --cov-report=xml + +[gh-actions] +python = + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314 diff --git a/python/keycard_test.py b/python/keycard_test.py new file mode 100644 index 00000000..df4957aa --- /dev/null +++ b/python/keycard_test.py @@ -0,0 +1,24 @@ +import keycard_wallet as keycard_wallet +import time # For testing + +pin = '111111' +path0 = "m/44'/60'/0'/0/0" +path1 = "m/44'/61'/0'/0/0" +path2 = "m/44'/62'/0'/0/0" +path3 = "m/44'/63'/0'/0/0" +path4 = "m/44'/64'/0'/0/0" + +my_wallet = keycard_wallet.KeycardWallet() +print("Setup communication with card...", my_wallet.setup_communication(pin)) + +print("Load mnemonic...", my_wallet.load_mnemonic()) + +print("Public key", my_wallet.get_public_key_for_path(path0)) +print("Public key", my_wallet.get_public_key_for_path(path1)) +print("Public key", my_wallet.get_public_key_for_path(path2)) +print("Public key", my_wallet.get_public_key_for_path(path3)) +print("Public key", my_wallet.get_public_key_for_path(path4)) + +print("Signature", my_wallet.sign_message_for_path()) + +print("Disconnection", my_wallet.disconnect()) \ No newline at end of file diff --git a/python/keycard_wallet.py b/python/keycard_wallet.py new file mode 100644 index 00000000..1a85af06 --- /dev/null +++ b/python/keycard_wallet.py @@ -0,0 +1,137 @@ +from smartcard.System import readers +from keycard.exceptions import APDUError, TransportError +from ecdsa import VerifyingKey, SECP256k1 + +from keycard.keycard import KeyCard + +from mnemonic import Mnemonic +from keycard import constants + +import keycard + +PIN = '123456' +PUK = '123456123456' +DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" +DEFAULT_MNEMONIC = "fashion degree mountain wool question damp current pond grow dolphin chronic then" +DEFAULT_PASSPHRASE = "" + +class KeycardWallet: + def __init__(self): + self.card = KeyCard() + self.pairing_index = None + self.pairing_key = None + + def _is_smart_card_reader_detected(self) -> bool: + try: + return len(readers()) > 0 + except Exception: + return False + + def _is_keycard_detected(self) -> bool: + try: + KeyCard().select() + return True + except (TransportError, APDUError, Exception): + # No readers, no card, or card doesn't respond. + return False + + # Wrapped + def is_unpaired_keycard_available(self) -> bool: + if not self._is_smart_card_reader_detected(): + return False + elif not self._is_keycard_detected(): + return False + return True + + # Wrapped + def setup_communication(self, pin = PIN, password = DEFAULT_PAIRING_PASSWORD) -> bool: + try: + self.card.select() + + if not self.card.is_initialized: + return False + + if self.pairing_index is None: + pairing_index, pairing_key = self.card.pair(password) + self.pairing_index = pairing_index + self.pairing_key = pairing_key + + + self.card.open_secure_channel(pairing_index, pairing_key) + self.card.verify_pin(pin) + + return True + except Exception as e: + print(f"Error: {e}") + return False + + def load_mnemonic(self, mnemonic = DEFAULT_MNEMONIC, passphrase = DEFAULT_PASSPHRASE) -> bool: + try: + # Convert mnemonic to seed + mnemo = Mnemonic("english") + seed = mnemo.to_seed(mnemonic, passphrase) + + # Load the LEE seed onto the card + result = self.card.load_key( + key_type = constants.LoadKeyType.BIP39_SEED, + lee_seed = seed + ) + + return True + except Exception as e: + print(f"Error during disconnect: {e}") + return False + + def disconnect(self) -> bool: + try: + if not self.card.is_secure_channel_open: + return None + + self.card.unpair(self.pairing_index) + self.pairing_index = None + self.pairing_key = None + + return True + except Exception as e: + print(f"Error during unpair: {e}") + return False + + def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: + try: + if not self.card.is_secure_channel_open or not self.card.is_pin_verified: + return None + + public_key = self.card.export_key( + derivation_option = constants.DerivationOption.DERIVE, + public_only = True, + keypath = path + ) + + public_key = public_key.public_key + public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1) + public_key = public_key.to_string("compressed")[1:] + + return public_key + + except Exception as e: + print(f"Error getting public key: {e}") + return None + + + def sign_message_for_path(self, message: bytes = b"DefaultMessageTestDefaultMessage", path: str = "m/44'/60'/0'/0/0") -> bytes | None: + try: + if not self.card.is_secure_channel_open or not self.card.is_pin_verified: + return None + + signature = self.card.sign_with_path( + digest = message, + path = path, + algorithm = constants.SigningAlgorithm.SCHNORR_BIP340, + make_current = False + ) + + return signature.signature + + except Exception as e: + print(f"Error signing message: {e}") + return None \ No newline at end of file diff --git a/wallet-ffi/src/transfer.rs b/wallet-ffi/src/transfer.rs index 739832ae..e65fa473 100644 --- a/wallet-ffi/src/transfer.rs +++ b/wallet-ffi/src/transfer.rs @@ -72,7 +72,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.send_public_transfer(from_id, to_id, amount)) { + match block_on(transfer.send_public_transfer(from_id, to_id, amount, &None, &None)) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -566,7 +566,7 @@ pub unsafe extern "C" fn wallet_ffi_register_public_account( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.register_account(account_id)) { + match block_on(transfer.register_account(account_id, &None, &None)) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 4e98b8ef..8e94ec2f 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -18,6 +18,7 @@ amm_core.workspace = true testnet_initial_state.workspace = true ata_core.workspace = true bip39.workspace = true +pyo3 = { version = "0.21", features = ["auto-initialize"] } anyhow.workspace = true thiserror.workspace = true @@ -39,3 +40,4 @@ async-stream.workspace = true indicatif = { version = "0.18.3", features = ["improved_unicode"] } optfield = "0.4.0" url.workspace = true +keycard_wallet.workspace = true \ No newline at end of file diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 86ae7e35..04050a79 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -28,16 +28,15 @@ pub enum AccountSubcommand { #[arg(short, long)] keys: bool, /// Valid 32 byte base58 string with privacy prefix. - #[arg( - short, - long, - conflicts_with = "account_label", - required_unless_present = "account_label" - )] + #[arg(short, long, conflicts_with = "account_label")] account_id: Option, /// Account label (alternative to --account-id). #[arg(long, conflicts_with = "account_id")] account_label: Option, + #[arg(long, conflicts_with = "account_id", conflicts_with = "account_id")] + pin: Option, + #[arg(long, conflicts_with = "account_id", conflicts_with = "account_id")] + key_path: Option, }, /// Produce new public or private account. #[command(subcommand)] @@ -191,17 +190,23 @@ impl WalletSubcommand for AccountSubcommand { keys, account_id, account_label, + pin, + key_path, } => { let resolved = resolve_id_or_label( account_id, account_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &pin, + &key_path, )?; let (account_id_str, addr_kind) = parse_addr_with_privacy_prefix(&resolved)?; let account_id: nssa::AccountId = account_id_str.parse()?; + println!("Account Id: {}", resolved); + if let Some(label) = wallet_core.storage.labels.get(&account_id_str) { println!("Label: {label}"); } @@ -407,6 +412,8 @@ impl WalletSubcommand for AccountSubcommand { account_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (account_id_str, _) = parse_addr_with_privacy_prefix(&resolved)?; diff --git a/wallet/src/cli/keycard.rs b/wallet/src/cli/keycard.rs new file mode 100644 index 00000000..8c825458 --- /dev/null +++ b/wallet/src/cli/keycard.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use clap::Subcommand; +use keycard_wallet::{KeycardWallet, python_path}; +use pyo3::prelude::*; + +use crate::{ + WalletCore, + cli::{SubcommandReturnValue, WalletSubcommand}, +}; + +/// Represents generic chain CLI subcommand. +#[derive(Subcommand, Debug, Clone)] +pub enum KeycardSubcommand { + Available, + Load { + #[arg(short, long)] + mnemonic: Option, + #[arg(short, long)] + pin: Option, + }, +} + +impl WalletSubcommand for KeycardSubcommand { + #[expect(clippy::cognitive_complexity, reason = "TODO: fix later")] + async fn handle_subcommand( + self, + _wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::Available => { + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + let available = wallet + .is_unpaired_keycard_available(py) + .expect("Expect a Boolean."); + + if available { + println!("\u{2705} Keycard is available."); + } else { + println!("\u{274c} Keycard is not available."); + } + }); + + Ok(SubcommandReturnValue::Empty) + } + Self::Load { mnemonic, pin } => { + Python::with_gil(|py| { + python_path::add_python_path(py).expect("keycard_wallet.py not found"); + + let wallet = KeycardWallet::new(py).expect("Expect keycard wallet"); + + let is_connected = wallet + .setup_communication(py, &pin.expect("TODO")) + .expect("Expect a Boolean."); + + if is_connected { + println!("\u{2705} Keycard is now connected to wallet."); + } else { + println!("\u{274c} Keycard is not connected to wallet."); + } + + let _ = wallet.load_mnemonic(py, &mnemonic.expect("TODO")); + + let _ = wallet.disconnect(py); + }); + + Ok(SubcommandReturnValue::Empty) + } + } + } +} diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 1653e938..1948db24 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -14,6 +14,7 @@ use crate::{ account::AccountSubcommand, chain::ChainSubcommand, config::ConfigSubcommand, + keycard::KeycardSubcommand, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, @@ -25,6 +26,7 @@ use crate::{ pub mod account; pub mod chain; pub mod config; +pub mod keycard; pub mod programs; pub(crate) trait WalletSubcommand { @@ -73,6 +75,8 @@ pub enum Command { }, /// Deploy a program. DeployProgram { binary_filepath: PathBuf }, + #[command(subcommand)] + Keycard(KeycardSubcommand), } /// To execute commands, env var `NSSA_WALLET_HOME_DIR` must be set into directory with config. @@ -121,6 +125,9 @@ pub async fn execute_subcommand( Command::Pinata(pinata_subcommand) => { pinata_subcommand.handle_subcommand(wallet_core).await? } + Command::Keycard(keycard_subcommand) => { + keycard_subcommand.handle_subcommand(wallet_core).await? + } Command::CheckHealth => { let remote_program_ids = wallet_core .sequencer_client diff --git a/wallet/src/cli/programs/amm.rs b/wallet/src/cli/programs/amm.rs index 0f8a0fff..aa0c5482 100644 --- a/wallet/src/cli/programs/amm.rs +++ b/wallet/src/cli/programs/amm.rs @@ -216,18 +216,24 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { user_holding_a_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_b = resolve_id_or_label( user_holding_b, user_holding_b_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_lp = resolve_id_or_label( user_holding_lp, user_holding_lp_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (user_holding_a, user_holding_a_privacy) = parse_addr_with_privacy_prefix(&user_holding_a)?; @@ -282,12 +288,16 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { user_holding_a_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_b = resolve_id_or_label( user_holding_b, user_holding_b_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (user_holding_a, user_holding_a_privacy) = parse_addr_with_privacy_prefix(&user_holding_a)?; @@ -368,18 +378,24 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { user_holding_a_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_b = resolve_id_or_label( user_holding_b, user_holding_b_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_lp = resolve_id_or_label( user_holding_lp, user_holding_lp_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (user_holding_a, user_holding_a_privacy) = parse_addr_with_privacy_prefix(&user_holding_a)?; @@ -437,18 +453,24 @@ impl WalletSubcommand for AmmProgramAgnosticSubcommand { user_holding_a_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_b = resolve_id_or_label( user_holding_b, user_holding_b_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let user_holding_lp = resolve_id_or_label( user_holding_lp, user_holding_lp_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (user_holding_a, user_holding_a_privacy) = parse_addr_with_privacy_prefix(&user_holding_a)?; diff --git a/wallet/src/cli/programs/native_token_transfer.rs b/wallet/src/cli/programs/native_token_transfer.rs index 41008eac..3bd60464 100644 --- a/wallet/src/cli/programs/native_token_transfer.rs +++ b/wallet/src/cli/programs/native_token_transfer.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::Subcommand; use common::transaction::NSSATransaction; +use keycard_wallet::KeycardWallet; use nssa::AccountId; use crate::{ @@ -23,12 +24,16 @@ pub enum AuthTransferSubcommand { #[arg( long, conflicts_with = "account_label", - required_unless_present = "account_label" + // required_unless_present = "account_label" )] account_id: Option, /// Account label (alternative to --account-id). #[arg(long, conflicts_with = "account_id")] account_label: Option, + #[arg(long)] + pin: Option, + #[arg(long, conflicts_with = "account_id", conflicts_with = "account_label")] + key_path: Option, }, /// Send native tokens from one account to another with variable privacy. /// @@ -38,11 +43,7 @@ pub enum AuthTransferSubcommand { /// First is used for owned accounts, second otherwise. Send { /// from - valid 32 byte base58 string with privacy prefix. - #[arg( - long, - conflicts_with = "from_label", - required_unless_present = "from_label" - )] + #[arg(long, conflicts_with = "from_label")] from: Option, /// From account label (alternative to --from). #[arg(long, conflicts_with = "from")] @@ -62,6 +63,12 @@ pub enum AuthTransferSubcommand { /// amount - amount of balance to move. #[arg(long)] amount: u128, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + pin: Option, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + from_key_path: Option, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + to_key_path: Option, }, } @@ -74,13 +81,18 @@ impl WalletSubcommand for AuthTransferSubcommand { Self::Init { account_id, account_label, + pin, + key_path, } => { let resolved = resolve_id_or_label( account_id, account_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &pin, + &key_path, )?; + let (account_id, addr_privacy) = parse_addr_with_privacy_prefix(&resolved)?; match addr_privacy { @@ -88,7 +100,7 @@ impl WalletSubcommand for AuthTransferSubcommand { let account_id = account_id.parse()?; let tx_hash = NativeTokenTransfer(wallet_core) - .register_account(account_id) + .register_account(account_id, &pin, &key_path) .await?; println!("Transaction hash is {tx_hash}"); @@ -133,24 +145,37 @@ impl WalletSubcommand for AuthTransferSubcommand { to_npk, to_vpk, amount, + pin, + from_key_path, + to_key_path, } => { let from = resolve_id_or_label( from, from_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &pin, + &from_key_path, )?; - let to = match (to, to_label) { - (v, None) => v, - (None, Some(label)) => Some(resolve_account_label( + + let to = match (to, to_label, to_key_path) { + (v, None, None) => v, + (None, Some(label), None) => Some(resolve_account_label( &label, &wallet_core.storage.labels, &wallet_core.storage.user_data, )?), - (Some(_), Some(_)) => { + (None, None, Some(to_key_path)) => { + Some(KeycardWallet::get_account_id_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &to_key_path, + )) + } + _ => { anyhow::bail!("Provide only one of --to or --to-label") } }; + let underlying_subcommand = match (to, to_npk, to_vpk) { (None, None, None) => { anyhow::bail!( @@ -171,7 +196,13 @@ impl WalletSubcommand for AuthTransferSubcommand { match (from_privacy, to_privacy) { (AccountPrivacyKind::Public, AccountPrivacyKind::Public) => { - NativeTokenTransferProgramSubcommand::Public { from, to, amount } + NativeTokenTransferProgramSubcommand::Public { + from, + to, + amount, + pin, + key_path: from_key_path, + } } (AccountPrivacyKind::Private, AccountPrivacyKind::Private) => { NativeTokenTransferProgramSubcommand::Private( @@ -195,6 +226,8 @@ impl WalletSubcommand for AuthTransferSubcommand { from, to, amount, + pin, + key_path: from_key_path, }, ) } @@ -221,6 +254,8 @@ impl WalletSubcommand for AuthTransferSubcommand { to_npk, to_vpk, amount, + pin, + key_path: from_key_path, }, ) } @@ -250,6 +285,10 @@ pub enum NativeTokenTransferProgramSubcommand { /// amount - amount of balance to move. #[arg(long)] amount: u128, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + pin: Option, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + key_path: Option, }, /// Private execution. #[command(subcommand)] @@ -290,6 +329,10 @@ pub enum NativeTokenTransferProgramSubcommandShielded { /// amount - amount of balance to move. #[arg(long)] amount: u128, + #[arg(long)] + pin: Option, + #[arg(long)] + key_path: Option, }, /// Send native token transfer from `from` to `to` for `amount`. /// @@ -307,6 +350,10 @@ pub enum NativeTokenTransferProgramSubcommandShielded { /// amount - amount of balance to move. #[arg(long)] amount: u128, + #[arg(long)] + pin: Option, + #[arg(long)] + key_path: Option, }, } @@ -427,13 +474,19 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { wallet_core: &mut WalletCore, ) -> Result { match self { - Self::ShieldedOwned { from, to, amount } => { + Self::ShieldedOwned { + from, + to, + amount, + pin, + key_path, + } => { let from: AccountId = from.parse().unwrap(); let to: AccountId = to.parse().unwrap(); let (tx_hash, secret) = NativeTokenTransfer(wallet_core) - .send_shielded_transfer(from, to, amount) - .await?; + .send_shielded_transfer(from, to, amount, &pin, &key_path) + .await?; //TODO: here (marvin) println!("Transaction hash is {tx_hash}"); @@ -457,6 +510,8 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { to_npk, to_vpk, amount, + pin, + key_path, } => { let from: AccountId = from.parse().unwrap(); @@ -472,7 +527,9 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { nssa_core::encryption::shared_key_derivation::Secp256k1Point(to_vpk.to_vec()); let (tx_hash, _) = NativeTokenTransfer(wallet_core) - .send_shielded_transfer_to_outer_account(from, to_npk, to_vpk, amount) + .send_shielded_transfer_to_outer_account( + from, to_npk, to_vpk, amount, &pin, &key_path, + ) .await?; println!("Transaction hash is {tx_hash}"); @@ -522,12 +579,18 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommand { Ok(SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash }) } - Self::Public { from, to, amount } => { + Self::Public { + from, + to, + amount, + pin, + key_path, + } => { let from: AccountId = from.parse().unwrap(); let to: AccountId = to.parse().unwrap(); let tx_hash = NativeTokenTransfer(wallet_core) - .send_public_transfer(from, to, amount) + .send_public_transfer(from, to, amount, &pin, &key_path) .await?; println!("Transaction hash is {tx_hash}"); diff --git a/wallet/src/cli/programs/pinata.rs b/wallet/src/cli/programs/pinata.rs index 6171a0f2..e41c0fd8 100644 --- a/wallet/src/cli/programs/pinata.rs +++ b/wallet/src/cli/programs/pinata.rs @@ -17,15 +17,15 @@ pub enum PinataProgramAgnosticSubcommand { /// Claim pinata. Claim { /// to - valid 32 byte base58 string with privacy prefix. - #[arg( - long, - conflicts_with = "to_label", - required_unless_present = "to_label" - )] + #[arg(long, conflicts_with = "to_label")] to: Option, /// To account label (alternative to --to). #[arg(long, conflicts_with = "to")] to_label: Option, + #[arg(long, conflicts_with = "to", conflicts_with = "to_label")] + pin: Option, + #[arg(long, conflicts_with = "to", conflicts_with = "to_label")] + key_path: Option, }, } @@ -35,15 +35,22 @@ impl WalletSubcommand for PinataProgramAgnosticSubcommand { wallet_core: &mut WalletCore, ) -> Result { let underlying_subcommand = match self { - Self::Claim { to, to_label } => { + Self::Claim { + to, + to_label, + pin, + key_path, + } => { let to = resolve_id_or_label( to, to_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &pin, + &key_path, )?; - let (to, to_addr_privacy) = parse_addr_with_privacy_prefix(&to)?; + let (to, to_addr_privacy) = parse_addr_with_privacy_prefix(&to)?; match to_addr_privacy { AccountPrivacyKind::Public => { PinataProgramSubcommand::Public(PinataProgramSubcommandPublic::Claim { diff --git a/wallet/src/cli/programs/token.rs b/wallet/src/cli/programs/token.rs index 0575da09..436b54a8 100644 --- a/wallet/src/cli/programs/token.rs +++ b/wallet/src/cli/programs/token.rs @@ -76,6 +76,10 @@ pub enum TokenProgramAgnosticSubcommand { /// amount - amount of balance to move. #[arg(long)] amount: u128, + #[arg(long)] + pin: Option, + #[arg(long, conflicts_with = "from", conflicts_with = "from_label")] + from_key_path: Option, }, /// Burn tokens on `holder`, modify `definition`. /// @@ -95,11 +99,7 @@ pub enum TokenProgramAgnosticSubcommand { #[arg(long, conflicts_with = "definition")] definition_label: Option, /// holder - valid 32 byte base58 string with privacy prefix. - #[arg( - long, - conflicts_with = "holder_label", - required_unless_present = "holder_label" - )] + #[arg(long, conflicts_with = "holder_label")] holder: Option, /// Holder account label (alternative to --holder). #[arg(long, conflicts_with = "holder")] @@ -107,6 +107,10 @@ pub enum TokenProgramAgnosticSubcommand { /// amount - amount of balance to burn. #[arg(long)] amount: u128, + #[arg(long, conflicts_with = "holder", conflicts_with = "holder_label")] + holder_pin: Option, + #[arg(long, conflicts_with = "holder", conflicts_with = "holder_label")] + holder_key_path: Option, }, /// Mint tokens on `holder`, modify `definition`. /// @@ -142,6 +146,10 @@ pub enum TokenProgramAgnosticSubcommand { /// amount - amount of balance to mint. #[arg(long)] amount: u128, + #[arg(long, conflicts_with = "holder", conflicts_with = "holder_label")] + holder_pin: Option, + #[arg(long, conflicts_with = "holder", conflicts_with = "holder_label")] + holder_key_path: Option, }, } @@ -164,12 +172,16 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { definition_account_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let supply_account_id = resolve_id_or_label( supply_account_id, supply_account_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let (definition_account_id, definition_addr_privacy) = parse_addr_with_privacy_prefix(&definition_account_id)?; @@ -229,12 +241,16 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { to_npk, to_vpk, amount, + pin, + from_key_path, } => { let from = resolve_id_or_label( from, from_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &pin, + &from_key_path, )?; let to = match (to, to_label) { (v, None) => v, @@ -272,6 +288,8 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { sender_account_id: from, recipient_account_id: to, balance_to_move: amount, + pin, + sender_key_path: from_key_path, }, ) } @@ -336,24 +354,29 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { holder, holder_label, amount, + holder_pin, + holder_key_path, } => { let definition = resolve_id_or_label( definition, definition_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; let holder = resolve_id_or_label( holder, holder_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &holder_pin, + &holder_key_path, )?; let underlying_subcommand = { let (definition, definition_privacy) = parse_addr_with_privacy_prefix(&definition)?; let (holder, holder_privacy) = parse_addr_with_privacy_prefix(&holder)?; - match (definition_privacy, holder_privacy) { (AccountPrivacyKind::Public, AccountPrivacyKind::Public) => { TokenProgramSubcommand::Public( @@ -404,24 +427,25 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { holder_npk, holder_vpk, amount, + holder_pin, + holder_key_path, } => { let definition = resolve_id_or_label( definition, definition_label, &wallet_core.storage.labels, &wallet_core.storage.user_data, + &None, + &None, )?; - let holder = match (holder, holder_label) { - (v, None) => v, - (None, Some(label)) => Some(resolve_account_label( - &label, - &wallet_core.storage.labels, - &wallet_core.storage.user_data, - )?), - (Some(_), Some(_)) => { - anyhow::bail!("Provide only one of --holder or --holder-label") - } - }; + let holder = Some(resolve_id_or_label( + holder.clone(), + holder_label.clone(), + &wallet_core.storage.labels, + &wallet_core.storage.user_data, + &holder_pin, + &holder_key_path, + )?); let underlying_subcommand = match (holder, holder_npk, holder_vpk) { (None, None, None) => { anyhow::bail!( @@ -542,6 +566,10 @@ pub enum TokenProgramSubcommandPublic { recipient_account_id: String, #[arg(short, long)] balance_to_move: u128, + #[arg(long)] + pin: Option, + #[arg(long)] + sender_key_path: Option, }, // Burn tokens using the token program BurnToken { @@ -774,12 +802,16 @@ impl WalletSubcommand for TokenProgramSubcommandPublic { sender_account_id, recipient_account_id, balance_to_move, + pin, + sender_key_path, } => { Token(wallet_core) .send_transfer_transaction( sender_account_id.parse().unwrap(), recipient_account_id.parse().unwrap(), balance_to_move, + pin, + sender_key_path, ) .await?; Ok(SubcommandReturnValue::Empty) diff --git a/wallet/src/helperfunctions.rs b/wallet/src/helperfunctions.rs index 3e304253..fcf19863 100644 --- a/wallet/src/helperfunctions.rs +++ b/wallet/src/helperfunctions.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, path::PathBuf, str::FromStr as _}; use anyhow::{Context as _, Result}; use base58::ToBase58 as _; use key_protocol::key_protocol_core::NSSAUserData; +use keycard_wallet::KeycardWallet; use nssa::Account; use nssa_core::account::Nonce; use rand::{RngCore as _, rngs::OsRng}; @@ -60,11 +61,18 @@ pub fn resolve_id_or_label( label: Option, labels: &HashMap, user_data: &NSSAUserData, + pin: &Option, + key_path: &Option, ) -> Result { - match (id, label) { - (Some(id), None) => Ok(id), - (None, Some(label)) => resolve_account_label(&label, labels, user_data), - _ => anyhow::bail!("provide exactly one of account id or account label"), + match (id, label, pin) { + (Some(id), None, None) => Ok(id), + (None, Some(label), None) => resolve_account_label(&label, labels, user_data), + (None, None, Some(pin)) => Ok(KeycardWallet::get_account_id_for_path_with_connect( + pin, + key_path.as_ref().expect("TODO"), + ) + .to_string()), + _ => anyhow::bail!("provide exactly one of account id, account label or keycard path"), } } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index c9cf57f1..3c82ec92 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -16,16 +16,19 @@ use chain_storage::WalletChainStore; use common::{HashType, transaction::NSSATransaction}; use config::WalletConfig; use key_protocol::key_management::key_tree::{chain_index::ChainIndex, traits::KeyNode as _}; +use keycard_wallet::KeycardWallet; use log::info; use nssa::{ - Account, AccountId, PrivacyPreservingTransaction, + Account, AccountId, PrivacyPreservingTransaction, PublicKey, Signature, privacy_preserving_transaction::{ circuit::{ProgramWithDependencies, Proof}, message::EncryptedAccountData, }, }; use nssa_core::{ - Commitment, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData, + Commitment, MembershipProof, SharedSecretKey, + account::{AccountWithMetadata, Nonce}, + program::InstructionData, }; pub use privacy_preserving_tx::PrivacyPreservingAccount; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; @@ -33,7 +36,7 @@ use tokio::io::AsyncWriteExt as _; use crate::{ config::{PersistentStorage, WalletConfigOverrides}, - helperfunctions::produce_data_for_storage, + helperfunctions::{parse_addr_with_privacy_prefix, produce_data_for_storage}, poller::TxPoller, }; @@ -363,10 +366,17 @@ impl WalletCore { accounts: Vec, instruction_data: InstructionData, program: &ProgramWithDependencies, + pin: &Option, + key_path: &Option, ) -> Result<(HashType, Vec), ExecutionFailureKind> { - self.send_privacy_preserving_tx_with_pre_check(accounts, instruction_data, program, |_| { - Ok(()) - }) + self.send_privacy_preserving_tx_with_pre_check( + accounts, + instruction_data, + program, + |_| Ok(()), + pin, + key_path, + ) .await } @@ -376,10 +386,53 @@ impl WalletCore { instruction_data: InstructionData, program: &ProgramWithDependencies, tx_pre_check: impl FnOnce(&[&Account]) -> Result<(), ExecutionFailureKind>, + pin: &Option, + key_path: &Option, ) -> Result<(HashType, Vec), ExecutionFailureKind> { let acc_manager = privacy_preserving_tx::AccountManager::new(self, accounts).await?; - let pre_states = acc_manager.pre_states(); + let mut pre_states = acc_manager.pre_states(); + + let keycard_account = if let Some(pin) = pin.as_ref() { + let account_id = KeycardWallet::get_account_id_for_path_with_connect( + pin, + key_path.as_ref().expect("TODO"), + ); + + let (acc_id, _) = + parse_addr_with_privacy_prefix(&account_id).expect("Valid parsing of account id"); + + let account_id = acc_id.parse().expect("Expect a valid Account Id"); + let account = self + .get_account_public(account_id) + .await + .expect("Expect valid account"); + + Some(AccountWithMetadata { + account, + is_authorized: true, + account_id, + }) + } else { + None + }; + + let mut nonces: Vec = acc_manager.public_account_nonces().into_iter().collect(); + + let mut account_ids: Vec = acc_manager.public_account_ids(); + + let mut visibility_mask = acc_manager.visibility_mask().to_vec(); + + if let Some(acc) = keycard_account.as_ref() { + nonces.push(acc.account.nonce); + + account_ids.push(acc.account_id); + + visibility_mask.push(0); + + pre_states.push(acc.clone()); + } + tx_pre_check( &pre_states .iter() @@ -391,7 +444,7 @@ impl WalletCore { let (output, proof) = nssa::privacy_preserving_transaction::circuit::execute_and_prove( pre_states, instruction_data, - acc_manager.visibility_mask().to_vec(), + visibility_mask, private_account_keys .iter() .map(|keys| (keys.npk, keys.ssk)) @@ -404,8 +457,8 @@ impl WalletCore { let message = nssa::privacy_preserving_transaction::message::Message::try_from_circuit_output( - acc_manager.public_account_ids(), - Vec::from_iter(acc_manager.public_account_nonces()), + account_ids, + nonces, private_account_keys .iter() .map(|keys| (keys.npk, keys.vpk.clone(), keys.epk.clone())) @@ -414,7 +467,7 @@ impl WalletCore { ) .unwrap(); - let witness_set = Self::sign_privacy_message(&message, &proof, &acc_manager) + let witness_set = Self::sign_privacy_message(&message, &proof, &acc_manager, pin, key_path) .expect("Expect a valid witness set"); let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -574,13 +627,76 @@ impl WalletCore { message: &nssa::privacy_preserving_transaction::Message, proof: &Proof, acc_manager: &privacy_preserving_tx::AccountManager, + pin: &Option, + key_path: &Option, ) -> Result { + if pin.is_none() { + Ok( + nssa::privacy_preserving_transaction::witness_set::WitnessSet::for_message( + message, + proof.clone(), + &acc_manager.public_account_auth(), + ), + ) + } else { + let public_key = KeycardWallet::get_public_key_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &key_path.as_ref().expect("TODO"), + ); + let signature = KeycardWallet::sign_message_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &key_path.as_ref().expect("TODO"), + &message.hash_message(), + ) + .expect("Expect a valid signature"); + let mut signatures = Vec::::new(); + signatures.push(signature); + let mut public_keys = Vec::::new(); + public_keys.push(public_key); + Ok( + nssa::privacy_preserving_transaction::witness_set::WitnessSet::from_list( + proof.clone(), + &signatures, + &public_keys, + ), + ) + } + } + + pub fn sign_privacy_message_with_keycard( + message: &nssa::privacy_preserving_transaction::Message, + proof: Proof, + pin: &String, + key_paths: &[String], + ) -> Result + { + let mut signatures = Vec::::new(); + let mut public_keys = Vec::::new(); + + let message_bytes: [u8; 32] = { + let v = message.to_bytes(); + let mut bytes = [0_u8; 32]; + let len = v.len().min(32); + bytes[..len].copy_from_slice(&v[..len]); + bytes + }; + + for path in key_paths.iter() { + public_keys.push(KeycardWallet::get_public_key_for_path_with_connect( + &pin, &path, + )); + signatures.push( + KeycardWallet::sign_message_for_path_with_connect(&pin, &path, &message_bytes) + .expect("Expect a valid signature"), + ); + } + Ok( - nssa::privacy_preserving_transaction::witness_set::WitnessSet::for_message( - message, - proof.clone(), - &acc_manager.public_account_auth(), + nssa::privacy_preserving_transaction::witness_set::WitnessSet::from_list( + proof, + &signatures, + &public_keys, ), ) } diff --git a/wallet/src/program_facades/ata.rs b/wallet/src/program_facades/ata.rs index ac60fb63..a72d50a9 100644 --- a/wallet/src/program_facades/ata.rs +++ b/wallet/src/program_facades/ata.rs @@ -194,7 +194,13 @@ impl Ata<'_> { ]; self.0 - .send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency()) + .send_privacy_preserving_tx( + accounts, + instruction_data, + &ata_with_token_dependency(), + &None, + &None, + ) .await .map(|(hash, mut secrets)| { let secret = secrets.pop().expect("expected owner's secret"); @@ -229,7 +235,13 @@ impl Ata<'_> { ]; self.0 - .send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency()) + .send_privacy_preserving_tx( + accounts, + instruction_data, + &ata_with_token_dependency(), + &None, + &None, + ) .await .map(|(hash, mut secrets)| { let secret = secrets.pop().expect("expected owner's secret"); @@ -263,7 +275,13 @@ impl Ata<'_> { ]; self.0 - .send_privacy_preserving_tx(accounts, instruction_data, &ata_with_token_dependency()) + .send_privacy_preserving_tx( + accounts, + instruction_data, + &ata_with_token_dependency(), + &None, + &None, + ) .await .map(|(hash, mut secrets)| { let secret = secrets.pop().expect("expected owner's secret"); diff --git a/wallet/src/program_facades/native_token_transfer/deshielded.rs b/wallet/src/program_facades/native_token_transfer/deshielded.rs index d51f15ce..e7abbb3e 100644 --- a/wallet/src/program_facades/native_token_transfer/deshielded.rs +++ b/wallet/src/program_facades/native_token_transfer/deshielded.rs @@ -22,6 +22,8 @@ impl NativeTokenTransfer<'_> { instruction_data, &program.into(), tx_pre_check, + &None, + &None, ) .await .map(|(resp, secrets)| { diff --git a/wallet/src/program_facades/native_token_transfer/private.rs b/wallet/src/program_facades/native_token_transfer/private.rs index c3a2125b..dbf9f5bf 100644 --- a/wallet/src/program_facades/native_token_transfer/private.rs +++ b/wallet/src/program_facades/native_token_transfer/private.rs @@ -19,6 +19,8 @@ impl NativeTokenTransfer<'_> { vec![PrivacyPreservingAccount::PrivateOwned(from)], Program::serialize_instruction(instruction).unwrap(), &Program::authenticated_transfer_program().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -49,6 +51,8 @@ impl NativeTokenTransfer<'_> { instruction_data, &program.into(), tx_pre_check, + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -76,6 +80,8 @@ impl NativeTokenTransfer<'_> { instruction_data, &program.into(), tx_pre_check, + &None, + &None, ) .await .map(|(resp, secrets)| { diff --git a/wallet/src/program_facades/native_token_transfer/public.rs b/wallet/src/program_facades/native_token_transfer/public.rs index 7705c268..1ee1da6f 100644 --- a/wallet/src/program_facades/native_token_transfer/public.rs +++ b/wallet/src/program_facades/native_token_transfer/public.rs @@ -1,4 +1,5 @@ use common::{HashType, transaction::NSSATransaction}; +use keycard_wallet::KeycardWallet; use nssa::{ AccountId, PublicTransaction, program::Program, @@ -15,6 +16,8 @@ impl NativeTokenTransfer<'_> { from: AccountId, to: AccountId, balance_to_move: u128, + pin: &Option, + key_path: &Option, ) -> Result { let balance = self .0 @@ -52,8 +55,22 @@ impl NativeTokenTransfer<'_> { let message = Message::try_new(program_id, account_ids, nonces, balance_to_move).unwrap(); - let witness_set = WalletCore::sign_public_message(self.0, &message, &sign_ids) - .expect("Expect a valid signature"); + let witness_set = if pin.is_none() { + WalletCore::sign_public_message(self.0, &message, &sign_ids) + .expect("Expect a valid signature") + } else { + let pub_key = KeycardWallet::get_public_key_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &key_path.as_ref().expect("TODO"), + ); + let signature = KeycardWallet::sign_message_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &key_path.as_ref().expect("TODO"), + &message.hash_message(), + ) + .expect("Expect valid signature"); + WitnessSet::from_list(&[signature], &[pub_key]) + }; let tx = PublicTransaction::new(message, witness_set); @@ -70,6 +87,8 @@ impl NativeTokenTransfer<'_> { pub async fn register_account( &self, from: AccountId, + pin: &Option, // Used by Keycard. + key_path: &Option, // Used by Keycard. ) -> Result { let nonces = self .0 @@ -80,16 +99,32 @@ impl NativeTokenTransfer<'_> { let instruction: u128 = 0; let account_ids = vec![from]; let program_id = Program::authenticated_transfer_program().id(); - let message = Message::try_new(program_id, account_ids, nonces, instruction).unwrap(); + let message = Message::try_new(program_id, account_ids, nonces, instruction) + .expect("Expect a valid Message"); - let signing_key = self.0.storage.user_data.get_pub_account_signing_key(from); + let witness_set = if pin.is_none() { + let signing_key = self.0.storage.user_data.get_pub_account_signing_key(from); - let Some(signing_key) = signing_key else { - return Err(ExecutionFailureKind::KeyNotFoundError); + let Some(signing_key) = signing_key else { + return Err(ExecutionFailureKind::KeyNotFoundError); + }; + + WitnessSet::for_message(&message, &[signing_key]) + } else { + let pub_key = KeycardWallet::get_public_key_for_path_with_connect( + pin.as_ref().expect("TODO"), + key_path.as_ref().expect("TODO"), + ); + + let signature = KeycardWallet::sign_message_for_path_with_connect( + pin.as_ref().as_ref().expect("TODO"), + key_path.as_ref().expect("TODO"), + &message.hash_message(), + ) + .expect("Expect a valid Signature."); + WitnessSet::from_list(&[signature], &[pub_key]) }; - let witness_set = WitnessSet::for_message(&message, &[signing_key]); - let tx = PublicTransaction::new(message, witness_set); Ok(self diff --git a/wallet/src/program_facades/native_token_transfer/shielded.rs b/wallet/src/program_facades/native_token_transfer/shielded.rs index 625e1a8b..48d90820 100644 --- a/wallet/src/program_facades/native_token_transfer/shielded.rs +++ b/wallet/src/program_facades/native_token_transfer/shielded.rs @@ -11,6 +11,8 @@ impl NativeTokenTransfer<'_> { from: AccountId, to: AccountId, balance_to_move: u128, + pin: &Option, + from_key_path: &Option, ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); @@ -23,6 +25,8 @@ impl NativeTokenTransfer<'_> { instruction_data, &program.into(), tx_pre_check, + pin, + from_key_path, ) .await .map(|(resp, secrets)| { @@ -40,6 +44,8 @@ impl NativeTokenTransfer<'_> { to_npk: NullifierPublicKey, to_vpk: ViewingPublicKey, balance_to_move: u128, + pin: &Option, + from_key_path: &Option, ) -> Result<(HashType, SharedSecretKey), ExecutionFailureKind> { let (instruction_data, program, tx_pre_check) = auth_transfer_preparation(balance_to_move); @@ -55,6 +61,8 @@ impl NativeTokenTransfer<'_> { instruction_data, &program.into(), tx_pre_check, + pin, + from_key_path, ) .await .map(|(resp, secrets)| { diff --git a/wallet/src/program_facades/pinata.rs b/wallet/src/program_facades/pinata.rs index 97118ecd..f30fcd5c 100644 --- a/wallet/src/program_facades/pinata.rs +++ b/wallet/src/program_facades/pinata.rs @@ -60,6 +60,8 @@ impl Pinata<'_> { ], nssa::program::Program::serialize_instruction(solution).unwrap(), &nssa::program::Program::pinata().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { diff --git a/wallet/src/program_facades/token.rs b/wallet/src/program_facades/token.rs index 1f941c8c..36d7557b 100644 --- a/wallet/src/program_facades/token.rs +++ b/wallet/src/program_facades/token.rs @@ -1,5 +1,6 @@ use common::{HashType, transaction::NSSATransaction}; -use nssa::{AccountId, program::Program}; +use keycard_wallet::KeycardWallet; +use nssa::{AccountId, program::Program, public_transaction::WitnessSet}; use nssa_core::{NullifierPublicKey, SharedSecretKey, encryption::ViewingPublicKey}; use sequencer_service_rpc::RpcClient as _; use token_core::Instruction; @@ -78,6 +79,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -108,6 +111,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -138,6 +143,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -153,6 +160,8 @@ impl Token<'_> { sender_account_id: AccountId, recipient_account_id: AccountId, amount: u128, + pin: Option, + sender_key_path: Option, ) -> Result { let account_ids = vec![sender_account_id, recipient_account_id]; let program_id = nssa::program::Program::token().id(); @@ -164,34 +173,12 @@ impl Token<'_> { .get_accounts_nonces(vec![sender_account_id]) .await .map_err(ExecutionFailureKind::SequencerError)?; - - let mut private_keys = Vec::new(); - let sender_sk = self + let recipient_nonces = self .0 - .storage - .user_data - .get_pub_account_signing_key(sender_account_id) - .ok_or(ExecutionFailureKind::KeyNotFoundError)?; - private_keys.push(sender_sk); - - if let Some(recipient_sk) = self - .0 - .storage - .user_data - .get_pub_account_signing_key(recipient_account_id) - { - private_keys.push(recipient_sk); - let recipient_nonces = self - .0 - .get_accounts_nonces(vec![recipient_account_id]) - .await - .map_err(ExecutionFailureKind::SequencerError)?; - nonces.extend(recipient_nonces); - } else { - println!( - "Receiver's account ({recipient_account_id}) private key not found in wallet. Proceeding with only sender's key." - ); - } + .get_accounts_nonces(vec![recipient_account_id]) + .await + .map_err(ExecutionFailureKind::SequencerError)?; + nonces.extend(recipient_nonces); let message = nssa::public_transaction::Message::try_new( program_id, @@ -200,8 +187,44 @@ impl Token<'_> { instruction, ) .unwrap(); - let witness_set = - nssa::public_transaction::WitnessSet::for_message(&message, &private_keys); + + let witness_set = if pin.is_none() { + let mut private_keys = Vec::new(); + let sender_sk = self + .0 + .storage + .user_data + .get_pub_account_signing_key(sender_account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + private_keys.push(sender_sk); + + if let Some(recipient_sk) = self + .0 + .storage + .user_data + .get_pub_account_signing_key(recipient_account_id) + { + private_keys.push(recipient_sk); + } else { + println!( + "Receiver's account ({recipient_account_id}) private key not found in wallet. Proceeding with only sender's key." + ); + } + + nssa::public_transaction::WitnessSet::for_message(&message, &private_keys) + } else { + let sender_public_key = KeycardWallet::get_public_key_for_path_with_connect( + &pin.as_ref().expect("TODO"), + &sender_key_path.as_ref().expect("TODO"), + ); + let signature = KeycardWallet::sign_message_for_path_with_connect( + &pin.expect("TODO"), + &sender_key_path.expect("TODO"), + &message.hash_message(), + ) + .expect("Expect a valid signature"); + WitnessSet::from_list(&[signature], &[sender_public_key]) + }; let tx = nssa::PublicTransaction::new(message, witness_set); @@ -232,6 +255,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -266,6 +291,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -296,6 +323,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -327,6 +356,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -362,6 +393,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -435,6 +468,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -465,6 +500,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -496,6 +533,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -591,6 +630,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -625,6 +666,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -655,6 +698,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -686,6 +731,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { @@ -721,6 +768,8 @@ impl Token<'_> { ], instruction_data, &Program::token().into(), + &None, + &None, ) .await .map(|(resp, secrets)| { diff --git a/wallet_with_keycard.sh b/wallet_with_keycard.sh new file mode 100644 index 00000000..94c10d4c --- /dev/null +++ b/wallet_with_keycard.sh @@ -0,0 +1,21 @@ +cargo install --path wallet --force + + +python3 -m venv venv +source venv/bin/activate +python3 -m pip install pyscard +python3 -m pip install mnemonic +python3 -m pip install ecdsa +python3 -m pip install pyaes + +cd python + +# Need to use local version till fix applet +# git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git +cd keycard-py +python3 -m venv venv +source venv/bin/activate +pip install -e . + + +